Compare commits
47 Commits
python-sec
...
docs-token
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b2a3a4a16 | ||
|
|
793bdf79ab | ||
|
|
8acf58c464 | ||
|
|
505057635d | ||
|
|
f71b169128 | ||
|
|
4709110720 | ||
|
|
409eb9638f | ||
|
|
560aba706f | ||
|
|
400cca7fe5 | ||
|
|
226ab1bf62 | ||
|
|
a97dc5cf61 | ||
|
|
c05f5ec9eb | ||
|
|
536e71d4cb | ||
|
|
f04c507ac4 | ||
|
|
949cddcd2b | ||
|
|
cc8e2c332a | ||
|
|
3ae7763a1b | ||
|
|
3edc9d2259 | ||
|
|
a0e7336119 | ||
|
|
d810202021 | ||
|
|
82ede379eb | ||
|
|
70ac109c30 | ||
|
|
9a277a649e | ||
|
|
c43fc5726e | ||
|
|
b21072994c | ||
|
|
9ab49864c1 | ||
|
|
8e02fb7dd2 | ||
|
|
b53131f473 | ||
|
|
4805c9f244 | ||
|
|
894a2fb895 | ||
|
|
52517517f5 | ||
|
|
52aba51798 | ||
|
|
9a33050c61 | ||
|
|
381fa1fdbd | ||
|
|
bf17242cb9 | ||
|
|
f1336e039f | ||
|
|
1233934a0d | ||
|
|
5bdc6d7000 | ||
|
|
81ac3c9ae5 | ||
|
|
dba578efad | ||
|
|
0d66452695 | ||
|
|
9edb741033 | ||
|
|
c62d68c201 | ||
|
|
6390717ce9 | ||
|
|
27abb898a5 | ||
|
|
c70bd40481 | ||
|
|
2e9f645974 |
@@ -1735,6 +1735,7 @@
|
||||
lib-be
|
||||
models
|
||||
native-query-snippets
|
||||
permissions
|
||||
premium-features
|
||||
queries
|
||||
query-processor
|
||||
|
||||
7
.github/actions/test-driver/action.yml
vendored
@@ -35,7 +35,10 @@ runs:
|
||||
continue-on-error: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.draft == true) }}
|
||||
- name: Publish Test Report (JUnit)
|
||||
uses: dorny/test-reporter@v1
|
||||
if: ${{ steps.run-test.outcome == 'failure' }}
|
||||
# As per the documentation: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context
|
||||
# test.outcome gives us access to the result before `continue-on-error` overrides it and sets it to 'success'.
|
||||
# We want test reports even if the test step gets cancelled (e.g. times out), hence we match anything that is not a 'success'.
|
||||
if: steps.run-test.outcome != 'success'
|
||||
with:
|
||||
path: "target/junit/**/*_test.xml"
|
||||
name: JUnit Test Report ${{ inputs.junit-name }}
|
||||
@@ -44,7 +47,7 @@ runs:
|
||||
list-tests: failed
|
||||
fail-on-error: false
|
||||
- name: Upload Logs on Failure
|
||||
if: ${{ steps.run-test.outcome == 'failure' }}
|
||||
if: always() && steps.run-test.outcome != 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-logs-${{ github.job }}
|
||||
|
||||
2
.github/codeql/codeql-config.yml
vendored
@@ -1,3 +1,5 @@
|
||||
version: 1
|
||||
|
||||
name: "Metabase CodeQL config"
|
||||
|
||||
paths-ignore:
|
||||
|
||||
5
.github/file-paths.yaml
vendored
@@ -127,10 +127,6 @@ e2e_ci: &e2e_ci
|
||||
e2e_specs: &e2e_specs
|
||||
- e2e/!(test-host-app)**/*.cy.*.{js,ts}
|
||||
|
||||
e2e_cross_version: &e2e_cross_version
|
||||
- ".github/workflows/e2e-cross-version.yml"
|
||||
- "e2e/test/scenarios/cross-version/**"
|
||||
|
||||
e2e_embedding_sdk:
|
||||
- *frontend_embedding_sdk_ci
|
||||
- *sources
|
||||
@@ -170,6 +166,7 @@ yaml:
|
||||
- "**/*.yaml"
|
||||
|
||||
codeql:
|
||||
- ".github/workflows/codeql.yml"
|
||||
- "frontend/src/**"
|
||||
- "enterprise/frontend/src/**"
|
||||
|
||||
|
||||
5
.github/workflows/codeql.yml
vendored
@@ -30,9 +30,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: javascript
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
trap-caching: false
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
10
.github/workflows/drivers.yml
vendored
@@ -390,10 +390,6 @@ jobs:
|
||||
name: H2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
- name: Test H2 driver
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
@@ -413,12 +409,6 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-mysql-mariadb:
|
||||
if: ${{ !inputs.skip }}
|
||||
|
||||
175
.github/workflows/e2e-cross-version.yml
vendored
@@ -1,175 +0,0 @@
|
||||
name: E2E Cross-version Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1-5'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
# don't run this workflow on a cron for forks
|
||||
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
e2e_cross_version: ${{ steps.changes.outputs.e2e_cross_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@v3.0.0
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
cross-version-testing:
|
||||
needs: files-changed
|
||||
if: ${{ needs.files-changed.outputs.e2e_cross_version == 'true' || github.event_name == 'schedule'}}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
version: [
|
||||
# Major OSS upgrade
|
||||
{ source: v0.42.2, target: v0.48.7 },
|
||||
# OSS upgrade
|
||||
{ source: v0.41.3.1, target: v0.42.2 },
|
||||
# EE upgrade
|
||||
{ source: v1.43.4, target: v1.44.0 },
|
||||
# EE downgrade
|
||||
{ source: v1.42.0, target: v1.41.3.1 },
|
||||
# Cross-edition upgrade
|
||||
{ source: v0.41.3.1, target: v1.42.2 }
|
||||
]
|
||||
fail-fast: false
|
||||
env:
|
||||
CROSS_VERSION_SOURCE: ${{ matrix.version.source }}
|
||||
CROSS_VERSION_TARGET: ${{ matrix.version.target }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set source Docker image to OSS
|
||||
run: |
|
||||
echo "DOCKER_SOURCE_IMAGE=metabase/metabase:${{ matrix.version.source }}" >> $GITHUB_ENV
|
||||
if: startsWith(matrix.version.source, 'v0')
|
||||
- name: Set source Docker image to EE
|
||||
run: |
|
||||
echo "DOCKER_SOURCE_IMAGE=metabase/metabase-enterprise:${{ matrix.version.source }}" >> $GITHUB_ENV
|
||||
if: startsWith(matrix.version.source, 'v1')
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Start Metabase ${{ matrix.version.source }}
|
||||
run: |
|
||||
docker run -d \
|
||||
-v $PWD/my-metabase:/metabase.db \
|
||||
-p 3000:3000 \
|
||||
-e MB_RUN_MODE=e2e \
|
||||
-e METASTORE_DEV_SERVER_URL=${{ secrets.METASTORE_DEV_SERVER_URL }} \
|
||||
-e CYPRESS_MB_ALL_FEATURES_TOKEN=${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }} \
|
||||
-e CYPRESS_MB_STARTER_CLOUD_TOKEN=${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }} \
|
||||
-e CYPRESS_MB_PRO_CLOUD_TOKEN=${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }} \
|
||||
-e CYPRESS_MB_PRO_SELF_HOSTED_TOKEN=${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }} \
|
||||
--name metabase-${{ matrix.version.source }} \
|
||||
${{ env.DOCKER_SOURCE_IMAGE }}
|
||||
- name: Wait for Metabase to start on port 3000
|
||||
run: while ! curl -s 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done
|
||||
- name: Prepare Cypress environment
|
||||
id: cypress-prep
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
- name: Run Cypress on the source
|
||||
run: |
|
||||
yarn cypress run \
|
||||
--browser ${{ steps.cypress-prep.outputs.chrome-path }} \
|
||||
--config-file e2e/test/scenarios/cross-version/source/shared/cross-version-source.config.js \
|
||||
--spec e2e/test/scenarios/cross-version/source/**/*.cy.spec.js
|
||||
- name: Stop Metabase ${{ matrix.version.source }}
|
||||
run: docker stop metabase-${{ matrix.version.source }}
|
||||
|
||||
- name: Set target Docker image to OSS
|
||||
run: |
|
||||
echo "DOCKER_TARGET_IMAGE=metabase/metabase:${{ matrix.version.source }}" >> $GITHUB_ENV
|
||||
if: startsWith(matrix.version.source, 'v0')
|
||||
- name: Set target Docker image to EE
|
||||
run: |
|
||||
echo "DOCKER_TARGET_IMAGE=metabase/metabase-enterprise:${{ matrix.version.source }}" >> $GITHUB_ENV
|
||||
if: startsWith(matrix.version.source, 'v1')
|
||||
- name: Start Metabase ${{ matrix.version.target }}
|
||||
run: |
|
||||
docker run -d \
|
||||
-v $PWD/my-metabase:/metabase.db \
|
||||
-p 3001:3000 \
|
||||
-e MB_RUN_MODE=e2e \
|
||||
-e METASTORE_DEV_SERVER_URL=${{ secrets.METASTORE_DEV_SERVER_URL }} \
|
||||
-e CYPRESS_MB_ALL_FEATURES_TOKEN=${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }} \
|
||||
-e CYPRESS_MB_STARTER_CLOUD_TOKEN=${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }} \
|
||||
-e CYPRESS_MB_PRO_CLOUD_TOKEN=${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }} \
|
||||
-e CYPRESS_MB_PRO_SELF_HOSTED_TOKEN=${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }} \
|
||||
--name metabase-${{ matrix.version.target }} \
|
||||
${{ env.DOCKER_TARGET_IMAGE }}
|
||||
- name: Wait for Metabase to start on port 3001
|
||||
run: while ! curl -s 'http://localhost:3001/api/health' | grep '{"status":"ok"}'; do sleep 1; done
|
||||
- name: Run Cypress on the target
|
||||
run: |
|
||||
yarn cypress run \
|
||||
--browser ${{ steps.cypress-prep.outputs.chrome-path }} \
|
||||
--config-file e2e/test/scenarios/cross-version/target/shared/cross-version-target.config.js \
|
||||
--spec e2e/test/scenarios/cross-version/target/**/*.cy.spec.js
|
||||
- name: Upload Cypress Artifacts upon failure
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: cypress-artifacts-${{ matrix.version.source}}-to-${{ matrix.version.target }}
|
||||
path: |
|
||||
./cypress
|
||||
if-no-files-found: ignore
|
||||
notify-on-failure:
|
||||
needs: cross-version-testing
|
||||
if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }}
|
||||
name: Cross-version test failure
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/Jod # 22.x.x
|
||||
- run: npm install @slack/web-api
|
||||
- name: notify slack of failure
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: | # js
|
||||
const FAILED_RUN_URL = "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}";
|
||||
const REF_NAME = "${{ github.ref_name }}";
|
||||
|
||||
// notify slack of failure
|
||||
const { WebClient } = require('@slack/web-api');
|
||||
const slack = new WebClient('${{ secrets.SLACK_BOT_TOKEN }}');
|
||||
|
||||
await slack.chat.postMessage({
|
||||
channel: 'engineering-ci',
|
||||
text: 'Cross-version Tests Have Failed',
|
||||
blocks: [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": `:broken_heart: Cross-version tests are failing on ${REF_NAME}`,
|
||||
"emoji": true,
|
||||
}
|
||||
},
|
||||
],
|
||||
attachments: [{
|
||||
color: "#BF40BF",
|
||||
blocks: [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": `See <${FAILED_RUN_URL}|failing run details>.`
|
||||
}
|
||||
},
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
3049
docs/api.html
@@ -30,7 +30,13 @@ Appearance settings are split across different tabs:
|
||||
|
||||
## Dark mode
|
||||
|
||||
People can display their Metabase in dark mode in their [account settings](../people-and-groups/account-settings.md#theme). Dark mode is a user-level setting, not an instance-level setting. Currently, there's no way to change the theme to dark mode for the entire instance, but you can edit some [user interface colors](#user-interface-colors).
|
||||
People can display their Metabase in dark mode in their [account settings](../people-and-groups/account-settings.md#theme). Options are:
|
||||
|
||||
- System default (which is also the default setting). Metabase will switch between light and dark mode when the system switches without having to reload the page.
|
||||
- Always use dark mode.
|
||||
- Always use light mode.
|
||||
|
||||
Dark mode is a user-level setting, not an instance-level setting. Currently, there's no way to change the theme to dark mode for the entire instance, but you can edit some [user interface colors](#user-interface-colors).
|
||||
|
||||
## Branding
|
||||
|
||||
|
||||
@@ -1950,7 +1950,7 @@ Maximum number of async Jetty threads. If not set, then [MB_JETTY_MAXTHREADS](#m
|
||||
Type: integer<br>
|
||||
Default: `20`<br>
|
||||
|
||||
Limits the number of rows Metabase will display in tables sent with dashboard subscriptions and alerts. Range: 1-100. To limit the total number of rows included in the file attachment for an email dashboard subscription, use [MB_UNAGGREGATED_QUERY_ROW_LIMIT](#mb_unaggregated_query_row_limit).
|
||||
Limits the number of rows Metabase will display in tables sent with dashboard subscriptions and alerts. Range: 1-100. To limit the total number of rows included in the file attachment for an email dashboard subscription, use [MB_ATTACHMENT_ROW_LIMIT](#mb_attachment_row_limit).
|
||||
|
||||
### `MB_AUDIT_MAX_RETENTION_DAYS`
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 128 KiB |
@@ -283,9 +283,7 @@ If you don't see the palette icon, you'll need to:
|
||||
|
||||
After you've made your ideal dashboard, you may want to put the dashboard on a TV to help keep your team up to date throughout the day.
|
||||
|
||||
To enter fullscreen mode, click the **fullscreen** icon in the top right of the dashboard (the icon with the arrows pointing in opposite directions). Once you've entered fullscreen mode, you can also switch the dashboard into "Night mode" for higher contrast.
|
||||
|
||||

|
||||
To enter fullscreen mode, click the **fullscreen** icon in the top right of the dashboard (the icon with the arrows pointing in opposite directions).
|
||||
|
||||
## Auto refresh
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
title: Python transforms
|
||||
summary: Use Python to wrangle your data in Metabase and write the results back to your database.
|
||||
---
|
||||
|
||||
# Python transforms
|
||||
|
||||
> Python transforms are only available on Metabase Cloud [Pro and Enterprise plans](https://www.metabase.com/pricing) with the **Python execution add-on**.
|
||||
|
||||
You can write a Python script to transform the data from your database, and write the result back into the database as another table.
|
||||
|
||||
For general information about Metabase transforms, see [Transforms](transforms.md).
|
||||
|
||||
## Create a Python transform
|
||||
|
||||
To write Python transforms, you'll need to have the Python execution add-on. Once you've enabled the add-on:
|
||||
|
||||
1. Go to **Admin settings > Transforms** and click **Create a transform > Python script**.
|
||||
|
||||
2. Select a database that has the data you want to transform. See [Databases that support transforms](transforms.md#databases-that-support-transforms).
|
||||
|
||||
3. Select one or more tables with the data that you'd like to transform. Optionally, assign aliases to the tables.
|
||||
|
||||
The tables you select will be available as DataFrames in your Python code with your chosen aliases, and will be passed to the `transform()` function as parameters.
|
||||
|
||||
4. Create a function `transform()` that does the data wrangling and returns a [pandas DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html). The DataFrame returned by the function will be written back to your database when the transform is run.
|
||||
5. Click **Save** in the top right corner.
|
||||
6. Select a target schema for your transform and enter a name for the target table. Metabase will write the results of the transform into this table.
|
||||
|
||||
You can only write back to the same database as you chose for the transform source.
|
||||
|
||||
7. Optionally, assign tags to your transforms. Tags are used by [jobs](transforms.md#jobs-and-tags) to run transforms on schedule.
|
||||
|
||||
## Tips for Python transforms
|
||||
|
||||
- A Python transform must have a function `transform()` that returns a single `pandas` DataFrame.
|
||||
- You can use aliases to include tables from your database as DataFrames inside the `transform()` function. The tables will _only_ be available in the transform function. Other functions won't have access to the tables.
|
||||
- Only `pandas` will be imported by default, but you can import [certain other packages](#available-python-packages). You can also use functions from the [common library](#common-python-library).
|
||||
- You'll see the output of `print()` statements in the transform's logs.
|
||||
- Metabase won't write DataFrame indexes to the database, including indexes created by `groupby()`. If you're using a custom index that you'd like to include in the target table, you'll need to [reset index](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.reset_index.html) on your DataFrame inside the `transform()` function.
|
||||
|
||||
## Run a Python transform
|
||||
|
||||
See [Run a transform](transforms.md#run-a-transform). You'll see logs for a transform run (including the output of any `print()` statements you included in the `transform()` code) on the transform's page.
|
||||
|
||||
## Available Python packages
|
||||
|
||||
You can import any of the following Python packages in Python transforms:
|
||||
|
||||
- [`pandas`](https://pandas.pydata.org/);
|
||||
- Any dependencies of `pandas`, e.g. [`numpy`](https://numpy.org/);
|
||||
- Any packages from the [Python standard library](https://docs.python.org/3/library/index.html), e.g. `json` or `datetime`.
|
||||
|
||||
Due to security considerations for the Python execution environment, you won't be able to install or import any other packages.
|
||||
|
||||
You can also write your own functions and add them to the [common library](#common-python-library) in Metabase, which will be available across all your Python transforms.
|
||||
|
||||
## Common Python library
|
||||
|
||||
If you have functions or classes you'd like to reuse across multiple transforms, you can add them to the common library that will be available across all Python transforms.
|
||||
|
||||
To add things to the common Python library:
|
||||
|
||||
1. Go to **Admin > Transforms > Python library**.
|
||||
|
||||
2. Add a Python function or class.
|
||||
|
||||
Functions in this library can't access any data in your database.
|
||||
|
||||
To use functions or classes from your Python library:
|
||||
|
||||
1. When editing your Python transform script, check **Import common library** in the top right.
|
||||
|
||||
This will add an aptly named import `import common` to the transform's code.
|
||||
|
||||
2. Reference functions or classes from the common library in your code like `common.manifest_kittens()`.
|
||||
|
||||
## Current limitations of Python transforms
|
||||
|
||||
- Currently, Python transforms are only available on Metabase Cloud.
|
||||
- The transform function must return a single `pandas` DataFrame. Other data manipulation and DataFrame libraries like `polars` or `pyspark` are not supported.
|
||||
- DataFrame indexes, including indexes created by `groupby()`, are ignored from writing back to the database. If you're using a custom index that you'd like to include in the target table, you'll need to [reset index](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.reset_index.html) on your DataFrame inside the `transform()` function to make the index into a real column.
|
||||
- You can't preview the results of a Python transform.
|
||||
- Only a [limited set of packages](#available-python-packages) are available for import. You can't install additional packages.
|
||||
- Because Python transforms use `pandas`, all data manipulation is done in memory. The available memory is determined by the Python execution add-on. For large datasets, consider using [query-based transforms](transforms.md#query-based-transforms) that run in your database.
|
||||
@@ -1,176 +0,0 @@
|
||||
---
|
||||
title: Transforms
|
||||
summary: Transforms allow you to wrangle your data in Metabase, write the query results back to your database, and reuse them in Metabase as sources for new queries.
|
||||
---
|
||||
|
||||
# Transforms
|
||||
|
||||
{% include plans-blockquote.html feature="Transforms" is_plural=true%}
|
||||
|
||||
_Admin settings > Transforms_
|
||||
|
||||
Transforms can be used to, well, transform your data - do stuff like preprocessing, cleaning, joining tables, pre-computing metrics. Transforms give you the ability to do the "T" of "ETL" within Metabase.
|
||||
|
||||
You'll write a query or a Python script in Metabase, a transform will run this query or script, create a table in your target database containing the results, and sync that table to Metabase, so it can be used as a data source for questions or other transforms.
|
||||
|
||||
## Transforms overview
|
||||
|
||||
- **Transforms** are queries (either with SQL or the query builder) or Python scripts that write back to your database as a new, persistent table. Use transforms to clean, join, or pre-aggregate data.
|
||||
- Transforms are scheduled and organized using **tags** and **jobs**.
|
||||
- You assign tags (e.g., daily, hourly) to group your transforms.
|
||||
- A job runs on a schedule (e.g., every day at midnight) and executes all transforms that have been assigned a specific tag.
|
||||
- Each execution of a transform is a **run**. A run replaces the target table with fresh results. You can review the history of runs to monitor their success or failure.
|
||||
|
||||
## Databases that support transforms
|
||||
|
||||
Currently, Metabase can create transforms on the following databases:
|
||||
|
||||
- BigQuery
|
||||
- ClickHouse (only ClickHouse Cloud)
|
||||
- MySQL/MariaDB
|
||||
- PostgreSQL
|
||||
- Redshift
|
||||
- Snowflake
|
||||
- SQL Server
|
||||
|
||||
You can't create transforms on databases that have [Database routing](../permissions/database-routing.md) enabled, on Metabase [Cloud Storage](../cloud/storage.md), or on Metabase's Sample Database.
|
||||
|
||||
Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../databases/users-roles-privileges.md).
|
||||
|
||||
## Types of transforms
|
||||
|
||||
Metabase supports two types of transforms: query-based transforms and Python transforms. Here's a high-level overview of how they work and the differences between them:
|
||||
|
||||
### Query-based transforms
|
||||
|
||||
- In Metabase, you create a `SELECT` query either using SQL or Metabase's [graphical query builder](../questions/query-builder/editor.md).
|
||||
- When the transform first runs, your _database_ executes the transform's query.
|
||||
- Your database writes the results of the query as a new table.
|
||||
- The new table is synced to Metabase.
|
||||
- On subsequent transform runs, your database will overwrite that table with the updated results (updates are not incremental).
|
||||
|
||||
### Python-based transforms
|
||||
|
||||
Python-based transforms require a dedicated Python execution environment, so you'll need to buy the Python execution add-on.
|
||||
|
||||
- In your Metabase, you write a Python script that returns a `pandas` DataFrame and uses one or more tables from your database.
|
||||
- When Metabase runs the transform, a new Python execution environment is spun up. Python transforms run in a separate, isolated environment—not on your Metabase instance.
|
||||
- Metabase securely copies your source data to your Python environment and makes it available as pandas DataFrames.
|
||||
- The Python environment executes your Python script _in memory_.
|
||||
- The Python environment saves the resulting DataFrame as a file.
|
||||
- Your Metabase instance reads the file and writes the results to a new table in your database.
|
||||
- The new table is synced to Metabase.
|
||||
- On subsequent transform runs, your database will overwrite that table with the updated results (updates are not incremental).
|
||||
|
||||
For more on Python transforms, see [Python transforms](python-transforms.md).
|
||||
|
||||
## Create a transform
|
||||
|
||||
Only admins can create transforms.
|
||||
|
||||
To create a transform:
|
||||
|
||||
1. Go to Admin settings > Transforms.
|
||||
2. Click **+ Create transform**.
|
||||
3. Select the source for your transform.
|
||||
|
||||
You can create transforms using the query builder, SQL editor, or by copying an existing question or model. On Metabase Cloud with the Python execution add-on, you can also [create transforms from a Python script](python-transforms.md). See [Types of transforms](#types-of-transforms).
|
||||
|
||||
If you select a question for the transform's source, Metabase will only _copy_ the question's query. Later edits to that question won't affect the transform's query.
|
||||
|
||||
Your transform can use the target tables of other transforms.
|
||||
|
||||
4. Edit the query or the Python code for the transform.
|
||||
|
||||
In SQL transforms, you can reference other saved questions and use snippets, but you can't use SQL parameters.
|
||||
|
||||
When editing query-based transforms, you can preview results without writing data into your database.
|
||||
|
||||
For editing Python transforms, see [Python transforms](python-transforms.md).
|
||||
|
||||
5. Click **Save** in the top right corner.
|
||||
6. Select a target schema for your transform and enter a name for the target table. Metabase will write the results of the transform into this table.
|
||||
|
||||
You can only transform data _within_ a database; you can't write from one database to another.
|
||||
|
||||
7. Optionally, assign tags to your transforms. Tags are used by [jobs](#jobs-and-tags) to run transforms on schedule.
|
||||
|
||||
## Edit a transform
|
||||
|
||||
You can edit the transform's name and description, query or script, and target table. You can edit the transform even if it already ran or is scheduled to run.
|
||||
|
||||
### Edit transform's query or script
|
||||
|
||||
To edit the transform's query or script, click on "Edit query" or "Edit script" above the transform definition.
|
||||
|
||||
Currently, you can't convert between different transform types (e.g. a query builder transform to a SQL-based transform, or a SQL transform into a Python transform). If you want to change your transform built with the query builder into a SQL transform, you'll need to create a new transform with the same target and tags, and delete the old transform.
|
||||
|
||||
Once you change the transform's query or script, the next transform run (manual or scheduled) will use the updated query and write the results into the target table. If you have questions that query the transform's target table, they might break. For example, if your new transform query no longer includes a column that a downstream question was relying on, that question will break.
|
||||
|
||||
### Edit transform's target
|
||||
|
||||
To edit transform's target table, i.e., the table where the query results are written, click on "Change target". You'll need to select whether you want to keep the old target table, or delete it. Deletion can't be undone.
|
||||
|
||||
### Edit transform's name or description
|
||||
|
||||
To edit a transform's name or description, click on the name or description at the top of the transform page.
|
||||
|
||||
## Run a transform
|
||||
|
||||
You can run a transform manually or on schedule (e.g., hourly).
|
||||
|
||||
Running a transform for the first time will create and sync the table created by the transform, and you'll be able to edit the table's [metadata](./metadata-editing.md) and [permissions](../permissions/data.md). Subsequent runs will drop and recreate the table. Currently, Metabase doesn't support incremental transforms. Each transform run will recreate the target table using the entirety of the query results.
|
||||
|
||||
To run a transform manually, visit the transform and click **Run transform**.
|
||||
|
||||
To schedule a transform, you'll need to assign one or more tags to it, then create a [scheduled job](#jobs-and-tags) that picks up those tags.
|
||||
|
||||
You can see the time and status of the latest transform run on the transform's page, or in the Runs view. The time of the run is given in the system's timezone.
|
||||
|
||||
For Python transforms, you'll also see the transform's execution logs.
|
||||
|
||||
## Transform dependencies
|
||||
|
||||
Transform queries can use the data from other transforms. For example, you can have a transform that uses data from a `raw_events` table and writes to a `stg_events` table, and then create another transform that uses data from the `stg_events` table and writes to an `events` table.
|
||||
|
||||
Metabase will track transform dependencies, and execute them in order. For each transform, you can see which transforms the current transform depends on.
|
||||
|
||||
If a [job](#jobs-and-tags) includes a transform that depends on a table created by another transform, then the job will run all the tagged transforms and their dependencies, even if they lack tags.
|
||||
|
||||
## Jobs and tags
|
||||
|
||||
Jobs let you run multiple transforms on a schedule.
|
||||
|
||||
You can see and filter the list of all jobs, their last and next run, and the status of the least run, in _Admin settings > Transforms > Jobs_
|
||||
|
||||
To create a new job, go to _Admin settings > Transforms_, switch to the **Jobs** view, and click **Create a job**
|
||||
|
||||
To edit a job, click on the job in the list of jobs.
|
||||
|
||||
Jobs have two components: schedule and tags.
|
||||
|
||||
Metabase uses tags to locate transforms that should be run by the job, and then schedules those transforms according to the cron schedule. The times are given in the system's timezone.
|
||||
|
||||
### Tags
|
||||
|
||||
Job can use multiple tags, in which case, the job will run all transforms that have _any_ of those tags. For example, you can have a job "Weekend job" that is scheduled run at noon on Saturdays and Sundays that picks up all transforms tagged either "Saturday", "Sunday", or "Weekend".
|
||||
|
||||
By default, Metabase comes with hourly, daily, weekly, and monthly tags and jobs that are run on the corresponding schedules, but you can remove or rename those tags, or create new tags.
|
||||
|
||||
To create a new tag, just type the new tag's name in "Tags" field (either when viewing a transform or when viewing a job) and select "Create a tag".
|
||||
|
||||
### Jobs take dependencies into account
|
||||
|
||||
Depended transforms will be scheduled and run intelligently: if Transform B depends on the output of Transform A, then a job will run Transform A before Transform B. A job will run all dependent transforms even if the dependencies aren't tagged. You will see the order of transform execution on the job's page.
|
||||
|
||||
## Transforms vs models
|
||||
|
||||
Transforms are similar to models with model persistence turned on, but there are a few crucial differences:
|
||||
|
||||
- Transforms can only be created by admins. Models can be created by anyone with permissions to create queries on the data source (but only admins can enable model persistence).
|
||||
- You can choose the target schema and tables for transforms. Model persistence will create its own schema and tables.
|
||||
- You can't rename columns in a transform (but you can change column metadata once they're synced back to Metabase). You can rename columns in model metadata and then persist those column names using model persistence.
|
||||
- Transforms support more databases than model persistence
|
||||
- On Metabase Cloud with Python execution add-on, you can use Python to create transforms.
|
||||
|
||||
Use models to enable non-admins to create their own datasets within Metabase, and to add context like field descriptions and semantic types. Use transforms to create persisted datasets in your database and reuse them across Metabase.
|
||||
@@ -9,7 +9,6 @@ We recommend creating a `metabase` database user with the following database rol
|
||||
- [`analytics` for read access](#minimum-database-privileges) to any schemas or tables used for analysis.
|
||||
- Optional [`metabase_actions` for write access](#privileges-to-enable-actions-and-editable-table-data) to tables used for Metabase actions.
|
||||
- Optional [`metabase_model_persistence` for write access](#privileges-to-enable-model-persistence) to the schema used for Metabase model persistence.
|
||||
- Optional [`metabase_transforms` for write access](#privileges-to-enable-transforms) to the schema used for Metabase transforms.
|
||||
|
||||
Bundling your privileges into roles based on use cases makes it easier to manage privileges in the future (especially in [multi-tenant situations](#multi-tenant-permissions)). For example, you could:
|
||||
|
||||
@@ -135,19 +134,6 @@ GRANT INSERT, UPDATE, DELETE ON "your_model's_table" IN SCHEMA "your_schema" TO
|
||||
GRANT metabase_model_persistence TO metabase;
|
||||
```
|
||||
|
||||
## Privileges to enable transforms
|
||||
|
||||
[Transforms](../data-modeling/transforms.md) let Metabase write query results back to your database. We suggest that you create a dedicated schema for your transforms. Metabase's database user will need to be able to create and drop transform tables.
|
||||
|
||||
In addition to the [minimum database privileges](#minimum-database-privileges):
|
||||
|
||||
- Create a new role called `metabase_transforms`.
|
||||
- Create a dedicated schema for transforms.
|
||||
- Give the `metabase_transforms` role `CREATE TABLE` / `ALTER` / `DROP` privileges for the specified schema
|
||||
- Give the `metabase_transforms` role to the `metabase` user.
|
||||
|
||||
In certain cases, Metabase will give you an option to create new schemas for transforms, in which case you'll also need `CREATE SCHEMA` privileges for your metabase users (or `CREATE DATABASE` for ClickHouse).
|
||||
|
||||
## Privileges to enable uploads
|
||||
|
||||
You can [upload CSVs](../databases/uploads.md) to supported databases. Metabase's database user should have write access (`INSERT`, `UPDATE`, `DELETE`) to the schema where you want to store the uploads.
|
||||
|
||||
@@ -41,6 +41,25 @@ If you're self-hosting Metabase, you can opt out of providing us with your anony
|
||||
|
||||
If you're in the process of setting up your Metabase, you can also toggle off tracking during the `Usage Data Preferences` onboarding step. We collect a few anonymous events before that point, but won't do so anymore if you choose to opt out.
|
||||
|
||||
## Token validation requests for paid plans
|
||||
|
||||
If you're on a paid plan (Pro, Enterprise, or a trial), Metabase periodically sends a token validation request to verify your license. This is separate from the anonymous usage data described above.
|
||||
|
||||
### Data included in token validation requests
|
||||
|
||||
The token validation request includes:
|
||||
|
||||
- Count of active users (used for billing)
|
||||
- Count of external users
|
||||
- Count of internal users
|
||||
- Count of email domains
|
||||
- Count of embedded dashboards and questions
|
||||
- Types of embedding (static, interactive, Embedded analtyics SDK, Embedded analytics JS)
|
||||
- Site UUID (just an identifier for your Metabase)
|
||||
- Metabase version
|
||||
- Query execution timestamp (last UTC day)
|
||||
- Metabot usage statistics: count of tokens, queries, users, and date (just counts and the date).
|
||||
|
||||
## Further reading
|
||||
|
||||
Check out our page on [data privacy and security](https://www.metabase.com/security).
|
||||
Check out our page on [data privacy and security](https://www.metabase.com/security).
|
||||
@@ -50,7 +50,6 @@ Metabase will only export the following entities:
|
||||
- Collections (but personal collections don't get exported unless explicitly specified them through [export options](#customize-what-gets-exported))
|
||||
- Dashboards
|
||||
- Saved questions
|
||||
- Transforms (including jobs)
|
||||
- Documents (without comments)
|
||||
- Actions
|
||||
- Models
|
||||
|
||||
@@ -195,7 +195,7 @@ Learn more about [SQL parameters](../questions/native-editor/sql-parameters.md)
|
||||
For example, say have a table like this:
|
||||
|
||||
| User_ID | Value |
|
||||
|---------|-------|
|
||||
| ------- | ----- |
|
||||
| 1 | 10 |
|
||||
| 1 | 50 |
|
||||
| 2 | 5 |
|
||||
@@ -203,7 +203,7 @@ For example, say have a table like this:
|
||||
| 3 | 5 |
|
||||
| 3 | 5 |
|
||||
|
||||
If you want to give someone access to multiple user IDs (e.g., the person should see rows for both `User_ID` 1 and 2.), you can set up a user attribute, like `user_id` that can handle comma-separated values like "1,2".
|
||||
If you want to give someone access to multiple user IDs (e.g., the person should see rows for both `User_ID` 1 and 2.), you can set up a user attribute, like `user_id` that can handle comma-separated values like "1,2".
|
||||
|
||||
1. Create a SQL question that parses the comma-separated string and filters the table:
|
||||
|
||||
@@ -313,10 +313,6 @@ Create a SQL question that casts the advanced data type column to a basic data t
|
||||
|
||||
If you can't use SQL casting in Metabase, create a view in your database that converts the advanced data type to a basic type, then set up row and column security on that view instead of the original table. You'll also need to block the original table.
|
||||
|
||||
#### Option 3: Use transforms
|
||||
|
||||
Use a [transform](../data-modeling/transforms.md) to create a table that casts the advanced data type to a basic type. Then set up row and column security on the transformed table instead. You'll also need to block the original table.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Row and column security examples](./row-and-column-security-examples.md)
|
||||
|
||||
@@ -76,6 +76,8 @@ For example, if you want to build a progress bar comparing count of orders this
|
||||
|
||||

|
||||
|
||||
Note that both columns are computed fields here, one for 2024 and one for the current year.
|
||||
|
||||
Once you have a column that you want to use, set it as a goal:
|
||||
|
||||
1. While viewing the progress bar visualization, click on the **Gear** icon in bottom left to open settings.
|
||||
|
||||
@@ -30,9 +30,6 @@ const snowplowMicroUrl = process.env["MB_SNOWPLOW_URL"];
|
||||
|
||||
const isQaDatabase = process.env["QA_DB_ENABLED"] === "true";
|
||||
|
||||
const sourceVersion = process.env["CROSS_VERSION_SOURCE"];
|
||||
const targetVersion = process.env["CROSS_VERSION_TARGET"];
|
||||
|
||||
const isEmbeddingSdk = process.env.CYPRESS_IS_EMBEDDING_SDK === "true";
|
||||
|
||||
// docs say that tsconfig paths should handle aliases, but they don't
|
||||
@@ -53,7 +50,7 @@ const assetsResolverPlugin = {
|
||||
};
|
||||
|
||||
// these are special and shouldn't be chunked out arbitrarily
|
||||
const specBlacklist = ["/embedding-sdk/", "/cross-version/"];
|
||||
const specBlacklist = ["/embedding-sdk/"];
|
||||
|
||||
function getSplittableSpecs(specs) {
|
||||
return specs.filter((spec) => {
|
||||
@@ -152,8 +149,6 @@ const defaultConfig = {
|
||||
config.env.IS_ENTERPRISE = isEnterprise;
|
||||
config.env.HAS_SNOWPLOW_MICRO = hasSnowplowMicro;
|
||||
config.env.SNOWPLOW_MICRO_URL = snowplowMicroUrl;
|
||||
config.env.SOURCE_VERSION = sourceVersion;
|
||||
config.env.TARGET_VERSION = targetVersion;
|
||||
|
||||
require("@cypress/grep/src/plugin")(config);
|
||||
|
||||
@@ -238,18 +233,6 @@ const snapshotsConfig = {
|
||||
video: false,
|
||||
};
|
||||
|
||||
const crossVersionSourceConfig = {
|
||||
...defaultConfig,
|
||||
baseUrl: "http://localhost:3000",
|
||||
specPattern: "e2e/test/scenarios/cross-version/source/**/*.cy.spec.{js,ts}",
|
||||
};
|
||||
|
||||
const crossVersionTargetConfig = {
|
||||
...defaultConfig,
|
||||
baseUrl: "http://localhost:3001",
|
||||
specPattern: "e2e/test/scenarios/cross-version/target/**/*.cy.spec.{js,ts}",
|
||||
};
|
||||
|
||||
const stressTestConfig = {
|
||||
...defaultConfig,
|
||||
retries: 0,
|
||||
@@ -279,7 +262,5 @@ module.exports = {
|
||||
mainConfig,
|
||||
snapshotsConfig,
|
||||
stressTestConfig,
|
||||
crossVersionSourceConfig,
|
||||
crossVersionTargetConfig,
|
||||
embeddingSdkComponentTestConfig,
|
||||
};
|
||||
|
||||
@@ -94,6 +94,10 @@ export function downloadAndAssert(
|
||||
|
||||
cy.log(`Downloading ${fileType} file`);
|
||||
|
||||
// For _some_ reason wait is necessary for dashcard menu to stay open after
|
||||
// the click. I assume card needs to finish loading.
|
||||
cy.wait(200);
|
||||
|
||||
if (isDashboard) {
|
||||
if (isEmbed) {
|
||||
getDashboardCard().realHover();
|
||||
|
||||
@@ -19,19 +19,3 @@ export const createSegment = ({
|
||||
definition,
|
||||
});
|
||||
};
|
||||
|
||||
export const createMetric = ({
|
||||
name,
|
||||
table_id,
|
||||
definition,
|
||||
description = null,
|
||||
}) => {
|
||||
cy.log(`Create a metric: ${name}`);
|
||||
// This endpoint doesn't exist anymore, but we need this helper for cross-version tests
|
||||
return cy.request("POST", "/api/metric", {
|
||||
name,
|
||||
description,
|
||||
table_id,
|
||||
definition,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -254,6 +254,10 @@ export const queryBuilderFooter = () => {
|
||||
return cy.findByTestId("view-footer");
|
||||
};
|
||||
|
||||
export const queryBuilderFooterDisplayToggle = () => {
|
||||
return cy.findByTestId("query-display-tabular-toggle");
|
||||
};
|
||||
|
||||
export const closeQuestionActions = () => {
|
||||
queryBuilderHeader().click();
|
||||
};
|
||||
|
||||
@@ -292,3 +292,40 @@ describe("scenarios > embedding-sdk > interactive-dashboard", () => {
|
||||
cy.get("@datasetQuery.all").should("have.length", 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scenarios > embedding-sdk > interactive-dashboard > tabs", () => {
|
||||
it("should not show add a chart button on empty dashboard with many tabs (metabase#65001)", () => {
|
||||
signInAsAdminAndEnableEmbeddingSdk();
|
||||
|
||||
const TAB_WITH_CARDS = { id: 1, name: "Tab with cards" };
|
||||
const EMPTY_TAB = { id: 2, name: "Empty tab" };
|
||||
|
||||
const questionCard: Partial<DashboardCard> = {
|
||||
id: ORDERS_DASHBOARD_DASHCARD_ID,
|
||||
dashboard_tab_id: TAB_WITH_CARDS.id,
|
||||
card_id: ORDERS_QUESTION_ID,
|
||||
row: 0,
|
||||
col: 0,
|
||||
size_x: 16,
|
||||
size_y: 8,
|
||||
};
|
||||
|
||||
H.createDashboardWithTabs({
|
||||
name: "Dashboard with empty tab",
|
||||
tabs: [TAB_WITH_CARDS, EMPTY_TAB],
|
||||
dashcards: [questionCard],
|
||||
}).then(({ id: dashboardId }) => {
|
||||
cy.signOut();
|
||||
mockAuthProviderAndJwtSignIn();
|
||||
|
||||
mountSdkContent(<InteractiveDashboard dashboardId={dashboardId} />);
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Orders").should("be.visible");
|
||||
cy.findByRole("tab", { name: EMPTY_TAB.name }).click();
|
||||
cy.findByTestId("dashboard-empty-state").should("be.visible");
|
||||
cy.findByText("Add a chart").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -768,7 +768,6 @@ describe("scenarios > content translation > static embedding > dashboards", () =
|
||||
H.filterWidget().findByText("Multi").click();
|
||||
// Search matches against untranslated text, hence "Fran" matching these names
|
||||
cy.findByPlaceholderText("Recherche dans la liste").type("Fran");
|
||||
cy.wait("@searchQuery");
|
||||
cy.findByTestId("parameter-value-dropdown").within(() => {
|
||||
cy.findByText(/Glacia Froskeon/).click();
|
||||
cy.button(/Ajouter un filtre/).click();
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("Remote Sync", () => {
|
||||
cy.wait("@updateDashboard").then((req) => {
|
||||
expect(req.response?.statusCode).to.eq(400);
|
||||
expect(req.response?.body.message).to.contain(
|
||||
"non-remote-synced dependencies",
|
||||
"content that is not remote synced",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -533,6 +533,7 @@ describe("scenarios > collection defaults", () => {
|
||||
({ body: { groups, revision } }) => {
|
||||
// Give `Data` group permission to "curate" Child collection only
|
||||
// Access to everything else is revoked by default - that's why we chose `Data` group
|
||||
groups[DATA_GROUP] = {};
|
||||
groups[DATA_GROUP][CHILD_COLLECTION_ID] = "write";
|
||||
|
||||
// We're chaining these 2 requestes in order to match schema (passing it from GET to PUT)
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
export function parseVersionString(versionString) {
|
||||
if (typeof versionString === "undefined") {
|
||||
return []; // Return empty array if versionString is undefined
|
||||
}
|
||||
|
||||
const segments = versionString.match(/\d+/g);
|
||||
if (!segments) {
|
||||
return { version: versionString }; // Return versionString if no segments found
|
||||
}
|
||||
|
||||
const segmentInts = segments.map((segment) => parseInt(segment, 10));
|
||||
const editionIndex = segmentInts[0];
|
||||
|
||||
let edition = null;
|
||||
if (editionIndex === 1) {
|
||||
edition = "ee";
|
||||
} else if (editionIndex === 0) {
|
||||
edition = "oss";
|
||||
}
|
||||
|
||||
return {
|
||||
version: versionString,
|
||||
edition: edition,
|
||||
majorVersion: segmentInts[1],
|
||||
minorVersion: segmentInts[2],
|
||||
patchVersion: segmentInts[3],
|
||||
};
|
||||
}
|
||||
|
||||
// Versions in which a breaking GUI change was introduced. For example, version
|
||||
// 32 added a "What will you use Metabase for?" stage in the initial setup.
|
||||
|
||||
// Change: Makes the "Sample Database" in setup more prominent
|
||||
// GitHub PR: https://github.com/metabase/metabase/pull/60511
|
||||
// Date: 2025-07-08
|
||||
// Author: nemanjaglumac
|
||||
const moreProminentSampleDbInSetupVersion = 56;
|
||||
|
||||
// Change: Adds "What will you use Metabase for?" stage in the initial setup.
|
||||
// Git sha: d88d32e5e021ad4f47b5d740b78df73945e8ff82
|
||||
// Date: 2024-02-08
|
||||
// Author: npretto
|
||||
const metabasePurposeVersion = 49;
|
||||
|
||||
// Change: Question save modal logic changes required different cypress logic
|
||||
// to click "Save" button as former cy.clck("Save") became ambiguous.
|
||||
// Git sha: f90e4db22e0b668600846e00ed4d1e28d8f92d95
|
||||
// Date: 2024-02-23
|
||||
// Author: markbastian
|
||||
const updatedCypressSaveLogicVersion = 49;
|
||||
|
||||
// Change: Sample data times changed.
|
||||
// TODO: The specific version in which this changed hasn't been tracked down
|
||||
// yet, but this is an upper bound. If more cross-version checks are added for
|
||||
// earlier versions and this breaks, this number may need to be adjusted down.
|
||||
export const sampleDataTimesChangedVersion = 47;
|
||||
|
||||
// Change: In older versions of metabase, when a question was loaded a modal was
|
||||
// presented stating "It's okay to play around with saved questions". The user
|
||||
// was required to press "Okay" to dismiss this modal. In later versions this
|
||||
// modal was not presented in the same location.
|
||||
// TODO: The specific version in which this changed hasn't been tracked down
|
||||
// yet, but this is an upper bound. If more cross-version checks are added for
|
||||
// earlier versions and this breaks, this number may need to be adjusted down.
|
||||
export const questionsAreOkToPlayWithModalVersion = 45;
|
||||
|
||||
// Change: Older versions of Metabase presented a 3-panel display, the center
|
||||
// panel being "Custom question". In later generations, this was accessible via
|
||||
// clicking "New" -> "Question" at the top right corner in the UI.
|
||||
// TODO: The specific version in which this changed hasn't been tracked down
|
||||
// yet, but this is an upper bound. If more cross-version checks are added for
|
||||
// earlier versions and this breaks, this number may need to be adjusted down.
|
||||
export const newQuestionMenuVersion = 45;
|
||||
|
||||
// Change: In older versions of metabase, area and line charts were selectable
|
||||
// Visualization types, but the actual operation to fill the area was to select
|
||||
// the "area" icon in the Display settings, which is really weird.
|
||||
// TODO: The specific version in which this changed hasn't been tracked down
|
||||
// yet, but this is an upper bound. If more cross-version checks are added for
|
||||
// earlier versions and this breaks, this number may need to be adjusted down.
|
||||
export const filledAreaIconRemovedVersion = 45;
|
||||
|
||||
export function setupLanguage() {
|
||||
// Make sure English is the default selected language
|
||||
cy.findByText("English")
|
||||
.should("have.css", "background-color")
|
||||
.and("eq", "rgb(80, 158, 227)");
|
||||
|
||||
cy.button("Next").click();
|
||||
cy.findByText("Your language is set to English");
|
||||
}
|
||||
|
||||
export function setupInstance({ version, majorVersion }) {
|
||||
const companyLabel =
|
||||
version === "v0.41.3.1"
|
||||
? "Your company or team name"
|
||||
: "Company or team name";
|
||||
|
||||
const finalSetupButton = version === "v0.41.3.1" ? "Next" : "Finish";
|
||||
|
||||
cy.findByLabelText("First name").type("Superuser");
|
||||
cy.findByLabelText("Last name").type("Tableton");
|
||||
cy.findByLabelText("Email").type("admin@metabase.test");
|
||||
cy.findByLabelText(companyLabel).type("Metabase");
|
||||
cy.findByLabelText("Create a password").type("12341234");
|
||||
cy.findByLabelText("Confirm your password").type("12341234");
|
||||
cy.button("Next").click();
|
||||
cy.findByText("Hi, Superuser. Nice to meet you!");
|
||||
|
||||
// A "What will you use Metabase for?" prompt exists in later versions.
|
||||
// If it exists, click through.
|
||||
if (majorVersion >= metabasePurposeVersion) {
|
||||
cy.button("Next").click();
|
||||
}
|
||||
|
||||
const continueWithoutAddingDatabaseCopy =
|
||||
majorVersion >= moreProminentSampleDbInSetupVersion
|
||||
? "Continue with sample data"
|
||||
: "I'll add my data later";
|
||||
|
||||
cy.findByText(continueWithoutAddingDatabaseCopy).click();
|
||||
cy.findByText("I'll add my own data later");
|
||||
|
||||
// Collection defaults to on and describes data collection
|
||||
cy.findByText("All collection is completely anonymous.");
|
||||
// turn collection off, which hides data collection description
|
||||
cy.findByLabelText(
|
||||
"Allow Metabase to anonymously collect usage events",
|
||||
).click();
|
||||
cy.findByText("All collection is completely anonymous.").should("not.exist");
|
||||
cy.findByText(finalSetupButton).click();
|
||||
cy.findByText("Take me to Metabase").click();
|
||||
|
||||
cy.location("pathname").should("eq", "/");
|
||||
cy.contains("Reviews");
|
||||
}
|
||||
|
||||
export function newQuestion({ majorVersion }) {
|
||||
if (majorVersion < newQuestionMenuVersion) {
|
||||
cy.findByText("Custom question").click();
|
||||
} else {
|
||||
cy.button("New").click();
|
||||
cy.findByText("Question").click();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveQuestion({ majorVersion }) {
|
||||
if (majorVersion < updatedCypressSaveLogicVersion) {
|
||||
cy.button("Save").click();
|
||||
} else {
|
||||
cy.findByTestId("save-question-modal").within((modal) => {
|
||||
cy.findByText("Save").click();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertTimelineData({ majorVersion }) {
|
||||
if (majorVersion < sampleDataTimesChangedVersion) {
|
||||
cy.get(".x.axis .tick")
|
||||
.should("contain", "Q1 - 2017")
|
||||
.and("contain", "Q1 - 2018")
|
||||
.and("contain", "Q1 - 2019")
|
||||
.and("contain", "Q1 - 2020");
|
||||
} else {
|
||||
cy.get(".x.axis .tick")
|
||||
.should("contain", "Q1 2023")
|
||||
.and("contain", "Q1 2024")
|
||||
.and("contain", "Q1 2025")
|
||||
.and("contain", "Q1 2026");
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissOkToPlayWithQuestionsModal({ majorVersion }) {
|
||||
if (majorVersion < questionsAreOkToPlayWithModalVersion) {
|
||||
cy.findByText("It's okay to play around with saved questions");
|
||||
cy.button("Okay").click();
|
||||
}
|
||||
}
|
||||
|
||||
export function fillAreaUnderLineChart({ majorVersion }) {
|
||||
if (majorVersion < filledAreaIconRemovedVersion) {
|
||||
cy.findByTestId("sidebar-left").within((element) => {
|
||||
cy.icon("area").click();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import {
|
||||
setupInstance,
|
||||
setupLanguage,
|
||||
} from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers.js";
|
||||
|
||||
import { version } from "./helpers/cross-version-source-helpers";
|
||||
|
||||
describe(`setup on ${version}`, () => {
|
||||
it("should set up metabase", () => {
|
||||
cy.visit("/");
|
||||
// It redirects to the setup page
|
||||
cy.location("pathname").should("eq", "/setup");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Welcome to Metabase");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Let's get started").click();
|
||||
|
||||
setupLanguage(version);
|
||||
setupInstance(version);
|
||||
|
||||
cy.visit("/admin");
|
||||
// Find an element on the admin page so we know we've landed.
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Setup");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
const { H } = cy;
|
||||
|
||||
it("should generate metadata", () => {
|
||||
cy.signInAsAdmin();
|
||||
|
||||
H.withSampleDatabase((SAMPLE_DATABASE) => {
|
||||
cy.writeFile("e2e/support/cypress_sample_database.json", SAMPLE_DATABASE);
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import {
|
||||
createMetric,
|
||||
createSegment,
|
||||
} from "e2e/support/helpers/e2e-table-metadata-helpers";
|
||||
|
||||
const { ORDERS, ORDERS_ID, REVIEWS, PRODUCTS, PEOPLE } = SAMPLE_DATABASE;
|
||||
|
||||
const sampleDBDataModelPage = `/admin/datamodel/database/${SAMPLE_DB_ID}`;
|
||||
|
||||
it("should configure data model settings", () => {
|
||||
cy.intercept("GET", "/api/segment").as("getSegments");
|
||||
cy.intercept("GET", "/api/metric").as("getMetrics");
|
||||
|
||||
cy.signInAsAdmin();
|
||||
|
||||
cy.visit("/admin/datamodel");
|
||||
cy.location("pathname").should("eq", sampleDBDataModelPage);
|
||||
|
||||
// Remap ORDERS.PRODUCT_ID display value to PRODUCTS.TITLE
|
||||
cy.intercept("POST", `/api/field/${ORDERS.PRODUCT_ID}/dimension`).as(
|
||||
"updateProductId",
|
||||
);
|
||||
|
||||
cy.get(".AdminList").findByText("Orders").click();
|
||||
|
||||
cy.findByDisplayValue("Product ID")
|
||||
.parent()
|
||||
.parent()
|
||||
.find(".Icon-gear")
|
||||
.click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Use original value").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Use foreign key").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Title").click();
|
||||
cy.wait("@updateProductId");
|
||||
|
||||
cy.visit(sampleDBDataModelPage);
|
||||
|
||||
cy.get(".AdminList").findByText("Reviews").click();
|
||||
cy.intercept("POST", `/api/field/${REVIEWS.RATING}/values`).as(
|
||||
"remapRatingValues",
|
||||
);
|
||||
|
||||
cy.findByDisplayValue("Rating").parent().parent().find(".Icon-gear").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Use original value").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom mapping").click();
|
||||
cy.wait("@remapRatingValues");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(
|
||||
"You might want to update the field name to make sure it still makes sense based on your remapping choices.",
|
||||
);
|
||||
|
||||
const customMap = {
|
||||
1: "Awful",
|
||||
2: "Unpleasant",
|
||||
3: "Meh",
|
||||
4: "Enjoyable",
|
||||
5: "Perfecto",
|
||||
};
|
||||
|
||||
Object.entries(customMap).forEach(([key, value]) => {
|
||||
cy.findByDisplayValue(key).click().clear().type(value, { delay: 0 });
|
||||
});
|
||||
|
||||
cy.button("Save").click();
|
||||
cy.wait("@remapRatingValues");
|
||||
|
||||
// Hide PRODUCTS.EAN
|
||||
cy.visit(sampleDBDataModelPage);
|
||||
cy.get(".AdminList").findByText("Products").click();
|
||||
|
||||
cy.intercept("PUT", `/api/field/${PRODUCTS.EAN}`).as("hideEan");
|
||||
|
||||
cy.findByDisplayValue("Ean").parent().parent().contains("Everywhere").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Do not include").click();
|
||||
cy.wait("@hideEan");
|
||||
|
||||
cy.intercept("PUT", `/api/field/${PRODUCTS.PRICE}`).as("updatePriceField");
|
||||
|
||||
cy.findByDisplayValue("Price")
|
||||
.parent()
|
||||
.parent()
|
||||
.findByText("No semantic type")
|
||||
.click();
|
||||
|
||||
cy.get(".MB-Select").should("be.visible");
|
||||
|
||||
cy.get(".MB-Select")
|
||||
.scrollTo("top")
|
||||
.within(() => {
|
||||
cy.findByPlaceholderText("Find...").type("Pr");
|
||||
cy.findByText("Price").click();
|
||||
});
|
||||
cy.wait("@updatePriceField");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("US Dollar").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Euro").click();
|
||||
cy.wait("@updatePriceField");
|
||||
|
||||
// Hide PEOPLE.PASSWORD
|
||||
cy.get(".AdminList").findByText("People").click();
|
||||
|
||||
cy.intercept("PUT", `/api/field/${PEOPLE.PASSWORD}`).as("hidePassword");
|
||||
|
||||
cy.findByDisplayValue("Password")
|
||||
.parent()
|
||||
.parent()
|
||||
.contains("Everywhere")
|
||||
.click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Do not include").click();
|
||||
cy.wait("@hidePassword");
|
||||
|
||||
const metric = {
|
||||
name: "Revenue",
|
||||
description: "Sum of orders subtotal",
|
||||
table_id: ORDERS_ID, // legacy api
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.SUBTOTAL, null]]],
|
||||
},
|
||||
};
|
||||
|
||||
const segment = {
|
||||
name: "Large Purchases",
|
||||
description: "Orders over $100.",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
filter: [">", ["field", ORDERS.TOTAL, null], 100],
|
||||
},
|
||||
};
|
||||
|
||||
createMetric(metric); // legacy api
|
||||
createSegment(segment);
|
||||
|
||||
cy.visit("/admin/datamodel/segments");
|
||||
cy.wait("@getSegments");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(segment.name);
|
||||
|
||||
cy.visit("/admin/datamodel/metrics");
|
||||
cy.wait("@getMetrics");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(metric.name);
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
const { H } = cy;
|
||||
import {
|
||||
fillAreaUnderLineChart,
|
||||
newQuestion,
|
||||
saveQuestion,
|
||||
} from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers.js";
|
||||
|
||||
import { version } from "./helpers/cross-version-source-helpers";
|
||||
|
||||
it("should create questions", () => {
|
||||
cy.signInAsAdmin();
|
||||
|
||||
cy.visit("/question/new");
|
||||
|
||||
newQuestion(version);
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Orders").click();
|
||||
|
||||
cy.icon("join_left_outer").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Products").click();
|
||||
// Make sure `on` condition is populated automatically
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Product ID").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Add filters to narrow your answer").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Large Purchases").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Pick the metric you want to see").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(/^Average of/).click();
|
||||
|
||||
cy.findByRole("heading", { name: /Products?/ }).click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Rating").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Pick a column to group by").click();
|
||||
|
||||
// Older versions were Products, newer use Product
|
||||
cy.get(".List-section-title")
|
||||
.contains(/Products?/)
|
||||
.click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Category").click();
|
||||
|
||||
H.visualize();
|
||||
|
||||
cy.get(".bar").should("have.length", 4);
|
||||
|
||||
cy.findByTestId("viz-settings-button").click();
|
||||
|
||||
//NOTE: In older versions of Metabase, Display is selected by default. Newer
|
||||
// versions default to Data. This will ensure we've selected the right tab
|
||||
// either way.
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Display").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Show values on data points").next().click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("3.71");
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Save").click();
|
||||
cy.findByLabelText("Name").clear().type("Rating of Best-selling Products");
|
||||
cy.findByLabelText("Description").type(
|
||||
"The average rating of our top selling products broken down into categories.",
|
||||
{ delay: 0 },
|
||||
);
|
||||
|
||||
saveQuestion(version);
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Not now").click();
|
||||
|
||||
cy.visit("/question/new");
|
||||
|
||||
newQuestion(version);
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(/Sample (Dataset|Database)/).click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Orders").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Pick the metric you want to see").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Common Metrics").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Revenue").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Pick a column to group by").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Created At")
|
||||
.closest(".List-item")
|
||||
.findByText("by month")
|
||||
.click({ force: true });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Quarter").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Created At: Quarter");
|
||||
|
||||
H.visualize();
|
||||
cy.get("circle");
|
||||
|
||||
cy.findByTestId("viz-type-button").click();
|
||||
cy.findByTestId("Area-button").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Display").click();
|
||||
|
||||
fillAreaUnderLineChart(version);
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Goal line").next().click();
|
||||
cy.findByDisplayValue("0").type("100000").blur();
|
||||
cy.get(".line");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Goal");
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Save").click();
|
||||
cy.findByLabelText("Name").clear().type("Quarterly Revenue");
|
||||
|
||||
saveQuestion(version);
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Not now").click();
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
import { parseVersionString } from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers.js";
|
||||
|
||||
export const version = parseVersionString(Cypress.env("SOURCE_VERSION"));
|
||||
@@ -1,5 +0,0 @@
|
||||
const { defineConfig } = require("cypress");
|
||||
|
||||
const { crossVersionSourceConfig } = require("e2e/support/config");
|
||||
|
||||
module.exports = defineConfig({ e2e: crossVersionSourceConfig });
|
||||
@@ -1,3 +0,0 @@
|
||||
import { parseVersionString } from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers.js";
|
||||
|
||||
export const version = parseVersionString(Cypress.env("TARGET_VERSION"));
|
||||
@@ -1,5 +0,0 @@
|
||||
const { defineConfig } = require("cypress");
|
||||
|
||||
const { crossVersionTargetConfig } = require("e2e/support/config");
|
||||
|
||||
module.exports = defineConfig({ e2e: crossVersionTargetConfig });
|
||||
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
assertTimelineData,
|
||||
dismissOkToPlayWithQuestionsModal,
|
||||
} from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers";
|
||||
import { version } from "e2e/test/scenarios/cross-version/source/helpers/cross-version-source-helpers.js";
|
||||
|
||||
describe(`smoke test the migration to the version ${version}`, () => {
|
||||
it("should already be set up", () => {
|
||||
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
|
||||
|
||||
cy.visit("/");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Sign in to Metabase");
|
||||
|
||||
cy.findByLabelText("Email address").type("admin@metabase.test");
|
||||
cy.findByLabelText("Password").type("12341234");
|
||||
cy.button("Sign in").click();
|
||||
|
||||
cy.findByPlaceholderText("Search…");
|
||||
|
||||
// Question 1
|
||||
cy.visit("/collection/root");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Quarterly Revenue").click();
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
dismissOkToPlayWithQuestionsModal(version);
|
||||
|
||||
cy.get("circle");
|
||||
cy.get(".line");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Goal");
|
||||
cy.get(".x-axis-label").invoke("text").should("eq", "Created At");
|
||||
cy.get(".y-axis-label").invoke("text").should("eq", "Revenue");
|
||||
|
||||
assertTimelineData(version);
|
||||
|
||||
cy.get(".y.axis .tick")
|
||||
.should("contain", "20,000")
|
||||
.and("contain", "100,000")
|
||||
.and("contain", "140,000");
|
||||
|
||||
// Question 2
|
||||
cy.visit("/collection/root");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Rating of Best-selling Products").click();
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
cy.get(".bar").should("have.length", 4);
|
||||
cy.get(".x.axis .tick")
|
||||
.should("contain", "Gizmo")
|
||||
.and("contain", "Gadget")
|
||||
.and("contain", "Doohickey")
|
||||
.and("contain", "Widget");
|
||||
|
||||
cy.get(".value-labels")
|
||||
.should("contain", "3.27")
|
||||
.and("contain", "3.3")
|
||||
.and("contain", "3.71")
|
||||
.and("contain", "3.4");
|
||||
|
||||
cy.get(".x-axis-label").invoke("text").should("eq", "Products → Category");
|
||||
cy.get(".y-axis-label")
|
||||
.invoke("text")
|
||||
.should("eq", "Average of Products → Rating");
|
||||
});
|
||||
});
|
||||
@@ -1630,13 +1630,13 @@ describe("issue 47170", () => {
|
||||
});
|
||||
|
||||
it("should show legible dark mode colors in fullscreen mode (metabase#51524)", () => {
|
||||
cy.visit("/account/profile");
|
||||
cy.findByDisplayValue("Light").click();
|
||||
H.popover().findByText("Dark").click();
|
||||
cy.visit(`/dashboard/${ORDERS_DASHBOARD_ID}`);
|
||||
|
||||
H.dashboardHeader().findByLabelText("Move, trash, and more…").click();
|
||||
H.popover().findByText("Enter fullscreen").click();
|
||||
H.dashboardHeader()
|
||||
.findByLabelText(/dark mode/)
|
||||
.click();
|
||||
|
||||
const primaryTextColor = "rgba(255, 255, 255, 0.95)";
|
||||
|
||||
|
||||
@@ -430,6 +430,19 @@ describe("documents card embed node custom logic", () => {
|
||||
});
|
||||
|
||||
describe("deleting a cardEmbed", () => {
|
||||
it("should allow you to remove a card if it is the first item in a docuemnt (UXW-2169)", () => {
|
||||
cy.visit("/document/new");
|
||||
|
||||
H.documentContent().click();
|
||||
H.addToDocument("/ord", false);
|
||||
H.commandSuggestionItem(/Orders, Count$/).click();
|
||||
|
||||
H.openDocumentCardMenu("Orders, Count");
|
||||
H.popover().findByText("Remove Chart").click();
|
||||
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 0);
|
||||
});
|
||||
|
||||
it("should delete a cardEmbed when selected and Backspace is pressed", () => {
|
||||
H.createDocument({
|
||||
name: "DnD Test Document",
|
||||
|
||||
110
e2e/test/scenarios/documents/document-permissions.cy.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
const { H } = cy;
|
||||
import { USER_GROUPS } from "e2e/support/cypress_data";
|
||||
|
||||
const { ALL_USERS_GROUP } = USER_GROUPS;
|
||||
|
||||
H.describeWithSnowplowEE("document permissions", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.resetSnowplow();
|
||||
cy.signOut();
|
||||
});
|
||||
|
||||
it("should allow a non-admin user to create a new document and save it", () => {
|
||||
cy.signInAsAdmin();
|
||||
|
||||
cy.updateCollectionGraph({
|
||||
[ALL_USERS_GROUP]: { root: "none" },
|
||||
});
|
||||
|
||||
cy.signOut();
|
||||
|
||||
cy.signIn("none");
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
H.newButton("Document").click();
|
||||
cy.title().should("eq", "New document · Metabase");
|
||||
|
||||
cy.findByRole("textbox", { name: "Document Title" })
|
||||
.should("be.focused")
|
||||
.type("User Document");
|
||||
|
||||
H.documentContent().type("This is a document created by a non-admin user");
|
||||
|
||||
cy.findByRole("button", { name: "Save" }).click();
|
||||
|
||||
cy.findByTestId("item-picker-level-0")
|
||||
.findByText("Our analytics")
|
||||
.should("not.exist");
|
||||
H.entityPickerModalItem(0, "Collections").should("exist");
|
||||
|
||||
H.entityPickerModalItem(0, /Personal Collection/).click();
|
||||
H.entityPickerModal().findByRole("button", { name: "Select" }).click();
|
||||
|
||||
cy.location("pathname").should("match", /^\/document\/\d+/);
|
||||
cy.title().should("eq", "User Document · Metabase");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({ event: "document_created" });
|
||||
|
||||
H.appBar()
|
||||
.findByRole("link", { name: /Personal Collection/ })
|
||||
.click();
|
||||
|
||||
H.collectionTable()
|
||||
.findByRole("link", { name: "User Document" })
|
||||
.should("exist");
|
||||
});
|
||||
|
||||
it("should allow a non-admin user to edit their own document", () => {
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
H.createDocument({
|
||||
name: "User Document",
|
||||
document: {
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Original content",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "1",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
},
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
H.visitDocument("@documentId");
|
||||
|
||||
H.documentContent().should("contain.text", "Original content");
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("textbox")
|
||||
.should("have.attr", "contenteditable", "true");
|
||||
|
||||
H.documentContent().click();
|
||||
H.addToDocument(" and some new content");
|
||||
|
||||
H.documentSaveButton().click();
|
||||
|
||||
cy.findByTestId("toast-undo")
|
||||
.findByText("Document saved")
|
||||
.should("be.visible");
|
||||
|
||||
H.documentContent().should(
|
||||
"contain.text",
|
||||
"Original content and some new content",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,15 @@ H.describeWithSnowplowEE("documents", () => {
|
||||
|
||||
// Force the click since this is hidden behind a toast notification
|
||||
H.navigationSidebar().findByText("Trash").click({ force: true });
|
||||
H.getUnpinnedSection().findByText("Test Document").should("exist");
|
||||
H.getUnpinnedSection().findByText("Test Document").should("exist").click();
|
||||
|
||||
cy.log("test that deleted documents cannot be edited (metabase#63112)");
|
||||
cy.findByRole("textbox", { name: "Document Title" })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "readonly");
|
||||
H.documentContent()
|
||||
.findByRole("textbox")
|
||||
.should("have.attr", "contenteditable", "false");
|
||||
});
|
||||
|
||||
it("should handle navigating from /new to /new gracefully", () => {
|
||||
@@ -706,6 +714,31 @@ H.describeWithSnowplowEE("documents", () => {
|
||||
H.getDocumentCard("Orders").should("exist");
|
||||
});
|
||||
|
||||
it("should support renaming cards", () => {
|
||||
cy.log("Add card");
|
||||
H.documentContent().click();
|
||||
H.addToDocument("/", false);
|
||||
H.commandSuggestionItem("Chart").click();
|
||||
H.commandSuggestionDialog()
|
||||
.findByText(PRODUCTS_COUNT_BY_CATEGORY_PIE.name)
|
||||
.click();
|
||||
|
||||
cy.log("Rename card");
|
||||
cy.findByTestId("card-embed-title").realHover();
|
||||
cy.icon("pencil").click();
|
||||
cy.realType("New name{enter}");
|
||||
|
||||
cy.log("Edit query");
|
||||
H.openDocumentCardMenu("New name");
|
||||
H.popover().findByText("Edit Query").click();
|
||||
H.removeSummaryGroupingField({ field: "Category" });
|
||||
H.addSummaryGroupingField({ field: "Price" });
|
||||
H.modal().findByRole("button", { name: "Save and use" }).click();
|
||||
|
||||
cy.log("Assert new name is preserved");
|
||||
H.getDocumentCard("New name").should("exist");
|
||||
});
|
||||
|
||||
it("should support resizing cards", () => {
|
||||
H.documentContent().click();
|
||||
H.addToDocument("/", false);
|
||||
|
||||
@@ -87,15 +87,19 @@ describe("scenarios > models list view", () => {
|
||||
cy.get("@listPreview").should("be.visible");
|
||||
cy.get("@listPreview").within(() => {
|
||||
// Verify preview shows sample data values
|
||||
cy.findByRole("img").should("have.attr", "aria-label", "document icon");
|
||||
cy.findAllByRole("img").should(
|
||||
"have.attr",
|
||||
"aria-label",
|
||||
"document icon",
|
||||
);
|
||||
cy.findAllByText("1").should("have.length", 2);
|
||||
cy.findByText("14").should("be.visible");
|
||||
cy.findByText("37.65").should("be.visible");
|
||||
cy.findByText("2.07").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("Add CREATED_AT column to left columns");
|
||||
cy.get("@leftColumns").within(() => {
|
||||
cy.log("Add CREATED_AT column to right columns");
|
||||
cy.get("@rightColumns").within(() => {
|
||||
cy.get("input").type("CR");
|
||||
});
|
||||
|
||||
@@ -131,20 +135,36 @@ describe("scenarios > models list view", () => {
|
||||
cy.findByText("DISCOUNT").click();
|
||||
});
|
||||
cy.get("@listPreview").within(() => {
|
||||
cy.findByText("$123.46").should("be.visible");
|
||||
cy.findByText("123.46").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("Update list item icon");
|
||||
cy.get("@listPreview").within(() => {
|
||||
cy.findByRole("img").should("have.attr", "aria-label", "document icon");
|
||||
cy.findAllByRole("img").should(
|
||||
"have.attr",
|
||||
"aria-label",
|
||||
"document icon",
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("list-view-icon").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Company").should("be.visible").click();
|
||||
cy.findByRole("img", { name: "factory icon" })
|
||||
.should("have.attr", "aria-label", "factory icon")
|
||||
.click();
|
||||
cy.findByTestId("list-view-icon-colors").then(($list) => {
|
||||
const $button = Cypress.$($list).find("button").eq(2);
|
||||
expect($button.attr("style")).to.include(
|
||||
"background: var(--mb-color-accent1)",
|
||||
);
|
||||
$button.click();
|
||||
});
|
||||
});
|
||||
cy.get("@listPreview").within(() => {
|
||||
cy.findByRole("img").should("have.attr", "aria-label", "company icon");
|
||||
cy.findAllByRole("img")
|
||||
.first()
|
||||
.should("have.attr", "style", "color: var(--mb-color-accent1);")
|
||||
.and("have.attr", "aria-label", "factory icon");
|
||||
});
|
||||
|
||||
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
|
||||
@@ -152,7 +172,7 @@ describe("scenarios > models list view", () => {
|
||||
|
||||
cy.log("Verify that custom column set is correct");
|
||||
cy.findByTestId("visualization-root").within(() => {
|
||||
cy.findByText("ID and CREATED_AT").should("be.visible");
|
||||
cy.findByText("ID").should("be.visible");
|
||||
cy.findByText("USER_ID").should("be.visible");
|
||||
cy.findByText("PRODUCT_ID").should("be.visible");
|
||||
cy.findByText("SUBTOTAL").should("be.visible");
|
||||
@@ -160,7 +180,7 @@ describe("scenarios > models list view", () => {
|
||||
|
||||
cy.findAllByRole("img")
|
||||
.first()
|
||||
.should("have.attr", "aria-label", "company icon");
|
||||
.should("have.attr", "aria-label", "factory icon");
|
||||
cy.findByText("February 11, 2025, 9:40 PM").should("be.visible");
|
||||
cy.findByText("14").should("be.visible");
|
||||
cy.findByText("37.65").should("be.visible");
|
||||
|
||||
@@ -1548,3 +1548,52 @@ describe("issue 63745", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 56094", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should allow to switch between automatic pivot table and usual table visualization (metabase#56094)", () => {
|
||||
H.visitQuestionAdhoc({
|
||||
name: "56094",
|
||||
dataset_query: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
query: {
|
||||
"source-table": PRODUCTS_ID,
|
||||
aggregation: [["count"]],
|
||||
breakout: [
|
||||
[
|
||||
"field",
|
||||
PRODUCTS.CATEGORY,
|
||||
{
|
||||
"base-type": "type/Text",
|
||||
},
|
||||
],
|
||||
[
|
||||
"field",
|
||||
PRODUCTS.RATING,
|
||||
{
|
||||
binning: {
|
||||
strategy: "default",
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
limit: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
H.queryBuilderFooter().findByLabelText("Switch to data").click();
|
||||
|
||||
H.queryBuilderFooterDisplayToggle().should("exist");
|
||||
|
||||
H.queryBuilderFooter().findByLabelText("Switch to visualization").click();
|
||||
|
||||
H.queryBuilderFooterDisplayToggle().should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
(let [add-on {:product-type product-type}]
|
||||
(events/publish-event! :event/cloud-add-on-purchase {:details {:add-on add-on}, :user-id api/*current-user-id*})
|
||||
(hm.client/call :change-add-ons :upsert-add-ons [add-on]))
|
||||
(premium-features/clear-cache)
|
||||
(premium-features/clear-cache!)
|
||||
{:status 200 :body {}}
|
||||
(catch Exception e
|
||||
(log/warnf e "Error adding purchasing add-on '%s'" product-type)
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.collections.models.collection.root :as collection.root]
|
||||
[metabase.graph.core :as graph]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.native-query-snippets.core :as native-query-snippets]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.queries.schema :as queries.schema]
|
||||
[metabase.revisions.core :as revisions]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
@@ -135,68 +137,161 @@
|
||||
(broken-cards-response breakages)))
|
||||
|
||||
(def ^:private entity-keys
|
||||
{:table [:name :description :display_name :db_id :db :schema :fields]
|
||||
:card [:name :type :display :database_id :view_count
|
||||
:created_at :creator :creator_id :description
|
||||
:result_metadata :last-edit-info
|
||||
:collection :collection_id :dashboard :dashboard_id
|
||||
:moderation_reviews]
|
||||
:snippet [:name :description]
|
||||
{:table [:name :description :display_name :db_id :db :schema :fields]
|
||||
:card [:name :type :display :database_id :view_count
|
||||
:created_at :creator :creator_id :description
|
||||
:result_metadata :last-edit-info
|
||||
:collection :collection_id :dashboard :dashboard_id
|
||||
:moderation_reviews]
|
||||
:snippet [:name :description]
|
||||
:transform [:name :description :table]
|
||||
:dashboard [:name :description :view_count
|
||||
:created_at :creator :creator_id :last-edit-info
|
||||
:collection :collection_id
|
||||
:moderation_reviews]
|
||||
:document [:name :description :view_count
|
||||
:created_at :creator
|
||||
:collection :collection_id]
|
||||
:sandbox [:table :table_id]})
|
||||
:document [:name :description :view_count
|
||||
:created_at :creator
|
||||
:collection :collection_id]
|
||||
:sandbox [:table :table_id]})
|
||||
|
||||
(defn- format-subentity [entity]
|
||||
(case (t2/model entity)
|
||||
:model/Collection (select-keys entity [:id :name :authority_level :is_personal])
|
||||
:model/Dashboard (select-keys entity [:id :name])
|
||||
:model/Dashboard (select-keys entity [:id :name])
|
||||
entity))
|
||||
|
||||
(defn- entity-value [entity-type {:keys [id] :as entity} usages]
|
||||
{:id id
|
||||
:type entity-type
|
||||
:data (->> (select-keys entity (entity-keys entity-type))
|
||||
(m/map-vals format-subentity))
|
||||
{:id id
|
||||
:type entity-type
|
||||
:data (->> (select-keys entity (entity-keys entity-type))
|
||||
(m/map-vals format-subentity))
|
||||
:dependents_count (usages [entity-type id])})
|
||||
|
||||
(def ^:private entity-model
|
||||
{:table :model/Table
|
||||
:card :model/Card
|
||||
:snippet :model/NativeQuerySnippet
|
||||
{:table :model/Table
|
||||
:card :model/Card
|
||||
:snippet :model/NativeQuerySnippet
|
||||
:transform :model/Transform
|
||||
:dashboard :model/Dashboard
|
||||
:document :model/Document
|
||||
:sandbox :model/Sandbox})
|
||||
:document :model/Document
|
||||
:sandbox :model/Sandbox})
|
||||
|
||||
(defn- readable-node?
|
||||
"Check if the user can read the entity represented by `[entity-type entity-id]`."
|
||||
[[entity-type entity-id]]
|
||||
(if-let [model (entity-model entity-type)]
|
||||
(try
|
||||
(mi/can-read? model entity-id)
|
||||
(catch Exception e
|
||||
(log/debugf e "Couldn't determine if %s with id %s is readble"
|
||||
(some-> entity-type name) entity-id)
|
||||
false))
|
||||
(do
|
||||
(log/warn "Cannot determine the model of" (some-> entity-type name))
|
||||
false)))
|
||||
;; IMPORTANT: This map defines which fields to select when fetching entities for the dependency graph.
|
||||
;; These field lists MUST be kept in sync with the frontend type definitions in:
|
||||
;; frontend/src/metabase-types/api/dependencies.ts
|
||||
;; (See CardDependencyNodeData, DashboardDependencyNodeData, etc.)
|
||||
;;
|
||||
;; Note: Some fields (like :creator, :collection, :moderation_reviews) are added via t2/hydrate,
|
||||
;; and others (like :last-edit-info, :view_count) are computed/added separately.
|
||||
;; This map only lists the base database columns to SELECT.
|
||||
(def ^:private entity-select-fields
|
||||
{:card [:id :name :description :type :display :database_id :collection_id :dashboard_id :result_metadata
|
||||
:created_at :creator_id
|
||||
;; :card_schema always has to be selected
|
||||
:card_schema]
|
||||
:dashboard [:id :name :description :created_at :creator_id :collection_id]
|
||||
:document [:id :name :created_at :creator_id :collection_id]
|
||||
:table [:id :name :description :display_name :db_id :schema]
|
||||
:transform [:id :name :description]
|
||||
:snippet [:id :name :description]
|
||||
:sandbox [:id :table_id]})
|
||||
|
||||
(defn- visible-entities-filter-clause
|
||||
"Returns a HoneySQL WHERE clause for filtering dependency graph entities by user visibility.
|
||||
|
||||
Accepts three arguments:
|
||||
- `entity-type-field`: Database column name for entity type (e.g., :to_entity_type)
|
||||
- `entity-id-field`: Database column name for entity ID (e.g., :to_entity_id)
|
||||
- `include-archived-items`: How to handle archived items (:exclude, :all, :only). Defaults to :exclude.
|
||||
This applies to both archived collections AND archived entities (cards/dashboards/documents/snippets/tables).
|
||||
|
||||
Returns a compound [:or ...] clause checking whether entities at those columns are readable.
|
||||
|
||||
Handles different entity types:
|
||||
- Superuser-only (:model/Transform, :model/Sandbox): Only if api/*is-superuser?* is true
|
||||
- Collection-based (:model/Card, :model/Dashboard, :model/Document, :model/NativeQuerySnippet):
|
||||
Uses collection/visible-collection-filter-clause for collection filtering and adds archived entity filtering.
|
||||
Native query snippets have additional restrictions for sandboxed users.
|
||||
- Table: Uses mi/visible-filter-clause with appropriate permissions and filters by active/visibility_type.
|
||||
Follows search API conventions: active=true AND visibility_type=nil for non-archived tables.
|
||||
Note: archived_at is not checked separately as archived tables always have active=false."
|
||||
([entity-type-field entity-id-field]
|
||||
(visible-entities-filter-clause entity-type-field entity-id-field nil))
|
||||
([entity-type-field entity-id-field {:keys [include-archived-items] :or {include-archived-items :exclude}}]
|
||||
(into [:or]
|
||||
(keep (fn [[entity-type model]]
|
||||
(let [table-name (t2/table-name model)
|
||||
id-column (keyword (name table-name) "id")]
|
||||
(case model
|
||||
;; Superuser-only entities
|
||||
(:model/Transform :model/Sandbox)
|
||||
(when api/*is-superuser?*
|
||||
[:and
|
||||
[:= entity-type-field (name entity-type)]
|
||||
[:in entity-id-field {:select [:id] :from [table-name]}]])
|
||||
|
||||
;; Collection-based entities with archived field
|
||||
(:model/Card :model/Dashboard :model/Document :model/NativeQuerySnippet)
|
||||
(let [archived-column (keyword (name table-name) "archived")]
|
||||
(when-not (and (= model :model/NativeQuerySnippet)
|
||||
(or (perms/sandboxed-user?)
|
||||
(not (perms/user-has-any-perms-of-type?
|
||||
api/*current-user-id* :perms/create-queries))))
|
||||
[:and
|
||||
[:= entity-type-field (name entity-type)]
|
||||
[:in entity-id-field {:select [:id]
|
||||
:from [table-name]
|
||||
:where [:and
|
||||
;; Filter by collection visibility
|
||||
(collection/visible-collection-filter-clause
|
||||
(keyword (name table-name) "collection_id")
|
||||
{:include-archived-items include-archived-items}
|
||||
{:current-user-id api/*current-user-id*
|
||||
:is-superuser? api/*is-superuser?*})
|
||||
;; Filter by entity archived status
|
||||
(case include-archived-items
|
||||
:exclude [:= archived-column false]
|
||||
:only [:= archived-column true]
|
||||
:all nil)]}]]))
|
||||
|
||||
;; Table with visible-filter-clause and active/visibility_type filtering
|
||||
:model/Table
|
||||
(let [active-column (keyword (name table-name) "active")
|
||||
visibility-type-column (keyword (name table-name) "visibility_type")]
|
||||
[:and
|
||||
[:= entity-type-field (name entity-type)]
|
||||
[:in entity-id-field {:select [:id]
|
||||
:from [table-name]
|
||||
:where [:and
|
||||
(mi/visible-filter-clause
|
||||
model
|
||||
id-column
|
||||
{:user-id api/*current-user-id*
|
||||
:is-superuser? api/*is-superuser?*}
|
||||
{:perms/view-data :unrestricted
|
||||
:perms/create-queries :query-builder})
|
||||
(case include-archived-items
|
||||
:exclude [:and
|
||||
[:= active-column true]
|
||||
[:= visibility-type-column nil]]
|
||||
(:only :all) nil)]}]])))))
|
||||
entity-model)))
|
||||
|
||||
(defn- readable-graph-dependencies
|
||||
"Return a permission-aware dependency graph that only includes readable entities."
|
||||
[]
|
||||
(dependency/filtered-graph-dependencies readable-node?))
|
||||
([]
|
||||
(readable-graph-dependencies nil))
|
||||
([opts]
|
||||
(dependency/filtered-graph-dependencies
|
||||
(fn [entity-type-field entity-id-field]
|
||||
(visible-entities-filter-clause entity-type-field entity-id-field opts)))))
|
||||
|
||||
(defn- readable-graph-dependents
|
||||
"Return a permission-aware dependent graph that only includes readable entities."
|
||||
[]
|
||||
(dependency/filtered-graph-dependents readable-node?))
|
||||
([]
|
||||
(readable-graph-dependents nil))
|
||||
([opts]
|
||||
(dependency/filtered-graph-dependents
|
||||
(fn [entity-type-field entity-id-field]
|
||||
(visible-entities-filter-clause entity-type-field entity-id-field opts)))))
|
||||
|
||||
(defn- calc-usages
|
||||
"Calculates the count of direct dependents for all nodes in `nodes`, based on `graph`. "
|
||||
@@ -224,41 +319,49 @@
|
||||
nodes-by-type (->> (group-by first nodes)
|
||||
(m/map-vals #(map second %)))]
|
||||
(mapcat (fn [[entity-type entity-ids]]
|
||||
(->> (cond-> (t2/select (entity-model entity-type)
|
||||
:id [:in entity-ids])
|
||||
(= entity-type :card) (-> (t2/hydrate :creator :dashboard [:collection :is_personal] :moderation_reviews)
|
||||
(->> (map collection.root/hydrate-root-collection))
|
||||
(revisions/with-last-edit-info :card))
|
||||
(= entity-type :table) (t2/hydrate :fields :db)
|
||||
(= entity-type :transform) (t2/hydrate :table-with-db-and-fields)
|
||||
(= entity-type :dashboard) (-> (t2/hydrate :creator [:collection :is_personal] :moderation_reviews)
|
||||
(->> (map collection.root/hydrate-root-collection))
|
||||
(revisions/with-last-edit-info :dashboard))
|
||||
(= entity-type :document) (-> (t2/hydrate :creator [:collection :is_personal])
|
||||
(->> (map collection.root/hydrate-root-collection)))
|
||||
(= entity-type :sandbox) (t2/hydrate [:table :db :fields]))
|
||||
(mapv #(entity-value entity-type % usages))))
|
||||
(let [model (entity-model entity-type)
|
||||
fields (entity-select-fields entity-type)]
|
||||
(->> (cond-> (t2/select (into [model] fields) :id [:in entity-ids])
|
||||
(= entity-type :card) (-> (t2/hydrate :creator :dashboard [:collection :is_personal] :moderation_reviews)
|
||||
(->> (map collection.root/hydrate-root-collection))
|
||||
(revisions/with-last-edit-info :card))
|
||||
(= entity-type :table) (t2/hydrate :fields :db)
|
||||
(= entity-type :transform) (t2/hydrate :table-with-db-and-fields)
|
||||
(= entity-type :dashboard) (-> (t2/hydrate :creator [:collection :is_personal] :moderation_reviews)
|
||||
(->> (map collection.root/hydrate-root-collection))
|
||||
(revisions/with-last-edit-info :dashboard))
|
||||
(= entity-type :document) (-> (t2/hydrate :creator [:collection :is_personal])
|
||||
(->> (map collection.root/hydrate-root-collection)))
|
||||
(= entity-type :sandbox) (t2/hydrate [:table :db :fields]))
|
||||
(mapv #(entity-value entity-type % usages)))))
|
||||
nodes-by-type)))
|
||||
|
||||
(api.macros/defendpoint :get "/graph"
|
||||
"This endpoint takes an :id and a supported entity :type, and returns a graph of all its upstream dependencies.
|
||||
The graph is represented by a list of :nodes and a list of :edges. Each node has an :id, :type, :data (which
|
||||
depends on the node type), and a map of :dependent_counts per entity type. Each edge is a :model/Dependency"
|
||||
depends on the node type), and a map of :dependent_counts per entity type. Each edge is a :model/Dependency.
|
||||
|
||||
Optional :archived parameter controls whether entities in archived collections are included:
|
||||
- false (default): Excludes entities in archived collections
|
||||
- true: Includes entities in archived collections"
|
||||
[_route-params
|
||||
{:keys [id type]} :- [:map
|
||||
[:id {:optional true} ms/PositiveInt]
|
||||
[:type {:optional true} (ms/enum-decode-keyword (vec (keys entity-model)))]]]
|
||||
{:keys [id type archived]} :- [:map
|
||||
[:id {:optional true} ms/PositiveInt]
|
||||
[:type {:optional true} (ms/enum-decode-keyword (vec (keys entity-model)))]
|
||||
[:archived {:optional true} :boolean]]]
|
||||
(api/read-check (entity-model type) id)
|
||||
(let [starting-nodes [[type id]]
|
||||
upstream-graph (readable-graph-dependencies)
|
||||
;; cache the downstream graph specifically, because between calculating transitive children and calculating
|
||||
;; edges, we'll call this multiple times on the same nodes.
|
||||
downstream-graph (graph/cached-graph (readable-graph-dependents))
|
||||
nodes (into (set starting-nodes)
|
||||
(graph/transitive upstream-graph starting-nodes))
|
||||
edges (graph/calc-edges-between downstream-graph nodes)]
|
||||
{:nodes (expanded-nodes downstream-graph nodes)
|
||||
:edges edges}))
|
||||
(lib-be/with-metadata-provider-cache
|
||||
(let [graph-opts {:include-archived-items (if archived :all :exclude)}
|
||||
starting-nodes [[type id]]
|
||||
upstream-graph (readable-graph-dependencies graph-opts)
|
||||
;; cache the downstream graph specifically, because between calculating transitive children and calculating
|
||||
;; edges, we'll call this multiple times on the same nodes.
|
||||
downstream-graph (graph/cached-graph (readable-graph-dependents graph-opts))
|
||||
nodes (into (set starting-nodes)
|
||||
(graph/transitive upstream-graph starting-nodes))
|
||||
edges (graph/calc-edges-between downstream-graph nodes)]
|
||||
{:nodes (expanded-nodes downstream-graph nodes)
|
||||
:edges edges})))
|
||||
|
||||
(def ^:private dependents-args
|
||||
[:map
|
||||
@@ -266,22 +369,29 @@
|
||||
[:type (ms/enum-decode-keyword (vec (keys entity-model)))]
|
||||
[:dependent_type (ms/enum-decode-keyword (vec (keys entity-model)))]
|
||||
[:dependent_card_type {:optional true} (ms/enum-decode-keyword
|
||||
[:question :model :metric])]])
|
||||
[:question :model :metric])]
|
||||
[:archived {:optional true} :boolean]])
|
||||
|
||||
(api.macros/defendpoint :get "/graph/dependents"
|
||||
"This endpoint takes an :id, :type, :dependent_type, and an optional :dependent_card_type, and returns a list of
|
||||
all that entity's dependents with :dependent_type. If the :dependent_type is :card, the dependents are further
|
||||
filtered by :dependent_card_type."
|
||||
filtered by :dependent_card_type.
|
||||
|
||||
Optional :archived parameter controls whether entities in archived collections are included:
|
||||
- false (default): Excludes entities in archived collections
|
||||
- true: Includes entities in archived collections"
|
||||
[_route-params
|
||||
{:keys [id type dependent_type dependent_card_type]} :- dependents-args]
|
||||
{:keys [id type dependent_type dependent_card_type archived]} :- dependents-args]
|
||||
(api/read-check (entity-model type) id)
|
||||
(let [downstream-graph (graph/cached-graph (readable-graph-dependents))
|
||||
nodes (-> (graph/children-of downstream-graph [[type id]])
|
||||
(get [type id]))]
|
||||
(->> (expanded-nodes downstream-graph nodes)
|
||||
(filter #(and (= (:type %) dependent_type)
|
||||
(or (not= dependent_type :card)
|
||||
(= (-> % :data :type) dependent_card_type)))))))
|
||||
(lib-be/with-metadata-provider-cache
|
||||
(let [graph-opts {:include-archived-items (if archived :all :exclude)}
|
||||
downstream-graph (graph/cached-graph (readable-graph-dependents graph-opts))
|
||||
nodes (-> (graph/children-of downstream-graph [[type id]])
|
||||
(get [type id]))]
|
||||
(->> (expanded-nodes downstream-graph nodes)
|
||||
(filter #(and (= (:type %) dependent_type)
|
||||
(or (not= dependent_type :card)
|
||||
(= (-> % :data :type) dependent_card_type))))))))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/dependencies` routes."
|
||||
|
||||
@@ -21,30 +21,55 @@
|
||||
{:from_entity_type mi/transform-keyword
|
||||
:to_entity_type mi/transform-keyword})
|
||||
|
||||
(defn- deps-children [src-type src-id dst-type dst-id key-seq]
|
||||
;; Group all keys with the same type together, so we make O(types) indexed [[t2/select]] calls, not O(n).
|
||||
(transduce (map (fn [[entity-type entity-keys]]
|
||||
(let [deps (t2/select :model/Dependency
|
||||
src-type entity-type
|
||||
src-id [:in entity-keys])]
|
||||
(u/group-by (juxt src-type src-id)
|
||||
(juxt dst-type dst-id)
|
||||
conj #{}
|
||||
deps))))
|
||||
merge {}
|
||||
(u/group-by first second key-seq)))
|
||||
(defn- deps-children
|
||||
"Get dependency children with optional database-level filtering.
|
||||
|
||||
Returns a map from [src-type src-id] tuples to sets of [dst-type dst-id] tuples representing dependencies.
|
||||
|
||||
When `destination-filter-fn` is provided, it should be a function accepting two arguments
|
||||
(entity-type-field, entity-id-field) and returning a HoneySQL WHERE clause for filtering destination entities."
|
||||
([src-type src-id dst-type dst-id key-seq]
|
||||
(deps-children src-type src-id dst-type dst-id key-seq nil))
|
||||
([src-type src-id dst-type dst-id key-seq destination-filter-fn]
|
||||
(let [base-filter (cond-> [:and]
|
||||
destination-filter-fn (conj (destination-filter-fn dst-type dst-id)))]
|
||||
(transduce (map (fn [[entity-type entity-keys]]
|
||||
(let [full-filter (conj base-filter
|
||||
[:= src-type (name entity-type)]
|
||||
[:in src-id entity-keys])
|
||||
deps (t2/select :model/Dependency {:where full-filter})]
|
||||
(u/group-by (juxt src-type src-id)
|
||||
(juxt dst-type dst-id)
|
||||
conj #{}
|
||||
deps))))
|
||||
merge {}
|
||||
(u/group-by first second key-seq)))))
|
||||
|
||||
(defn- key-dependents
|
||||
"Get the dependent entity keys for the entity keys in `entity-keys`.
|
||||
Entity keys are [entity-type, entity-id] pairs. See [[entity-type->model]]."
|
||||
[key-seq]
|
||||
(deps-children :to_entity_type :to_entity_id :from_entity_type :from_entity_id key-seq))
|
||||
"Get the dependent entity keys for the entity keys in `key-seq`.
|
||||
|
||||
Entity keys are [entity-type entity-id] tuples. Returns a map from source entity keys
|
||||
to sets of dependent (downstream) entity keys.
|
||||
|
||||
When `destination-filter-fn` is provided, it should be a function accepting two arguments
|
||||
(entity-type-field, entity-id-field) and returning a HoneySQL WHERE clause for filtering."
|
||||
([key-seq]
|
||||
(key-dependents key-seq nil))
|
||||
([key-seq destination-filter-fn]
|
||||
(deps-children :to_entity_type :to_entity_id :from_entity_type :from_entity_id key-seq destination-filter-fn)))
|
||||
|
||||
(defn- key-dependencies
|
||||
"Get the dependency entity keys for the entity keys in `entity-keys`, a seq of keys.
|
||||
Entity keys are [entity-type, entity-id] pairs. See [[entity-type->model]]."
|
||||
[key-seq]
|
||||
(deps-children :from_entity_type :from_entity_id :to_entity_type :to_entity_id key-seq))
|
||||
"Get the dependency entity keys for the entity keys in `key-seq`.
|
||||
|
||||
Entity keys are [entity-type entity-id] tuples. Returns a map from source entity keys
|
||||
to sets of dependency (upstream) entity keys.
|
||||
|
||||
When `destination-filter-fn` is provided, it should be a function accepting two arguments
|
||||
(entity-type-field, entity-id-field) and returning a HoneySQL WHERE clause for filtering."
|
||||
([key-seq]
|
||||
(key-dependencies key-seq nil))
|
||||
([key-seq destination-filter-fn]
|
||||
(deps-children :from_entity_type :from_entity_id :to_entity_type :to_entity_id key-seq destination-filter-fn)))
|
||||
|
||||
(p/deftype+ DependencyGraph [children-fn]
|
||||
graph/Graph
|
||||
@@ -61,27 +86,35 @@
|
||||
[]
|
||||
(->DependencyGraph key-dependencies))
|
||||
|
||||
(defn filtered-graph-dependencies
|
||||
"Return a dependency graph for finding dependencies (upstream entities), filtered by `node-predicate`.
|
||||
The predicate receives entity keys as `[entity-type entity-id]` and should return true for nodes to include."
|
||||
[node-predicate]
|
||||
(defn- filtered-graph
|
||||
"Create a dependency graph with database-level filtering.
|
||||
|
||||
Arguments:
|
||||
- `key-fn`: Either key-dependencies or key-dependents, determining graph direction
|
||||
- `destination-filter-fn`: Function accepting (entity-type-field, entity-id-field)
|
||||
and returning a HoneySQL WHERE clause"
|
||||
[key-fn destination-filter-fn]
|
||||
(->DependencyGraph
|
||||
(fn [key-seq]
|
||||
(into {}
|
||||
(map (fn [[k v]]
|
||||
[k (filterv node-predicate v)]))
|
||||
(key-dependencies key-seq)))))
|
||||
(key-fn key-seq destination-filter-fn))))
|
||||
|
||||
(defn filtered-graph-dependencies
|
||||
"Create a permission-aware dependency graph for finding upstream dependencies.
|
||||
|
||||
Arguments:
|
||||
- `destination-filter-fn`: Function accepting (entity-type-field, entity-id-field)
|
||||
and returning a HoneySQL WHERE clause for filtering destination entities"
|
||||
[destination-filter-fn]
|
||||
(filtered-graph key-dependencies destination-filter-fn))
|
||||
|
||||
(defn filtered-graph-dependents
|
||||
"Return a dependency graph for finding dependents (downstream entities), filtered by `node-predicate`.
|
||||
The predicate receives entity keys as `[entity-type entity-id]` and should return true for nodes to include."
|
||||
[node-predicate]
|
||||
(->DependencyGraph
|
||||
(fn [key-seq]
|
||||
(into {}
|
||||
(map (fn [[k v]]
|
||||
[k (filterv node-predicate v)]))
|
||||
(key-dependents key-seq)))))
|
||||
"Create a permission-aware dependency graph for finding downstream dependents.
|
||||
|
||||
Arguments:
|
||||
- `destination-filter-fn`: Function accepting (entity-type-field, entity-id-field)
|
||||
and returning a HoneySQL WHERE clause for filtering destination entities"
|
||||
[destination-filter-fn]
|
||||
(filtered-graph key-dependents destination-filter-fn))
|
||||
|
||||
(defn transitive-dependents
|
||||
"Given a map of updated entities `{entity-type [{:id 1, ...} ...]}`, return a map of its transitive dependents
|
||||
|
||||
@@ -197,6 +197,8 @@
|
||||
_query-params
|
||||
{:keys [name document collection_id collection_position cards] :as body} :- DocumentUpdateOptions]
|
||||
(let [existing-document (api/check-404 (get-document document-id))]
|
||||
(when-not (contains? body :archived)
|
||||
(api/check-not-archived existing-document))
|
||||
(api/write-check existing-document)
|
||||
(when (api/column-will-change? :collection_id existing-document body)
|
||||
(m.document/validate-collection-move-permissions (:collection_id existing-document) collection_id))
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
[:and
|
||||
[:map
|
||||
[:name :string]
|
||||
[:type [:enum :number, :string, :date, :datetime, :time, :boolean, :null]]
|
||||
[:type [:enum :number, :string, :date, :datetime, :time, :boolean, nil]]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:table-reference {:optional true} :string]]
|
||||
[:map {:encode/ai-service-request #(set/rename-keys % {:table-reference :table_reference})}]])
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[metabase.lib.types.isa :as lib.types.isa]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.humanization :as u.humanization]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.warehouse-schema.models.field-values :as field-values]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
@@ -27,16 +28,39 @@
|
||||
{:order-by [[:id :desc]]})]
|
||||
(= (:status review) "verified")))
|
||||
|
||||
(def ^:private max-glossary-items
|
||||
"Maximal number of items from glossary to include in Metabot's context."
|
||||
100)
|
||||
|
||||
(def ^:private glossary-order-column
|
||||
"Column to order by when selecting glossary items for Metabot's context injection."
|
||||
:updated_at)
|
||||
|
||||
(defn- glossary-for-context
|
||||
[]
|
||||
;; Rather small glossary sizes are anticipated. Additional count is bearable.
|
||||
(let [glossary-size (t2/count :model/Glossary)]
|
||||
(when (> glossary-size max-glossary-items)
|
||||
;; If we are notified about the following warning we should reconsider current,
|
||||
;; context injection, approach to glossary integration into Metabot.
|
||||
(log/warnf "Glossary size is larger than limit for context injection (%d > %d)."
|
||||
glossary-size max-glossary-items)))
|
||||
(not-empty (t2/select-fn->fn :term :definition :model/Glossary
|
||||
{:order-by [[glossary-order-column :desc]]
|
||||
:limit max-glossary-items})))
|
||||
|
||||
(defn get-current-user
|
||||
"Get information about the current user."
|
||||
[]
|
||||
(if-let [{:keys [id email first_name last_name]}
|
||||
(or (some-> api/*current-user* deref)
|
||||
(t2/select-one [:model/User :id :email :first_name :last_name] api/*current-user-id*))]
|
||||
{:structured-output {:id id
|
||||
:type :user
|
||||
:name (str first_name " " last_name)
|
||||
:email-address email}}
|
||||
{:structured-output (merge {:id id
|
||||
:type :user
|
||||
:name (str first_name " " last_name)
|
||||
:email-address email}
|
||||
(when-some [glossary (glossary-for-context)]
|
||||
{:glossary glossary}))}
|
||||
{:output "current user not found"}))
|
||||
|
||||
(defn get-dashboard-details
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
[:id :int]
|
||||
[:type [:= :user]]
|
||||
[:name :string]
|
||||
[:email_address :string]]]]
|
||||
[:email_address :string]
|
||||
[:glossary [:maybe [:map-of :string :string]]]]]]
|
||||
[:map [:output :string]]])
|
||||
(mr/def ::get-dashboard-details-result
|
||||
[:or
|
||||
@@ -602,20 +603,17 @@
|
||||
:with_metric_default_temporal_breakout :with-default-temporal-breakout?})}]])
|
||||
|
||||
(mr/def ::table-result
|
||||
[:schema
|
||||
{:registry {::table-result
|
||||
[:map
|
||||
[:id :int]
|
||||
[:type [:enum :model :table]]
|
||||
[:name :string]
|
||||
[:display_name :string]
|
||||
[:database_id :int]
|
||||
[:database_schema {:optional true} [:maybe :string]] ; Schema name, if applicable
|
||||
[:fields ::columns]
|
||||
[:related_tables {:optional true} [:sequential [:ref ::table-result]]]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:metrics {:optional true} [:sequential ::basic-metric]]]}}
|
||||
::table-result])
|
||||
[:map
|
||||
[:id :int]
|
||||
[:type [:enum :model :table]]
|
||||
[:name :string]
|
||||
[:display_name :string]
|
||||
[:database_id :int]
|
||||
[:database_schema {:optional true} [:maybe :string]] ; Schema name, if applicable
|
||||
[:fields ::columns]
|
||||
[:related_tables {:optional true} [:sequential [:ref ::table-result]]]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:metrics {:optional true} [:sequential ::basic-metric]]])
|
||||
|
||||
(mr/def ::get-table-details-result
|
||||
[:or
|
||||
|
||||
@@ -46,7 +46,3 @@
|
||||
(defenterprise decode-airgap-token
|
||||
"Decodes the airgap token and returns the decoded token."
|
||||
:feature :none [token] (decode-token token))
|
||||
|
||||
(defenterprise token-valid-now?
|
||||
"Check that the decoded token is still valid and returns the decoded token."
|
||||
:feature :none [token-status] (valid-now? token-status))
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
{:terms_of_service true})))))
|
||||
(testing "succeeds"
|
||||
(let [{store-api-proxy :proxy store-api-calls :calls} (semantic.tu/spy (constantly nil))
|
||||
{clear-token-cache-proxy :proxy clear-token-cache-calls :calls} (semantic.tu/spy premium-features/clear-cache)]
|
||||
(with-redefs [hm.client/call store-api-proxy
|
||||
premium-features/clear-cache clear-token-cache-proxy]
|
||||
{clear-token-cache-proxy :proxy clear-token-cache-calls :calls} (semantic.tu/spy premium-features/clear-cache!)]
|
||||
(with-redefs [hm.client/call store-api-proxy
|
||||
premium-features/clear-cache! clear-token-cache-proxy]
|
||||
(is (=? {}
|
||||
(mt/user-http-request user :post 200 "ee/cloud-add-ons/metabase-ai"
|
||||
{:terms_of_service true})))
|
||||
|
||||
@@ -316,6 +316,57 @@
|
||||
:type "card"}]
|
||||
response))))))))
|
||||
|
||||
(deftest graph-archived-card-test
|
||||
(testing "GET /api/ee/dependencies/graph with archived parameter"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(mt/with-temp [:model/User user {:email "test@test.com"}]
|
||||
(let [base-card (card/create-card! (basic-card "Archived Base Card") user)
|
||||
dependent-card (card/create-card! (wrap-card base-card) user)]
|
||||
(card/update-card! {:card-before-update base-card
|
||||
:card-updates {:archived true}})
|
||||
(testing "archived=false (default) excludes archived card from dependencies"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph"
|
||||
:id (:id dependent-card)
|
||||
:type "card")
|
||||
node-ids (set (map :id (:nodes response)))]
|
||||
(is (contains? node-ids (:id dependent-card)))
|
||||
(is (not (contains? node-ids (:id base-card))))))
|
||||
(testing "archived=true includes archived card in dependencies"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph"
|
||||
:id (:id dependent-card)
|
||||
:type "card"
|
||||
:archived true)
|
||||
node-ids (set (map :id (:nodes response)))]
|
||||
(is (contains? node-ids (:id dependent-card)))
|
||||
(is (contains? node-ids (:id base-card)))
|
||||
(is (contains? node-ids (mt/id :orders)))))))))))
|
||||
|
||||
(deftest dependents-archived-card-test
|
||||
(testing "GET /api/ee/dependencies/graph/dependents with archived parameter"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(mt/with-temp [:model/User user {:email "test@test.com"}]
|
||||
(let [base-card (card/create-card! (basic-card "Base Card") user)
|
||||
dependent-card (card/create-card! (wrap-card base-card) user)]
|
||||
(card/update-card! {:card-before-update dependent-card
|
||||
:card-updates {:archived true}})
|
||||
(testing "archived=false (default) excludes archived dependent"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph/dependents"
|
||||
:id (:id base-card)
|
||||
:type "card"
|
||||
:dependent_type "card")]
|
||||
(is (empty? response))))
|
||||
(testing "archived=true includes archived dependent"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph/dependents"
|
||||
:id (:id base-card)
|
||||
:type "card"
|
||||
:dependent_type "card"
|
||||
:dependent_card_type "question"
|
||||
:archived true)
|
||||
dependent-ids (set (map :id response))]
|
||||
(is (contains? dependent-ids (:id dependent-card)))))))))))
|
||||
|
||||
(deftest check-card-permissions-test
|
||||
(testing "POST /api/ee/dependencies/check_card requires read permissions on the input card"
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
[clojure.set :as set]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[metabase-enterprise.dependencies.models.dependency :as deps.graph]
|
||||
[metabase.graph.core :as graph]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.queries.models.card :as card]
|
||||
[metabase.test :as mt]
|
||||
@@ -223,3 +224,71 @@
|
||||
(let [card (card/create-card! (basic-orders) user)]
|
||||
(is (= deps.graph/current-dependency-analysis-version
|
||||
(t2/select-one-fn :dependency_analysis_version :model/Card :id (:id card))))))))))
|
||||
|
||||
(deftest filtered-graph-dependencies-test
|
||||
(testing "filtered-graph-dependencies respects filter clause"
|
||||
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [{id1 :id :as card1} (card/create-card! (basic-orders) user)
|
||||
{id2 :id :as card2} (card/create-card! (wrap-card card1) user)
|
||||
{id3 :id :as _card3} (card/create-card! (wrap-card card2) user)]
|
||||
(testing "without filter, returns all dependencies"
|
||||
(let [graph (deps.graph/graph-dependencies)
|
||||
deps (graph/transitive graph [[:card id3]])]
|
||||
(is (= #{[:card id2] [:card id1] [:table (mt/id :orders)]}
|
||||
(set deps)))))
|
||||
(testing "with filter excluding card1, omits card1 and its dependencies"
|
||||
(let [filter-fn (fn [entity-type-field entity-id-field]
|
||||
[:and
|
||||
[:= entity-type-field "card"]
|
||||
[:in entity-id-field [id2 id3]]])
|
||||
graph (deps.graph/filtered-graph-dependencies filter-fn)
|
||||
deps (graph/transitive graph [[:card id3]])]
|
||||
(is (= #{[:card id2]}
|
||||
(set deps))
|
||||
"Should only include card2, not card1 or table")))
|
||||
(testing "with filter excluding card2, breaks the chain"
|
||||
(let [filter-fn (fn [entity-type-field entity-id-field]
|
||||
[:and
|
||||
[:= entity-type-field "card"]
|
||||
[:in entity-id-field [id1 id3]]])
|
||||
graph (deps.graph/filtered-graph-dependencies filter-fn)
|
||||
deps (graph/transitive graph [[:card id3]])]
|
||||
(is (= #{}
|
||||
(set deps))
|
||||
"Should be empty, chain is broken at card2")))))))))
|
||||
|
||||
(deftest filtered-graph-dependents-test
|
||||
(testing "filtered-graph-dependents respects filter clause"
|
||||
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
|
||||
(mt/with-premium-features #{:dependencies}
|
||||
(mt/with-model-cleanup [:model/Card :model/Dependency]
|
||||
(let [{id1 :id :as card1} (card/create-card! (basic-orders) user)
|
||||
{id2 :id :as card2} (card/create-card! (wrap-card card1) user)
|
||||
{id3 :id :as _card3} (card/create-card! (wrap-card card2) user)]
|
||||
(testing "without filter, returns all dependents"
|
||||
(let [graph (deps.graph/graph-dependents)
|
||||
deps (graph/transitive graph [[:card id1]])]
|
||||
(is (= #{[:card id2] [:card id3]}
|
||||
(set deps)))))
|
||||
(testing "with filter excluding card3, omits card3"
|
||||
(let [filter-fn (fn [entity-type-field entity-id-field]
|
||||
[:and
|
||||
[:= entity-type-field "card"]
|
||||
[:in entity-id-field [id1 id2]]])
|
||||
graph (deps.graph/filtered-graph-dependents filter-fn)
|
||||
deps (graph/transitive graph [[:card id1]])]
|
||||
(is (= #{[:card id2]}
|
||||
(set deps))
|
||||
"Should only include card2, not card3")))
|
||||
(testing "with filter excluding card2, breaks the chain"
|
||||
(let [filter-fn (fn [entity-type-field entity-id-field]
|
||||
[:and
|
||||
[:= entity-type-field "card"]
|
||||
[:in entity-id-field [id1 id3]]])
|
||||
graph (deps.graph/filtered-graph-dependents filter-fn)
|
||||
deps (graph/transitive graph [[:card id1]])]
|
||||
(is (= #{}
|
||||
(set deps))
|
||||
"Should be empty, chain is broken at card2")))))))))
|
||||
|
||||
@@ -83,6 +83,17 @@
|
||||
(mt/user-http-request :rasta :put 403 (str "ee/document/" document-id)
|
||||
{:name "Meow"}))))
|
||||
|
||||
(deftest put-document-archived-test
|
||||
(testing "PUT /api/ee/document/:id - cannot update archived document"
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Test Document"
|
||||
:document (documents.test-util/text->prose-mirror-ast "Initial")
|
||||
:archived true}]
|
||||
(testing "editing archived document returns 404"
|
||||
(is (= "The object has been archived."
|
||||
(:message (mt/user-http-request :crowberto
|
||||
:put 404 (format "ee/document/%s" doc-id)
|
||||
{:name "Updated Name"}))))))))
|
||||
|
||||
(deftest post-document-with-no-perms-test
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}]
|
||||
(mt/with-non-admin-groups-no-collection-perms coll-id
|
||||
@@ -2318,7 +2329,7 @@
|
||||
{:name "Doc with bad reference"
|
||||
:collection_id remote-synced-id
|
||||
:document (prose-mirror-with-smartlink "Link to card" card-id)})]
|
||||
(is (= "Model has non-remote-synced dependencies" (:message response))
|
||||
(is (= "Uses content that is not remote synced." (:message response))
|
||||
"Should report non-remote-synced dependencies"))))))))
|
||||
|
||||
(deftest create-document-in-remote-synced-with-remote-synced-smartlink-test
|
||||
@@ -2394,7 +2405,7 @@
|
||||
(let [response (mt/user-http-request :crowberto
|
||||
:put 400 (format "ee/document/%s" doc-id)
|
||||
{:document (prose-mirror-with-smartlink "Bad link" card-id)})]
|
||||
(is (= "Model has non-remote-synced dependencies" (:message response)))))))))
|
||||
(is (= "Uses content that is not remote synced." (:message response)))))))))
|
||||
|
||||
(deftest update-document-in-remote-synced-with-remote-synced-smartlink-test
|
||||
(testing "PUT /api/ee/document/:id - updating document in remote-synced collection to add remote-synced reference"
|
||||
@@ -2499,7 +2510,7 @@
|
||||
(let [response (mt/user-http-request :crowberto
|
||||
:put 400 (format "ee/document/%s" doc-id)
|
||||
{:collection_id remote-synced-id})]
|
||||
(is (= "Model has non-remote-synced dependencies" (:message response)))
|
||||
(is (= "Uses content that is not remote synced." (:message response)))
|
||||
|
||||
;; Verify document was NOT moved
|
||||
(is (= regular-id (:collection_id (t2/select-one :model/Document :id doc-id)))
|
||||
@@ -2529,7 +2540,7 @@
|
||||
:put 400 (format "ee/document/%s" doc-id)
|
||||
{:document (prose-mirror-with-smartlink "Bad" regular-card-id)
|
||||
:collection_id remote-synced-id})]
|
||||
(is (= "Model has non-remote-synced dependencies" (:message response)))))
|
||||
(is (= "Uses content that is not remote synced." (:message response)))))
|
||||
|
||||
(testing "Does not throw when moving to a regular collection"
|
||||
(mt/user-http-request :crowberto
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.suggested-prompts :as metabot-v3.suggested-prompts]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.util]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.malli :as mu])
|
||||
[metabase.util.malli :as mu]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.io ByteArrayInputStream ByteArrayOutputStream)
|
||||
(metabase.server.streaming_response StreamingResponse)))
|
||||
@@ -59,3 +64,34 @@
|
||||
(is (=? [{:_type :TEXT :content "a1a2a3"}
|
||||
{:_type :FINISH_MESSAGE :finish_reason "stop"}]
|
||||
(metabot-v3.util/aisdk->messages "assistant" (str/split-lines body))))))))))
|
||||
|
||||
(deftest example-generation-payload-unknown-field-types-test
|
||||
(let [mp (mt/metadata-provider)]
|
||||
(mt/with-temp [:model/Card c {:type :metric
|
||||
:dataset_query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
|
||||
(lib/aggregate
|
||||
(lib/count (lib.metadata/field mp (mt/id :orders :total)))))
|
||||
;; Set large view count to ensure metric is picked in `generate-sample-prompts`.
|
||||
:view_count 1000}]
|
||||
;; Add column with type/* to metric result_metadata
|
||||
(t2/update! :model/Card :id (:id c)
|
||||
{:result_metadata (conj (:result_metadata (t2/select-one :model/Card :id (:id c)))
|
||||
{:database_type "point"
|
||||
:base_type :type/*
|
||||
:effective_type :type/*
|
||||
:name "dummy"})})
|
||||
(testing "No exception is thrown when type/* column is present it result_metadata"
|
||||
(is (= :not-thrown
|
||||
(try
|
||||
;; Override calls to ai service as we are interested in no exception being thrown
|
||||
;; prior to those calls.
|
||||
(with-redefs [metabot-v3.client/post! (constantly {:status 200
|
||||
:body {:table_questions []
|
||||
:metric_questions []}})]
|
||||
(let [metabot-eid (get-in metabot-v3.config/metabot-config [metabot-v3.config/internal-metabot-id
|
||||
:entity-id])
|
||||
metabot-id (t2/select-one-fn :id :model/Metabot :entity_id metabot-eid)]
|
||||
(metabot-v3.suggested-prompts/generate-sample-prompts metabot-id)
|
||||
:not-thrown))
|
||||
(catch Exception e
|
||||
e))))))))
|
||||
|
||||
@@ -493,17 +493,19 @@
|
||||
|
||||
(deftest ^:parallel get-current-user-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-current-user"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output {:id (mt/user->id :rasta)
|
||||
:type "user"
|
||||
:name "Rasta Toucan"
|
||||
:email_address "rasta@metabase.com"}
|
||||
:conversation_id conversation-id}
|
||||
response)))))
|
||||
(mt/with-temp [:model/Glossary _ {:term "asdf" :definition "Sales Team Performance"}]
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-current-user"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output {:id (mt/user->id :rasta)
|
||||
:type "user"
|
||||
:name "Rasta Toucan"
|
||||
:email_address "rasta@metabase.com"
|
||||
:glossary {(keyword "asdf") "Sales Team Performance"}}
|
||||
:conversation_id conversation-id}
|
||||
response))))))
|
||||
|
||||
(deftest get-dashboard-details-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
@@ -1225,8 +1227,9 @@
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:conversation_id conversation-id})
|
||||
(update :structured_output (fn [output]
|
||||
(filter #(#{(:id t1) (:id t2) (:id t3)} (:id %))
|
||||
output)))))))))))
|
||||
(->> output
|
||||
(filter #(#{(:id t1) (:id t2) (:id t3)} (:id %)))
|
||||
(sort-by :id))))))))))))
|
||||
|
||||
(deftest get-transform-test
|
||||
(mt/with-premium-features #{:metabot-v3 :transforms}
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
(let [response (mt/user-http-request :crowberto :put 400 (str "dashboard/" dash-id)
|
||||
{:collection_id target-id})]
|
||||
;; Verify error response contains dependency information
|
||||
(is (str/includes? (:message response) "non-remote-synced dependencies")
|
||||
"Error message should mention non-remote-synced dependencies"))
|
||||
(is (str/includes? (:message response) "content that is not remote synced")
|
||||
"Error message should mention content that is not remote synced"))
|
||||
|
||||
;; Verify the transaction was rolled back - dashboard should not be moved
|
||||
(let [unchanged-dash (t2/select-one :model/Dashboard :id dash-id)]
|
||||
@@ -150,8 +150,8 @@
|
||||
(let [response (mt/user-http-request :crowberto :post 400 (format "dashboard/%d/copy" dash-id)
|
||||
{:collection_id target-id})]
|
||||
;; Verify error response contains dependency information
|
||||
(is (str/includes? (:message response) "non-remote-synced dependencies")
|
||||
"Error message should mention non-remote-synced dependencies"))
|
||||
(is (str/includes? (:message response) "content that is not remote synced")
|
||||
"Error message should mention content that is not remote synced"))
|
||||
|
||||
;; Verify no dashboard was created
|
||||
(is (= 1 (t2/count :model/Dashboard :name "Dashboard to Copy"))
|
||||
|
||||
@@ -196,6 +196,19 @@ describe("EditableDashboard", () => {
|
||||
expect(dataPickerDataCallUrl).toContain("models=dataset");
|
||||
expect(dataPickerDataCallUrl).not.toContain("models=table");
|
||||
});
|
||||
|
||||
it("should show 'Add a chart' button on empty dashboards", async () => {
|
||||
await setup({ dashcards: [] });
|
||||
|
||||
expect(screen.getByText("This dashboard is empty")).toBeInTheDocument();
|
||||
expect(screen.getByText("Add a chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should allow editing the dashboard title", async () => {
|
||||
await setup();
|
||||
|
||||
expect(screen.getByTestId("dashboard-name-heading")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
function setupSimpleDataPickerEndpoints() {
|
||||
|
||||
@@ -131,4 +131,17 @@ describe("InteractiveDashboard", () => {
|
||||
dashboardHeader.queryByTestId("dashboard-header-row-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show 'Add a chart' button on empty dashboards", async () => {
|
||||
await setup({ dashcards: [] });
|
||||
|
||||
expect(screen.getByText("This dashboard is empty")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Add a chart")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not allow editing the dashboard title", async () => {
|
||||
await setup();
|
||||
|
||||
expect(screen.getByTestId("dashboard-name-heading")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,7 +342,6 @@ export const ExperimentalLayout = {
|
||||
<Paper>
|
||||
<SdkDashboard.ExportAsPdfButton />
|
||||
<SdkDashboard.InfoButton />
|
||||
<SdkDashboard.NightModeButton />
|
||||
<SdkDashboard.RefreshPeriod />
|
||||
</Paper>
|
||||
</Popover.Dropdown>
|
||||
|
||||
@@ -375,7 +375,6 @@ export const SdkDashboard = withPublicComponentWrapper(
|
||||
| "FullscreenButton"
|
||||
| "ExportAsPdfButton"
|
||||
| "InfoButton"
|
||||
| "NightModeButton"
|
||||
| "RefreshPeriod"
|
||||
>;
|
||||
|
||||
@@ -387,7 +386,6 @@ SdkDashboard.ParametersList = Dashboard.ParametersList;
|
||||
SdkDashboard.FullscreenButton = Dashboard.FullscreenButton;
|
||||
SdkDashboard.ExportAsPdfButton = Dashboard.ExportAsPdfButton;
|
||||
SdkDashboard.InfoButton = Dashboard.InfoButton;
|
||||
SdkDashboard.NightModeButton = Dashboard.NightModeButton;
|
||||
SdkDashboard.RefreshPeriod = Dashboard.RefreshPeriod;
|
||||
|
||||
type DashboardQueryBuilderProps = {
|
||||
|
||||
@@ -100,4 +100,17 @@ describe("StaticDashboard", () => {
|
||||
dashboardHeader.queryByTestId("dashboard-header-row-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show 'Add a chart' button on empty dashboards", async () => {
|
||||
await setup({ dashcards: [] });
|
||||
|
||||
expect(screen.getByText("This dashboard is empty")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Add a chart")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not allow editing the dashboard title", async () => {
|
||||
await setup();
|
||||
|
||||
expect(screen.getByTestId("dashboard-name-heading")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { setupSdkState } from "embedding-sdk-bundle/test/server-mocks/sdk-init";
|
||||
import type { MetabaseProviderProps } from "embedding-sdk-bundle/types/metabase-provider";
|
||||
import { useLocale } from "metabase/common/hooks/use-locale";
|
||||
import { Box } from "metabase/ui";
|
||||
import type { DashboardCard } from "metabase-types/api";
|
||||
import {
|
||||
createMockCard,
|
||||
createMockCardQueryMetadata,
|
||||
@@ -102,7 +103,11 @@ export const textDashcard2 = createMockTextDashboardCard({
|
||||
dashboard_tab_id: dashboardTabs[1].id,
|
||||
});
|
||||
|
||||
export const dashcards = [tableDashcard, textDashcard, textDashcard2];
|
||||
export const DEFAULT_DASHCARDS: DashboardCard[] = [
|
||||
tableDashcard,
|
||||
textDashcard,
|
||||
textDashcard2,
|
||||
];
|
||||
|
||||
export interface SetupSdkDashboardOptions {
|
||||
props?: Partial<SdkDashboardProps>;
|
||||
@@ -111,6 +116,7 @@ export interface SetupSdkDashboardOptions {
|
||||
component: React.ComponentType<SdkDashboardProps>;
|
||||
dashboardName?: string;
|
||||
dataPickerProps?: EditableDashboardProps["dataPickerProps"];
|
||||
dashcards?: DashboardCard[];
|
||||
}
|
||||
|
||||
jest.mock("metabase/common/hooks/use-locale", () => ({
|
||||
@@ -124,6 +130,7 @@ export const setupSdkDashboard = async ({
|
||||
component: Component,
|
||||
dashboardName = "Dashboard",
|
||||
dataPickerProps,
|
||||
dashcards = DEFAULT_DASHCARDS,
|
||||
}: SetupSdkDashboardOptions) => {
|
||||
const useLocaleMock = useLocale as jest.Mock;
|
||||
useLocaleMock.mockReturnValue({ isLocaleLoading });
|
||||
@@ -212,7 +219,14 @@ export const setupSdkDashboard = async ({
|
||||
);
|
||||
|
||||
if (!isLocaleLoading) {
|
||||
expect(await screen.findByTestId("dashboard-grid")).toBeInTheDocument();
|
||||
if (dashcards.length === 0) {
|
||||
// For empty dashboards, wait for the empty state instead of the grid
|
||||
expect(
|
||||
await screen.findByTestId("dashboard-empty-state"),
|
||||
).toBeInTheDocument();
|
||||
} else {
|
||||
expect(await screen.findByTestId("dashboard-grid")).toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -138,7 +138,8 @@ export const DocumentPage = ({
|
||||
const hasComments =
|
||||
!!commentsData?.comments && commentsData.comments.length > 0;
|
||||
|
||||
const canWrite = isNewDocument || documentData?.can_write;
|
||||
const canWrite =
|
||||
!documentData?.archived && (isNewDocument || documentData?.can_write);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -198,7 +199,10 @@ export const DocumentPage = ({
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
const currentTitle = documentTitle.trim();
|
||||
const originalTitle = documentData?.name || "";
|
||||
const titleChanged = currentTitle !== originalTitle;
|
||||
// We call .trim() on documentTitle to ensure that no one can push the save button
|
||||
// with a document name that is all whitespace, the API will reject it. However,
|
||||
// when comparing saved with current titles, we need to use unmofidied values
|
||||
const titleChanged = documentTitle !== originalTitle;
|
||||
|
||||
// Check if there are any draft cards
|
||||
const hasDraftCards = Object.keys(draftCards).length > 0;
|
||||
@@ -455,7 +459,6 @@ export const DocumentPage = ({
|
||||
<CollectionPickerModal
|
||||
title={t`Where should we save this document?`}
|
||||
onClose={() => setCollectionPickerMode(null)}
|
||||
value={{ id: "root", model: "collection" }}
|
||||
options={{
|
||||
showPersonalCollections: true,
|
||||
showRootCollection: true,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Route } from "react-router";
|
||||
|
||||
import {
|
||||
setupBookmarksEndpoints,
|
||||
setupCommentEndpoints,
|
||||
setupDocumentEndpoints,
|
||||
} from "__support__/server-mocks";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import { createMockDocument } from "metabase-types/api/mocks";
|
||||
|
||||
import { DocumentPage } from "./DocumentPage";
|
||||
|
||||
const setup = () => {
|
||||
setupBookmarksEndpoints([]);
|
||||
setupDocumentEndpoints(
|
||||
createMockDocument({
|
||||
name: "Ends with whitepsace ",
|
||||
id: 1,
|
||||
can_write: true,
|
||||
}),
|
||||
);
|
||||
setupCommentEndpoints([], { target_type: "document", target_id: 1 });
|
||||
|
||||
renderWithProviders(
|
||||
<>
|
||||
<Route path="/document/:entityId" component={DocumentPage}></Route>
|
||||
</>,
|
||||
{
|
||||
withRouter: true,
|
||||
initialRoute: "/document/1",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getDocumentTitle = async () =>
|
||||
await screen.findByRole("textbox", { name: /document title/i });
|
||||
|
||||
describe("Document Page", () => {
|
||||
it("should show a save button when title changes", async () => {
|
||||
setup();
|
||||
await waitFor(async () =>
|
||||
expect(await getDocumentTitle()).toHaveValue("Ends with whitepsace "),
|
||||
);
|
||||
|
||||
await userEvent.clear(await getDocumentTitle());
|
||||
await userEvent.type(await getDocumentTitle(), "New Title");
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Change the title back to the old value, the save button should disappear.
|
||||
// I don't love how this test works, but asserting that something doesn't exist
|
||||
// is a very easy way to get false positives in your test. Asserting that something
|
||||
// doesn't exist *anymore* should be more robust
|
||||
await userEvent.clear(await getDocumentTitle());
|
||||
await userEvent.type(await getDocumentTitle(), "Ends with whitepsace ");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,15 @@ import { t } from "ttag";
|
||||
import { ColorPillPicker } from "metabase/common/components/ColorPicker";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import type { MetabaseColors } from "metabase/embedding-sdk/theme";
|
||||
import { colors, staticVizOverrides } from "metabase/lib/colors";
|
||||
import { originalColors, staticVizOverrides } from "metabase/lib/colors";
|
||||
import { ActionIcon, Group, Icon, Stack, Text, Tooltip } from "metabase/ui";
|
||||
|
||||
import { getConfigurableThemeColors } from "../utils/theme-colors";
|
||||
|
||||
const defaultMetabaseColorsWithoutAlpha = { ...colors, ...staticVizOverrides };
|
||||
const defaultMetabaseColorsWithoutAlpha = {
|
||||
...originalColors,
|
||||
...staticVizOverrides,
|
||||
};
|
||||
|
||||
interface ColorCustomizationSectionProps {
|
||||
theme?: { colors?: Partial<MetabaseColors> };
|
||||
|
||||
@@ -97,7 +97,7 @@ export function MetabotAdminPage() {
|
||||
<MetabotCollectionConfigurationPane metabot={metabot} />
|
||||
)}
|
||||
|
||||
<MetabotPromptSuggestionPane metabotId={metabotId} />
|
||||
<MetabotPromptSuggestionPane metabot={metabot} />
|
||||
</SettingsSection>
|
||||
</ErrorBoundary>
|
||||
</AdminSettingsLayout>
|
||||
|
||||
@@ -26,15 +26,15 @@ import {
|
||||
} from "metabase-enterprise/api";
|
||||
import { FIXED_METABOT_IDS } from "metabase-enterprise/metabot/constants";
|
||||
import * as Urls from "metabase-enterprise/urls";
|
||||
import type { MetabotId, SuggestedMetabotPrompt } from "metabase-types/api";
|
||||
import type { MetabotInfo, SuggestedMetabotPrompt } from "metabase-types/api";
|
||||
|
||||
export const PAGE_SIZE = 10;
|
||||
|
||||
export const MetabotPromptSuggestionPane = ({
|
||||
metabotId,
|
||||
metabot,
|
||||
pageSize = PAGE_SIZE,
|
||||
}: {
|
||||
metabotId: MetabotId;
|
||||
metabot: Pick<MetabotInfo, "id" | "collection_id">;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
const [sendToast] = useToast();
|
||||
@@ -43,7 +43,7 @@ export const MetabotPromptSuggestionPane = ({
|
||||
const offset = page * pageSize;
|
||||
|
||||
const { data, isLoading, error } = useGetSuggestedMetabotPromptsQuery({
|
||||
metabot_id: metabotId,
|
||||
metabot_id: metabot.id,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
@@ -53,7 +53,7 @@ export const MetabotPromptSuggestionPane = ({
|
||||
|
||||
const handleDeletePrompt = async (promptId: SuggestedMetabotPrompt["id"]) => {
|
||||
const { error } = await deletePrompt({
|
||||
metabot_id: metabotId,
|
||||
metabot_id: metabot.id,
|
||||
prompt_id: promptId,
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ export const MetabotPromptSuggestionPane = ({
|
||||
};
|
||||
|
||||
const handleRegeneratePrompts = async () => {
|
||||
const { error } = await regeneratePrompts(metabotId);
|
||||
const { error } = await regeneratePrompts(metabot.id);
|
||||
if (error) {
|
||||
sendToast({
|
||||
message: t`Error regenerate prompts`,
|
||||
@@ -101,7 +101,11 @@ export const MetabotPromptSuggestionPane = ({
|
||||
<SettingHeader
|
||||
id="prompt-suggestions"
|
||||
title={t`Prompt suggestions`}
|
||||
description={t`When users open a new Metabot chat, we’ll randomly show them a few suggested prompts based on the models and metrics in the collection you chose.`}
|
||||
description={
|
||||
metabot.collection_id
|
||||
? t`When users open a new Metabot chat, we’ll show them a few suggested prompts based on popular models and metrics in the collection you chose.`
|
||||
: t`When users open a new Metabot chat, we’ll show them a few suggested prompts based on popular models and metrics in your instance.`
|
||||
}
|
||||
/>
|
||||
<Flex gap="md" align="center">
|
||||
<Button
|
||||
@@ -140,7 +144,7 @@ export const MetabotPromptSuggestionPane = ({
|
||||
key={row.id}
|
||||
row={row as SuggestedMetabotPrompt}
|
||||
onDelete={() => handleDeletePrompt(row.id)}
|
||||
metabotId={metabotId}
|
||||
metabotId={metabot.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@ const setup = async (opts?: SetupOpts) => {
|
||||
: paginationContext;
|
||||
|
||||
const TestComponent = () => (
|
||||
<MetabotPromptSuggestionPane metabotId={metabotId} pageSize={pageSize} />
|
||||
<MetabotPromptSuggestionPane
|
||||
metabot={{ id: metabotId, collection_id: null }}
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
);
|
||||
|
||||
renderWithProviders(<Route path="/" component={TestComponent} />, {
|
||||
|
||||
@@ -884,7 +884,6 @@ export const CardEmbedComponent = memo(
|
||||
onSave={(result) => {
|
||||
updateAttributes({
|
||||
id: result.card_id,
|
||||
name: null,
|
||||
});
|
||||
setIsModifyModalOpen(false);
|
||||
}}
|
||||
@@ -897,7 +896,6 @@ export const CardEmbedComponent = memo(
|
||||
onSave={(result) => {
|
||||
updateAttributes({
|
||||
id: result.card_id,
|
||||
name: null,
|
||||
});
|
||||
setIsModifyModalOpen(false);
|
||||
}}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface ModifyQuestionModalProps {
|
||||
card: Card;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (result: { card_id: number; name: string }) => void;
|
||||
onSave: (result: { card_id: number }) => void;
|
||||
}
|
||||
|
||||
export const ModifyQuestionModal = ({
|
||||
@@ -110,7 +110,7 @@ export const ModifyQuestionModal = ({
|
||||
}),
|
||||
);
|
||||
|
||||
onSave({ card_id: newCardId, name: card.name });
|
||||
onSave({ card_id: newCardId });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save modified question:", error);
|
||||
|
||||
@@ -33,7 +33,7 @@ interface NativeQueryModalProps {
|
||||
card: Card;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (result: { card_id: number; name: string }) => void;
|
||||
onSave: (result: { card_id: number }) => void;
|
||||
initialDataset?: Dataset;
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ export const NativeQueryModal = ({
|
||||
}),
|
||||
);
|
||||
|
||||
onSave({ card_id: newCardId, name: card.name });
|
||||
onSave({ card_id: newCardId });
|
||||
onClose();
|
||||
}, [modifiedQuestion, card, dispatch, onSave, onClose]);
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export const findNodeParentAndPos = (
|
||||
}
|
||||
});
|
||||
|
||||
if (parentPos && parent) {
|
||||
if (parentPos !== null && parent !== null) {
|
||||
return { parentPos, parent };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Color from "color";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
@@ -113,7 +114,7 @@ const BrandColorRow = memo(function BrandColorRow({
|
||||
<TableBodyCell>
|
||||
<ColorPicker
|
||||
value={color ?? originalColor}
|
||||
placeholder={originalColor}
|
||||
placeholder={Color(originalColor).hex()}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</TableBodyCell>
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("ColorSettings", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(color("summarize"));
|
||||
const input = screen.getByPlaceholderText(Color(color("summarize")).hex());
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, color("error"));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { SetByEnvVar } from "metabase/admin/settings/components/widgets/AdminSettingInput";
|
||||
import { useAdminSetting } from "metabase/api/utils";
|
||||
import { originalColors } from "metabase/lib/colors/palette";
|
||||
import { useMantineTheme } from "metabase/ui";
|
||||
import type { ColorSettings as ColorSettingsType } from "metabase-types/api";
|
||||
|
||||
import { ColorSettings } from "../ColorSettings";
|
||||
@@ -13,12 +14,14 @@ export const ColorSettingsWidget = () => {
|
||||
updateSetting,
|
||||
settingDetails,
|
||||
} = useAdminSetting("application-colors");
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const handleChange = (newValue: ColorSettingsType) => {
|
||||
updateSetting({
|
||||
const handleChange = async (newValue: ColorSettingsType) => {
|
||||
await updateSetting({
|
||||
key: "application-colors",
|
||||
value: newValue,
|
||||
});
|
||||
theme.other?.updateColorSettings?.(newValue);
|
||||
};
|
||||
|
||||
const onChangeDebounced = useDebouncedCallback(handleChange, 400);
|
||||
|
||||
@@ -131,8 +131,17 @@ export const isPK = (field) => field && isTypePK(field.semantic_type);
|
||||
export const isEntityName = (field) =>
|
||||
field && isa(field.semantic_type, TYPE.Name);
|
||||
export const isTitle = (field) => field && isa(field.semantic_type, TYPE.Title);
|
||||
export const isProduct = (field) =>
|
||||
field && isa(field.semantic_type, TYPE.Product);
|
||||
export const isSource = (field) =>
|
||||
field && isa(field.semantic_type, TYPE.Source);
|
||||
export const isAddress = (field) =>
|
||||
field && isa(field.semantic_type, TYPE.Address);
|
||||
export const isScore = (field) => field && isa(field.semantic_type, TYPE.Score);
|
||||
export const isQuantity = (field) =>
|
||||
field && isa(field.semantic_type, TYPE.Quantity);
|
||||
export const isCategory = (field) =>
|
||||
field && isa(field.semantic_type, TYPE.Category);
|
||||
|
||||
export const isAny = (col) => true;
|
||||
|
||||
@@ -165,6 +174,7 @@ export const isNumber = (field) =>
|
||||
(field.semantic_type == null ||
|
||||
isa(field.semantic_type, TYPE.Number) ||
|
||||
isa(field.semantic_type, TYPE.Category));
|
||||
export const isFloat = (field) => field && isa(field.semantic_type, TYPE.Float);
|
||||
|
||||
export const isTime = (field) => {
|
||||
if (!field) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EmbeddingParameters } from "metabase/public/lib/types";
|
||||
import type { IconName } from "metabase/ui";
|
||||
import type { PieRow } from "metabase/visualizations/echarts/pie/model/types";
|
||||
|
||||
import type { Collection, CollectionId, LastEditInfo } from "./collection";
|
||||
@@ -330,8 +331,11 @@ export type VisualizationSettings = {
|
||||
"sankey.label_value_formatting"?: "auto" | "full" | "compact";
|
||||
|
||||
// List view settings
|
||||
"list.columns"?: ListViewColumns;
|
||||
"list.entity_icon"?: string;
|
||||
"list.columns"?: ListViewColumns; // set of columns selected for custom list view
|
||||
"list.entity_icon_enabled"?: boolean; // display/hide first list item column rendering image/icon
|
||||
"list.use_image_column"?: boolean; // render image from image/avatar url column instead of icon
|
||||
"list.entity_icon"?: IconName | null;
|
||||
"list.entity_icon_color"?: string;
|
||||
|
||||
[key: string]: any;
|
||||
} & EmbedVisualizationSettings;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// IMPORTANT: The field selections in the *DependencyNodeData types below
|
||||
// MUST be kept in sync with the backend field selections in:
|
||||
// enterprise/backend/src/metabase_enterprise/dependencies/api.clj
|
||||
// (See entity-select-fields map)
|
||||
|
||||
import type { Card, CardType } from "./card";
|
||||
import type { Dashboard } from "./dashboard";
|
||||
import type { Document } from "./document";
|
||||
|
||||
@@ -188,7 +188,7 @@ const getCollectionPermission = (
|
||||
permissions: CollectionPermissions,
|
||||
groupId: number,
|
||||
collectionId: CollectionId,
|
||||
) => getIn(permissions, [groupId, collectionId]);
|
||||
) => getIn(permissions, [groupId, collectionId]) || "none";
|
||||
|
||||
const getNamespace = (_state: State, props: CollectionIdProps) =>
|
||||
props.namespace;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { ActionIcon, type ActionIconProps, Icon, Tooltip } from "metabase/ui";
|
||||
import { useColorScheme } from "metabase/ui/components/theme/ColorSchemeProvider";
|
||||
import type { ResolvedColorScheme } from "metabase/ui/components/theme/ColorSchemeProvider/ColorSchemeProvider";
|
||||
|
||||
const iconMap: Record<ResolvedColorScheme, "sun" | "moon"> = {
|
||||
light: "sun",
|
||||
dark: "moon",
|
||||
};
|
||||
|
||||
const getTooltipText = (
|
||||
scheme: ResolvedColorScheme,
|
||||
systemScheme: ResolvedColorScheme,
|
||||
): string => {
|
||||
const baseText =
|
||||
scheme === "dark" ? t`Switch to light mode` : t`Switch to dark mode`;
|
||||
|
||||
return systemScheme !== scheme
|
||||
? `${baseText} ${t`(system preference)`}`
|
||||
: baseText;
|
||||
};
|
||||
|
||||
export function ColorSchemeToggle(props: ActionIconProps & { id?: string }) {
|
||||
const { resolvedColorScheme, systemColorScheme, toggleColorScheme } =
|
||||
useColorScheme();
|
||||
|
||||
const tooltipText = getTooltipText(resolvedColorScheme, systemColorScheme);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltipText}>
|
||||
<ActionIcon
|
||||
onClick={toggleColorScheme}
|
||||
aria-label={tooltipText}
|
||||
{...props}
|
||||
>
|
||||
<Icon name={iconMap[resolvedColorScheme]} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./ColorSchemeSelect";
|
||||
export * from "./ColorSchemeToggle";
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface CollectionPickerModalProps {
|
||||
onChange: (item: CollectionPickerValueItem) => void;
|
||||
onClose: () => void;
|
||||
options?: CollectionPickerOptions;
|
||||
value: Pick<CollectionPickerValueItem, "id" | "model" | "collection_id">;
|
||||
value?: Pick<CollectionPickerValueItem, "id" | "model" | "collection_id">;
|
||||
shouldDisableItem?: (item: CollectionPickerItem) => boolean;
|
||||
searchResultFilter?: (searchResults: SearchResult[]) => SearchResult[];
|
||||
recentFilter?: (recentItems: RecentItem[]) => RecentItem[];
|
||||
@@ -38,7 +38,7 @@ export interface CollectionPickerModalProps {
|
||||
}
|
||||
|
||||
const baseCanSelectItem = (
|
||||
item: Pick<CollectionPickerItem, "can_write" | "model"> | null,
|
||||
item: Pick<CollectionPickerItem, "can_write" | "model"> | null | undefined,
|
||||
): item is CollectionPickerValueItem => {
|
||||
return (
|
||||
!!item &&
|
||||
@@ -62,17 +62,22 @@ export const CollectionPickerModal = ({
|
||||
shouldDisableItem,
|
||||
searchResultFilter,
|
||||
recentFilter,
|
||||
models = ["collection"],
|
||||
models: modelsProp,
|
||||
canSelectItem: _canSelectItem,
|
||||
}: CollectionPickerModalProps) => {
|
||||
options = { ...defaultOptions, ...options };
|
||||
|
||||
const models = modelsProp || ["collection"];
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<CollectionPickerItem | null>(
|
||||
null,
|
||||
);
|
||||
const canSelectItem = useCallback(
|
||||
(
|
||||
item: Pick<CollectionPickerItem, "id" | "can_write" | "model"> | null,
|
||||
item:
|
||||
| Pick<CollectionPickerItem, "id" | "can_write" | "model">
|
||||
| null
|
||||
| undefined,
|
||||
): item is CollectionPickerValueItem => {
|
||||
return baseCanSelectItem(item) && (_canSelectItem?.(item) ?? true);
|
||||
},
|
||||
@@ -166,8 +171,8 @@ export const CollectionPickerModal = ({
|
||||
displayName: models.some((model) => model !== "collection")
|
||||
? t`Browse`
|
||||
: t`Collections`,
|
||||
models,
|
||||
folderModels: ["collection" as const],
|
||||
models: models,
|
||||
folderModels: ["collection"],
|
||||
icon: "folder",
|
||||
render: ({ onItemSelect }) => (
|
||||
<CollectionPicker
|
||||
|
||||
@@ -3,31 +3,33 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
align-items: center;
|
||||
border: 1px dashed var(--mb-color-border);
|
||||
background-color: var(--mb-color-bg-light);
|
||||
border: 1px dashed var(--mb-color-saturated-blue) !important;
|
||||
background-color: var(--mb-color-background-hover-light);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
transition: box-shadow 250ms ease;
|
||||
min-height: 2.5rem;
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.max {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dragOver {
|
||||
box-shadow: 0 0 2px 1px var(--mb-color-border);
|
||||
.dragOver:not(.max) {
|
||||
box-shadow: 0 0 2px 1px var(--mb-color-saturated-blue);
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 0.5rem;
|
||||
height: 100%;
|
||||
font-weight: 400;
|
||||
background: var(--mb-color-bg-white);
|
||||
border: 1px solid var(--mb-color-border);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
cursor: pointer;
|
||||
color: var(--mb-color-text-primary);
|
||||
flex: 0 0 auto;
|
||||
font-weight: bold;
|
||||
min-width: 4rem;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
@@ -44,4 +46,5 @@
|
||||
|
||||
.inputField {
|
||||
min-width: 1rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { t } from "ttag";
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
type MantineSize,
|
||||
Pill,
|
||||
PillsInput,
|
||||
ScrollArea,
|
||||
@@ -37,7 +38,7 @@ interface Props {
|
||||
onChange: (next: string[]) => void;
|
||||
maxValues?: number;
|
||||
placeholder?: string;
|
||||
size?: string;
|
||||
size?: MantineSize;
|
||||
miw?: string | number;
|
||||
maw?: string | number;
|
||||
containerId?: string;
|
||||
@@ -45,6 +46,7 @@ interface Props {
|
||||
draggedItemId?: string | null;
|
||||
currentDroppable?: string | null;
|
||||
"data-testid"?: string;
|
||||
styles?: { input?: CSSProperties };
|
||||
}
|
||||
|
||||
export function SortablePill({
|
||||
@@ -53,12 +55,14 @@ export function SortablePill({
|
||||
onRemove,
|
||||
containerId,
|
||||
style,
|
||||
size,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
onRemove?: () => void;
|
||||
containerId?: string;
|
||||
style?: React.CSSProperties;
|
||||
style?: CSSProperties;
|
||||
size?: MantineSize;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -84,6 +88,7 @@ export function SortablePill({
|
||||
|
||||
return (
|
||||
<Pill
|
||||
size={size}
|
||||
ref={setNodeRef}
|
||||
className={cx(S.pill, { [S.dragging]: isDragging })}
|
||||
withRemoveButton
|
||||
@@ -93,6 +98,15 @@ export function SortablePill({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={combinedStyle}
|
||||
styles={{
|
||||
label: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
remove: {
|
||||
marginRight: 0,
|
||||
},
|
||||
}}
|
||||
radius="xl"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
@@ -116,6 +130,7 @@ export function ReorderableTagsInput({
|
||||
draggedItemId,
|
||||
currentDroppable,
|
||||
"data-testid": dataTestId,
|
||||
styles,
|
||||
}: Props) {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
@@ -192,6 +207,7 @@ export function ReorderableTagsInput({
|
||||
[S.dragOver]: isDragOver || currentDroppable === containerId,
|
||||
}),
|
||||
}}
|
||||
styles={styles}
|
||||
onMouseDownCapture={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Do not open when interacting with a pill (likely starting a drag or clicking remove)
|
||||
@@ -207,6 +223,7 @@ export function ReorderableTagsInput({
|
||||
onDragOver={(e) => {
|
||||
// Allow dropping external items
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
@@ -228,6 +245,7 @@ export function ReorderableTagsInput({
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const val = e.dataTransfer.getData("text/plain");
|
||||
if (!val) {
|
||||
return;
|
||||
@@ -252,6 +270,7 @@ export function ReorderableTagsInput({
|
||||
|
||||
return (
|
||||
<SortablePill
|
||||
size={size}
|
||||
key={v}
|
||||
id={v}
|
||||
label={data.find((o) => o.value === v)?.label ?? v}
|
||||
@@ -290,6 +309,7 @@ export function ReorderableTagsInput({
|
||||
removeAt(value.length - 1);
|
||||
}
|
||||
}}
|
||||
style={styles?.input}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderWithProviders } from "__support__/ui";
|
||||
import type { MantineSize } from "metabase/ui";
|
||||
|
||||
import { type Option, ReorderableTagsInput } from "./ReorderableTagsInput";
|
||||
|
||||
@@ -18,7 +19,7 @@ interface SetupOpts {
|
||||
onChange?: (value: string[]) => void;
|
||||
maxValues?: number;
|
||||
placeholder?: string;
|
||||
size?: string;
|
||||
size?: MantineSize;
|
||||
containerId?: string;
|
||||
useExternalDnd?: boolean;
|
||||
draggedItemId?: string | null;
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
.DashboardHeaderContainer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--mb-color-border);
|
||||
transition: var(--transition-theme-change);
|
||||
|
||||
&:not(.noBorder) {
|
||||
border-bottom: 1px solid var(--mb-color-border);
|
||||
}
|
||||
|
||||
&:not(.isEmbeddingSdk) {
|
||||
background-color: var(--mb-color-background);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { DashboardArchivedEntityBanner } from "metabase/archive/components/Archi
|
||||
import DashboardS from "metabase/css/dashboard.module.css";
|
||||
import { DashboardHeader } from "metabase/dashboard/components/DashboardHeader";
|
||||
import { useDashboardContext } from "metabase/dashboard/context";
|
||||
import { getIsHeaderVisible } from "metabase/dashboard/selectors";
|
||||
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { FilterApplyToast } from "metabase/parameters/components/FilterApplyToast";
|
||||
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
|
||||
import { FullWidthContainer } from "metabase/styled-components/layout/FullWidthContainer";
|
||||
@@ -18,7 +20,6 @@ import {
|
||||
DashboardInfoButton,
|
||||
ExportAsPdfButton,
|
||||
FullscreenToggle,
|
||||
NightModeToggleButton,
|
||||
} from "../DashboardHeader/buttons";
|
||||
import { DashboardParameterPanel } from "../DashboardParameterPanel";
|
||||
import { DashboardSidebars } from "../DashboardSidebars";
|
||||
@@ -33,6 +34,8 @@ const DashboardDefaultView = ({ className }: { className?: string }) => {
|
||||
const { dashboard, isEditing, isFullscreen, isSharing, selectedTabId } =
|
||||
useDashboardContext();
|
||||
|
||||
const isHeaderVisible = useSelector(getIsHeaderVisible);
|
||||
|
||||
const currentTabDashcards = useMemo(() => {
|
||||
if (!dashboard || !Array.isArray(dashboard.dashcards)) {
|
||||
return [];
|
||||
@@ -58,6 +61,7 @@ const DashboardDefaultView = ({ className }: { className?: string }) => {
|
||||
}
|
||||
|
||||
const isEmpty = !dashboardHasCards || (dashboardHasCards && !tabHasCards);
|
||||
const hasTabs = dashboard.tabs && dashboard.tabs.length > 1;
|
||||
|
||||
// Embedding SDK has parent containers that requires dashboard to be full height to avoid double scrollbars.
|
||||
const isFullHeight = isEditing || isSharing || isEmbeddingSdk();
|
||||
@@ -89,6 +93,7 @@ const DashboardDefaultView = ({ className }: { className?: string }) => {
|
||||
{
|
||||
[S.isEmbeddingSdk]: isEmbeddingSdk(),
|
||||
[S.isFullscreen]: isFullscreen,
|
||||
[S.noBorder]: !hasTabs && !isHeaderVisible,
|
||||
},
|
||||
)}
|
||||
data-element-id="dashboard-header-container"
|
||||
@@ -141,7 +146,6 @@ type DashboardComponentType = typeof DashboardDefaultView & {
|
||||
FullscreenButton: typeof FullscreenToggle;
|
||||
ExportAsPdfButton: typeof ExportAsPdfButton;
|
||||
InfoButton: typeof DashboardInfoButton;
|
||||
NightModeButton: typeof NightModeToggleButton;
|
||||
RefreshPeriod: typeof RefreshWidget;
|
||||
};
|
||||
|
||||
@@ -154,7 +158,6 @@ DashboardComponent.ParametersList = ParametersList;
|
||||
DashboardComponent.FullscreenButton = FullscreenToggle;
|
||||
DashboardComponent.ExportAsPdfButton = ExportAsPdfButton;
|
||||
DashboardComponent.InfoButton = DashboardInfoButton;
|
||||
DashboardComponent.NightModeButton = NightModeToggleButton;
|
||||
DashboardComponent.RefreshPeriod = RefreshWidget;
|
||||
|
||||
export const Dashboard = DashboardComponent;
|
||||
|
||||
@@ -30,9 +30,9 @@ export const Grid = ({
|
||||
onRefreshPeriodChange,
|
||||
isLoadingWithoutCards,
|
||||
onAddQuestion,
|
||||
isEditableDashboard,
|
||||
} = useDashboardContext();
|
||||
|
||||
const canWrite = Boolean(dashboard?.can_write);
|
||||
const currentTabDashcards = useMemo(() => {
|
||||
if (!dashboard || !Array.isArray(dashboard.dashcards)) {
|
||||
return [];
|
||||
@@ -92,7 +92,7 @@ export const Grid = ({
|
||||
|
||||
if (isEmpty) {
|
||||
if (!dashboardHasCards) {
|
||||
return canWrite ? (
|
||||
return isEditableDashboard ? (
|
||||
<DashboardEmptyState
|
||||
canCreateQuestions={canCreateQuestions}
|
||||
addQuestion={handleAddQuestion}
|
||||
@@ -105,7 +105,7 @@ export const Grid = ({
|
||||
}
|
||||
|
||||
if (dashboardHasCards && !tabHasCards) {
|
||||
return canWrite ? (
|
||||
return isEditableDashboard ? (
|
||||
<DashboardEmptyState
|
||||
canCreateQuestions={canCreateQuestions}
|
||||
addQuestion={handleAddQuestion}
|
||||
|
||||
@@ -75,10 +75,6 @@ const DASHBOARD_EXPECTED_DATA_MAP: Record<
|
||||
icon: "clock",
|
||||
tooltip: "Auto-refresh",
|
||||
},
|
||||
[DASHBOARD_ACTION.NIGHT_MODE_TOGGLE]: {
|
||||
icon: "sun",
|
||||
tooltip: "Switch to dark mode",
|
||||
},
|
||||
[DASHBOARD_ACTION.FULLSCREEN_TOGGLE]: {
|
||||
icon: "expand",
|
||||
tooltip: "Enter fullscreen",
|
||||
@@ -338,17 +334,6 @@ describe("DashboardHeaderButtonRow", () => {
|
||||
expectedButtons: [DASHBOARD_ACTION.FULLSCREEN_TOGGLE],
|
||||
});
|
||||
});
|
||||
|
||||
it("should show night mode toggle when in fullscreen", async () => {
|
||||
setup({
|
||||
isEditing: false,
|
||||
isFullscreen: true,
|
||||
});
|
||||
|
||||
await expectButtonsToExistInHeader({
|
||||
expectedButtons: [DASHBOARD_ACTION.NIGHT_MODE_TOGGLE],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when viewing analytics dashboard", () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ExportAsPdfButton,
|
||||
FullscreenAnalyticsDashboard,
|
||||
FullscreenToggle,
|
||||
NightModeToggleButton,
|
||||
} from "../buttons";
|
||||
import { AddLinkOrEmbedButton } from "../buttons/AddLinkOrEmbedButton";
|
||||
|
||||
@@ -73,11 +72,6 @@ export const dashboardActionButtons: Record<
|
||||
component: () => <RefreshWidget />,
|
||||
enabled: ({ dashboard, isEditing }) => !isEditing && !dashboard?.archived,
|
||||
},
|
||||
[DASHBOARD_ACTION.NIGHT_MODE_TOGGLE]: {
|
||||
component: () => <NightModeToggleButton />,
|
||||
enabled: ({ isEditing, isFullscreen, dashboard }) =>
|
||||
Boolean(!isEditing && isFullscreen && !dashboard.archived),
|
||||
},
|
||||
[DASHBOARD_ACTION.FULLSCREEN_TOGGLE]: {
|
||||
component: FullscreenToggle,
|
||||
enabled: ({ isFullscreen, isPublic }) => isPublic || isFullscreen,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { DASHBOARD_ACTION } from "./dashboard-action-keys";
|
||||
// Buttons visible in public dashboard
|
||||
export const DASHBOARD_DISPLAY_ACTIONS = [
|
||||
DASHBOARD_ACTION.REFRESH_WIDGET,
|
||||
DASHBOARD_ACTION.NIGHT_MODE_TOGGLE,
|
||||
DASHBOARD_ACTION.FULLSCREEN_TOGGLE,
|
||||
];
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ export const DASHBOARD_ACTION = {
|
||||
EDIT_DASHBOARD: "EDIT_DASHBOARD",
|
||||
DASHBOARD_SHARING: "DASHBOARD_SHARING",
|
||||
REFRESH_WIDGET: "REFRESH_WIDGET",
|
||||
NIGHT_MODE_TOGGLE: "NIGHT_MODE_TOGGLE",
|
||||
FULLSCREEN_TOGGLE: "FULLSCREEN_TOGGLE",
|
||||
DASHBOARD_HEADER_ACTION_DIVIDER: "DASHBOARD_HEADER_ACTION_DIVIDER",
|
||||
DASHBOARD_BOOKMARK: "DASHBOARD_BOOKMARK",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ColorSchemeToggle } from "metabase/common/components/ColorScheme";
|
||||
|
||||
export const NightModeToggleButton = () => {
|
||||
return <ColorSchemeToggle variant="viewHeader" />;
|
||||
};
|
||||
@@ -11,5 +11,4 @@ export * from "./EditDashboardButton";
|
||||
export * from "./FullscreenAnalyticsDashboard";
|
||||
export * from "./SaveEditButton";
|
||||
export * from "./FullscreenToggle";
|
||||
export * from "./NightModeToggleButton";
|
||||
export * from "./ExportAsPdfButton";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useDashboardTitle } from "metabase/dashboard/hooks/use-dashboard-title"
|
||||
|
||||
export const DashboardTitle = ({ className }: { className?: string }) => {
|
||||
const [title, setTitle] = useDashboardTitle();
|
||||
const { dashboard } = useDashboardContext();
|
||||
const { dashboard, isEditableDashboard } = useDashboardContext();
|
||||
|
||||
if (!dashboard) {
|
||||
return null;
|
||||
@@ -19,7 +19,7 @@ export const DashboardTitle = ({ className }: { className?: string }) => {
|
||||
key={title}
|
||||
initialValue={title}
|
||||
placeholder={t`Add title`}
|
||||
isDisabled={!dashboard?.can_write}
|
||||
isDisabled={!isEditableDashboard}
|
||||
data-testid="dashboard-name-heading"
|
||||
onChange={setTitle}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
|
||||