Compare commits
54 Commits
docs-embed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa79587c2 | ||
|
|
2b271c6987 | ||
|
|
37d5c5b042 | ||
|
|
30d6158ea1 | ||
|
|
3d9a6743c6 | ||
|
|
1c0661fa03 | ||
|
|
e27d36ca15 | ||
|
|
d764799f8f | ||
|
|
7d638168dd | ||
|
|
519834f9ee | ||
|
|
cc2db95954 | ||
|
|
ae0dbb421a | ||
|
|
94ef260096 | ||
|
|
c6b4dee91c | ||
|
|
54fe3c4365 | ||
|
|
ed1d3d1004 | ||
|
|
b9b5c25e7a | ||
|
|
d40e005888 | ||
|
|
b1ddf5b6c4 | ||
|
|
fd95839a47 | ||
|
|
190a31be82 | ||
|
|
f062ce0832 | ||
|
|
01bd5d9ea5 | ||
|
|
012e199084 | ||
|
|
a6ed8f0fef | ||
|
|
e53a1611b4 | ||
|
|
2dd2330f97 | ||
|
|
5927b9bce6 | ||
|
|
355147d6a9 | ||
|
|
3e57a280fe | ||
|
|
01d4659a3e | ||
|
|
db875cbd2a | ||
|
|
8561804a1a | ||
|
|
66c6739fdd | ||
|
|
5ddcbeeedf | ||
|
|
762512b71d | ||
|
|
12f8e514d4 | ||
|
|
96e656af6e | ||
|
|
83f20bc0b5 | ||
|
|
2d4ff73f16 | ||
|
|
6623df6c22 | ||
|
|
7907c04a71 | ||
|
|
41661db787 | ||
|
|
0feec26aea | ||
|
|
39ea08c8d8 | ||
|
|
9050b176d6 | ||
|
|
145357fbb1 | ||
|
|
e2369cc1d4 | ||
|
|
d6b2903ccd | ||
|
|
27e4890c7a | ||
|
|
6bf81a1340 | ||
|
|
bff0c3ba61 | ||
|
|
6cb8e9e904 | ||
|
|
515ab1961f |
@@ -1,6 +1,6 @@
|
||||
## Linting and Formatting
|
||||
|
||||
- **Lint:** `bun run lint-oxlint-pure`
|
||||
- **Lint:** `bun run lint-oxlint`
|
||||
- Run oxlint on the codebase
|
||||
- **Format:** `bun run format`
|
||||
- Format code with oxfmt (`oxfmt --write`); use `bun run lint-format-pure` to check without writing
|
||||
|
||||
184
.claude/skills/api-changelog/SKILL.md
Normal file
@@ -0,0 +1,184 @@
|
||||
---
|
||||
name: api-changelog
|
||||
description: Draft or audit the REST API changelog (docs/developers-guide/api-changelog.md) by semantically diffing the checked-in OpenAPI spec between two git refs. Use when asked "has the API changelog been updated", "what are the breaking API changes in vNN", when cutting a release branch, or when reviewing whether a PR needs a changelog entry.
|
||||
---
|
||||
|
||||
# API changelog
|
||||
|
||||
Answers "what changed in the REST API between two versions, and is the changelog
|
||||
honest about it?"
|
||||
|
||||
## Ground truth
|
||||
|
||||
`resources/openapi/openapi.json` is generated from the Malli endpoint schemas, so
|
||||
the spec at any git ref is what the API actually was at that ref. Diff the spec,
|
||||
do not read endpoint source or boot a server.
|
||||
|
||||
Response coverage is partial: about 199 response entries declare a real schema
|
||||
and are compared, but roughly 1,700 are description-only `2XX/4XX/5XX` stubs. For
|
||||
an endpoint with no declared response schema, a response-shape change is
|
||||
invisible here. Say so rather than implying the diff is complete.
|
||||
|
||||
### Check the spec is fresh first - it usually is not
|
||||
|
||||
The self-healing CI job (`.github/workflows/openapi-check.yml`) only runs on PRs
|
||||
labelled `openapi-self-healing`, so the committed spec drifts behind master by
|
||||
default. Verify before trusting a diff:
|
||||
|
||||
```bash
|
||||
./bin/mage openapi-staleness
|
||||
```
|
||||
|
||||
If the source is newer, regenerate before diffing the new ref:
|
||||
|
||||
```bash
|
||||
bun run generate-openapi # rewrites resources/openapi/openapi.json in place
|
||||
```
|
||||
|
||||
This staleness check only matters when you are diffing the committed spec.
|
||||
`--refs` (below) generates each ref's spec from its own source, so it is not
|
||||
subject to this drift at all - prefer it.
|
||||
|
||||
When you do read a committed spec, a change present in source but absent from
|
||||
that spec is a **false negative**: the diff will not report it, and an empty
|
||||
result is indistinguishable from an API that did not change. Confirm suspected
|
||||
gaps with `grep -rn "defendpoint" src/.../api.clj`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Pick the refs. Default old ref = the previous release branch
|
||||
(`origin/release-x.63.x`), new ref = the one being released
|
||||
(`origin/release-x.64.x`) or `origin/master`. Confirm with the user if
|
||||
ambiguous. `git fetch origin` first.
|
||||
|
||||
2. Extract and diff:
|
||||
|
||||
```bash
|
||||
# Preferred: generate both specs from source. ~2 min (two JVM boots), no drift.
|
||||
./bin/mage openapi-diff --refs origin/release-x.63.x origin/release-x.64.x --severity breaking
|
||||
|
||||
# Fast, but reads the committed spec, which lags source. Warns when it does.
|
||||
./bin/mage openapi-diff --refs --committed origin/release-x.63.x origin/release-x.64.x
|
||||
|
||||
# Two spec files directly.
|
||||
./bin/mage openapi-diff /tmp/old.json /tmp/new.json --severity breaking
|
||||
```
|
||||
|
||||
`--refs` checks each ref out into a throwaway worktree and runs that ref's own
|
||||
`generate-openapi-spec`, so it is not limited to refs that carry a committed spec
|
||||
and never touches your checkout. Very old refs may not build with the current
|
||||
toolchain; it falls back to the committed spec and says so when that happens.
|
||||
Prefer it. `--committed` compares whatever blob each ref happens to carry, and
|
||||
when a release branch and master share a stale one it reports far fewer findings
|
||||
than the API actually changed - an artifact of the spec, not of the API.
|
||||
|
||||
### Start from the grouped view
|
||||
|
||||
A single upstream change can produce hundreds of findings. `--grouped` collapses
|
||||
identical findings and sorts by blast radius, which is the shape you draft from:
|
||||
|
||||
```bash
|
||||
./bin/mage openapi-diff --refs <old> <new> --severity breaking --grouped
|
||||
```
|
||||
|
||||
On the real v63 -> master delta that turns 239 breaking findings into 113
|
||||
distinct changes. The top entry spans 217 endpoints and is one PR (#82447,
|
||||
closing `mu/defn` argument schemas) - **one changelog entry, not 217**.
|
||||
|
||||
Drop `--grouped` when you need the per-endpoint view to check a specific route.
|
||||
|
||||
A change spanning many endpoints is usually one upstream PR: write it once, name
|
||||
the cause, and say which endpoints it spans. The long tail of single-endpoint
|
||||
changes is where the individually-interesting entries are - the v63 -> master run
|
||||
surfaced a `pinned_state` -> `pinned-state` query param rename there.
|
||||
|
||||
Findings are classified and sorted breaking-first.
|
||||
|
||||
**A change is breaking when an existing caller, sending exactly what it sent
|
||||
before, can now fail or get a different result.** That covers three cases: the
|
||||
API requires more of the request, provides less in the response, or behaves
|
||||
differently for the same request. The first two invert between request and
|
||||
response:
|
||||
|
||||
| | Breaking | Not breaking |
|
||||
|---|---|---|
|
||||
| **Request** | field or param becomes required; type, enum, or bound (`minimum`, `minLength`, `pattern`, ...) narrowed; `additionalProperties: false` added; default changed or dropped | new *optional* field or param; type, enum, or bound widened; field made nullable; schema opened; default added |
|
||||
| **Response** | field removed or no longer always returned; field may now be `null`; new enum value | new field returned; field that was nullable never is |
|
||||
| **Endpoint** | removed | added |
|
||||
|
||||
When the tool cannot tell, it ranks the change breaking: a false alarm costs
|
||||
you one look, a miss ships undocumented. Two rules follow from that, not from
|
||||
the definition, so check them before drafting an entry:
|
||||
|
||||
- **A removed request field** is ranked breaking because the spec does not say
|
||||
whether the server rejects or acts on keys it does not declare. If the
|
||||
endpoint ignores unknown keys, the removal is not breaking.
|
||||
- **A change to a schema keyword the tool does not model** is ranked breaking
|
||||
so that it is never hidden. Read the finding to decide.
|
||||
|
||||
A reworded description, title, or example is DOC_ONLY.
|
||||
|
||||
Adding an optional parameter is not breaking. Returning extra data is not
|
||||
breaking. Existing callers keep working in both cases.
|
||||
|
||||
Start from `--severity breaking`. The long tail of API change is additive
|
||||
(a public Stripe-spec diff found 663 of 679 changes additive); reading the
|
||||
full diff to find the breaking few is how entries get missed.
|
||||
|
||||
**Identical or near-identical committed specs do not mean the API did not
|
||||
change.** Two refs often carry the same stale blob, so `--committed` can report
|
||||
zero findings for a pair that generating from source shows has removed endpoints.
|
||||
Before reporting "no API surface changes", re-run without `--committed`. Only a
|
||||
clean generated diff supports that conclusion.
|
||||
|
||||
3. Review the classification. The tool decides severity structurally, but two
|
||||
cases still need your judgement:
|
||||
- **A removed + added pair is often one endpoint moving** (method change, or a
|
||||
param moving between query/path/body). The tool reports two findings; read
|
||||
the docstrings to pair them and write ONE changelog entry describing the move.
|
||||
- **An `ADDITIVE` finding can still be worth an entry** when it is a new
|
||||
endpoint clients should know about. Additive means "will not break existing
|
||||
callers", not "not worth mentioning".
|
||||
|
||||
4. For every breaking change, decide whether you can state **what it does for a
|
||||
client** from the spec's `description` alone. If you cannot, do not guess and
|
||||
do not paper over it - list it under "Needs author input" with the endpoint
|
||||
name and what is unclear. Thin endpoint docstrings are the actual finding;
|
||||
report them loudly.
|
||||
|
||||
5. Cross-check `docs/developers-guide/api-changelog.md` for the target version.
|
||||
Report three buckets:
|
||||
- **Undocumented**: breaking changes with no changelog entry -> draft entries
|
||||
- **Stale**: changelog entries with no matching spec change -> verify by hand
|
||||
(may be a response-shape or behavior change the spec cannot see - do not
|
||||
delete without asking)
|
||||
- **Covered**: matched, no action
|
||||
|
||||
6. Draft entries in the existing house style: `## Metabase 0.64.0` heading,
|
||||
one `-` bullet per change, endpoint as `` `POST /api/foo/:id` `` with
|
||||
**`:id` colon-style params, not OpenAPI `{id}` braces**. Say what changed, what
|
||||
clients must do, and whether there was a deprecation period. Apply the
|
||||
`docs-write` skill. Show the diff before writing to the file.
|
||||
|
||||
## Attribution
|
||||
|
||||
Find who to ask about an unclear change. Search the endpoint SOURCE, not `openapi.json`: the committed spec only changes on
|
||||
PRs carrying the `openapi-self-healing` label, which is usually a later, unrelated
|
||||
PR, so its history names the wrong author.
|
||||
|
||||
```bash
|
||||
git log <old-ref>..<new-ref> --oneline -- \
|
||||
'src/metabase/**/api.clj' 'src/metabase/**/api/*.clj' 'src/metabase/**/routes.clj' \
|
||||
'enterprise/backend/src/**/api.clj' 'enterprise/backend/src/**/api/*.clj' 'enterprise/backend/src/**/routes.clj'
|
||||
```
|
||||
|
||||
Attribution by route search is unreliable in both directions - `git log -S` reports
|
||||
where a string's count *changed*, which for a long-lived route is its creation years
|
||||
ago rather than its recent removal. Treat any commit you find as a candidate to
|
||||
verify, never as the established cause.
|
||||
|
||||
## Backports
|
||||
|
||||
A change landing in both `0.64.0` and older lines gets a full entry under the
|
||||
newest version and a one-line pointer under each backport version
|
||||
("See the 0.64.0 entry."). Match the existing `POST /api/slack/bug-report` pattern.
|
||||
@@ -39,7 +39,7 @@ what and who may depend on whom.**
|
||||
| module elements, tiers, `enforceSharedTiers`, `enforcePublicApi` | `frontend/lint/module-boundaries.mjs` |
|
||||
| shared sub-tiers and levels, cluster rules | `frontend/lint/shared-tiers.mjs` |
|
||||
| standalone boundaries lint (all violations, incl. grandfathered) | `bun run module-boundaries` (config `eslint.config.module-boundaries.mjs`) |
|
||||
| PR lint (only enforced modules fail CI) | `bun run lint-oxlint-pure` |
|
||||
| PR lint (only enforced modules fail CI) | `bun run lint-oxlint` |
|
||||
| side-effect-free directories and their exceptions | `frontend/build/shared/rspack/side-effect-free-modules.js` (`SIDE_EFFECT_FREE_PATHS`, `SIDE_EFFECT_PATHS`) |
|
||||
| the lint rules that enforce that promise | `metabase/no-module-side-effects`, `metabase/no-base-api-access` in `frontend/lint/eslint-plugin-metabase/rules/` |
|
||||
| public-api enforcement | `metabase/enforce-module-public-api` rule, driven by `getPublicApiModules()` |
|
||||
@@ -199,7 +199,7 @@ prefix; never generic `-shared` / `-feature` suffixes.
|
||||
```
|
||||
grep -rn "<old path>" frontend enterprise/frontend e2e # must be empty
|
||||
bunx eslint --no-warn-ignored <touched files>
|
||||
bun run lint-oxlint-pure # enforce-module-public-api included
|
||||
bun run lint-oxlint # enforce-module-public-api included
|
||||
bun run type-check-pure
|
||||
bun run test-unit-keep-cljs <touched folders' specs>
|
||||
bun run module-boundaries 2>&1 | tail -1 # after; no violation may name the new files
|
||||
|
||||
@@ -172,7 +172,7 @@ export const OverviewStory = {
|
||||
- **Scope controls per story.** A static matrix should hide knobs it ignores: `parameters.controls = { include: [...] }` (or `{ disable: true }`). Wire any kept knob (e.g. `children`) through to the cells so it does something.
|
||||
- `loki.config.js` has a `storiesFilter`; check it before renaming a story that's already covered.
|
||||
|
||||
Validate: `bun run lint-oxlint-pure -- <files>` and `bun run type-check-pure`.
|
||||
Validate: `bun run lint-oxlint -- <files>` and `bun run type-check-pure`.
|
||||
|
||||
### Optional: verify in the browser yourself
|
||||
|
||||
@@ -217,7 +217,7 @@ General gotchas when extending Mantine components — adapt per component:
|
||||
- **Built-in icons use `currentColor`**, so they inherit the slot's text color.
|
||||
- For text on a brand fill, follow the existing convention (`text-primary-inverse`) unless the user wants pure white; flag the dark-theme implication.
|
||||
|
||||
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-oxlint-pure -- <files>`, `bun run type-check-pure`.
|
||||
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-oxlint -- <files>`, `bun run type-check-pure`.
|
||||
|
||||
## Phase 5 — Iterate with the user
|
||||
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.remote-sync.metric-dimension-api-test/with-synced-metric hooks.common/with-one-binding
|
||||
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
|
||||
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
|
||||
@@ -728,6 +728,7 @@
|
||||
:uses #{api
|
||||
api-routes
|
||||
app-db
|
||||
mcp
|
||||
metabot
|
||||
metabot.llm
|
||||
models
|
||||
@@ -1331,7 +1332,8 @@
|
||||
metabase.mcp.http-handler
|
||||
metabase.mcp.init
|
||||
metabase.mcp.usage
|
||||
metabase.mcp.v2.api}
|
||||
metabase.mcp.v2.api
|
||||
metabase.mcp.v2.registry}
|
||||
:model-exports #{:model/McpSessionLog :model/McpToolCallLog}
|
||||
:uses #{activity-feed
|
||||
ai-tracing
|
||||
@@ -4130,7 +4132,8 @@
|
||||
{:team "UX West"
|
||||
:ns-prefix "metabase-enterprise.sandbox"
|
||||
:api #{metabase-enterprise.sandbox.api.routes metabase-enterprise.sandbox.api.util metabase-enterprise.sandbox.schema}
|
||||
:uses #{api
|
||||
:uses #{analytics
|
||||
api
|
||||
api-scope
|
||||
app-db
|
||||
audit-app
|
||||
@@ -4202,6 +4205,7 @@
|
||||
:model/Collection
|
||||
:model/Dashboard
|
||||
:model/DataApp
|
||||
:model/Dimension
|
||||
:model/Document
|
||||
:model/Field
|
||||
:model/FieldUserSettings
|
||||
|
||||
1
.github/CODEOWNERS
vendored
@@ -43,6 +43,7 @@ docs/configuring-metabase/environment-variables.md # auto-generated
|
||||
docs/configuring-metabase/config-template.md # auto-generated
|
||||
docs/installation-and-operation/commands.md # auto-generated
|
||||
docs/ai/providers.md # auto-generated
|
||||
docs/ai/mcp-tools.md # auto-generated
|
||||
docs/embedding/sdk/api # auto-generated
|
||||
docs/embedding/eajs/snippets # auto-generated
|
||||
|
||||
|
||||
75
.github/actions/create-test-plan/action.yml
vendored
@@ -1,33 +1,17 @@
|
||||
name: Create test plan
|
||||
name: Create frontend test plan
|
||||
description: Compute the affected-tests plan for the changed files.
|
||||
|
||||
inputs:
|
||||
changed-files:
|
||||
description: All changed files (CSV).
|
||||
required: true
|
||||
fe-changed-files:
|
||||
description: Changed frontend_sources files (CSV).
|
||||
required: true
|
||||
be-changed-files:
|
||||
description: Changed backend_sources files (CSV).
|
||||
required: true
|
||||
unit-infra-touched:
|
||||
description: Whether FE unit infra changed.
|
||||
required: true
|
||||
loki-infra-touched:
|
||||
description: Whether Loki infra changed.
|
||||
required: true
|
||||
e2e-infra-touched:
|
||||
description: Whether e2e harness/support code changed.
|
||||
required: true
|
||||
shared-sources-touched:
|
||||
description: Whether cljc/cljs/package.json changed.
|
||||
required: true
|
||||
stats-url:
|
||||
description: Endpoint that stores the affected-tests stats row.
|
||||
required: false
|
||||
default: ''
|
||||
stats-api-key:
|
||||
description: API key for stats-url.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
outputs:
|
||||
stats:
|
||||
description: FE Affected Tests stats row (JSON).
|
||||
value: ${{ steps.compute.outputs.stats }}
|
||||
artifact-id:
|
||||
description: Artifact containing test-plan.json for this run.
|
||||
value: ${{ steps.upload.outputs.artifact-id }}
|
||||
@@ -35,6 +19,14 @@ outputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# The action reads the diff itself, so a caller hands it no file lists.
|
||||
- name: Test which files changed
|
||||
id: changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
list-files: csv
|
||||
filters: .github/file-paths.yaml
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Resolve closest-ancestor e2e coverage manifest
|
||||
@@ -59,13 +51,13 @@ runs:
|
||||
id: compute
|
||||
shell: bash
|
||||
env:
|
||||
CHANGED_FILES: ${{ inputs.changed-files }}
|
||||
FE_CHANGED_FILES: ${{ inputs.fe-changed-files }}
|
||||
BE_CHANGED_FILES: ${{ inputs.be-changed-files }}
|
||||
UNIT_INFRA_TOUCHED: ${{ inputs.unit-infra-touched }}
|
||||
LOKI_INFRA_TOUCHED: ${{ inputs.loki-infra-touched }}
|
||||
E2E_INFRA_TOUCHED: ${{ inputs.e2e-infra-touched }}
|
||||
SHARED_SOURCES_TOUCHED: ${{ inputs.shared-sources-touched }}
|
||||
CHANGED_FILES: ${{ steps.changes.outputs.all_changed_files }}
|
||||
FE_CHANGED_FILES: ${{ steps.changes.outputs.frontend_sources_files }}
|
||||
BE_CHANGED_FILES: ${{ steps.changes.outputs.backend_sources_files }}
|
||||
UNIT_INFRA_TOUCHED: ${{ steps.changes.outputs.frontend_unit_infra }}
|
||||
LOKI_INFRA_TOUCHED: ${{ steps.changes.outputs.frontend_loki_infra }}
|
||||
E2E_INFRA_TOUCHED: ${{ steps.changes.outputs.e2e_infra }}
|
||||
SHARED_SOURCES_TOUCHED: ${{ steps.changes.outputs.shared_sources }}
|
||||
E2E_SPEC_MANIFEST: ${{ steps.manifest.outputs.path }}
|
||||
run: |
|
||||
# stdout is the plan JSON; the planner's own info logs go to stderr.
|
||||
@@ -83,7 +75,7 @@ runs:
|
||||
|
||||
- name: Report test plan
|
||||
shell: bash
|
||||
run: |
|
||||
run: | # sh
|
||||
plan=test-plan.json
|
||||
row() { # name, selected, total
|
||||
if [ "$2" -eq "$3" ]; then echo "| $1 | all ($3) | $3 |"; else echo "| $1 | $2 | $3 |"; fi
|
||||
@@ -122,3 +114,20 @@ runs:
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
|
||||
# Only land rows for PRs from the upstream repo so forks don't pollute the table. Best-effort:
|
||||
# a failed upload must not cost us the plan.
|
||||
- name: Upload affected tests stats
|
||||
if: >-
|
||||
${{ inputs.stats-url != ''
|
||||
&& github.event.pull_request.head.repo.full_name == 'metabase/metabase' }}
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
STATS_JSON: ${{ steps.compute.outputs.stats }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
ENG_STATS_URL: ${{ inputs.stats-url }}
|
||||
API_KEY: ${{ inputs.stats-api-key }}
|
||||
run: bun .github/scripts/upload-affected-tests-stats.ts
|
||||
|
||||
14
.github/actions/test-driver/action.yml
vendored
@@ -150,14 +150,14 @@ runs:
|
||||
shell: bash
|
||||
id: run-test
|
||||
env:
|
||||
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. Inside
|
||||
# a called workflow `github.event` is the caller's payload, so these resolve here rather than
|
||||
# in each calling workflow. On a normal CI run there are no dispatch inputs, so these render
|
||||
# `false` and empty and the job's own selection stands.
|
||||
# PROFILE_TESTS is not set here: the suites that test.profile-backend.yml dispatches set it in
|
||||
# their workflow `env`, which reaches this step. The stress-test workflows are dispatched too
|
||||
# but leave it unset, so they never profile.
|
||||
#
|
||||
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job
|
||||
# runs the whole list.
|
||||
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
|
||||
# ONLY_TESTS comes from test.profile-backend.yml. Inside a called workflow `github.event` is
|
||||
# the caller's payload, so it resolves here rather than in each calling workflow. On any
|
||||
# other run it renders empty and the job's own selection stands. It replaces the job's own
|
||||
# test selection, so every matrix leg of the chosen job runs the whole list.
|
||||
ONLY_TESTS: ${{ github.event.inputs.tests }}
|
||||
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
|
||||
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}
|
||||
|
||||
30
.github/file-paths.yaml
vendored
@@ -1,5 +1,5 @@
|
||||
# Catch-all filter — emits the full changed-files list as `all_changed_files`
|
||||
# so create-test-plan in run-tests.yml can reuse dorny's diff.
|
||||
# for the create-test-plan action.
|
||||
all_changed:
|
||||
- "**"
|
||||
|
||||
@@ -23,6 +23,8 @@ shared_ci: &shared_ci
|
||||
- ".github/actions/prepare-frontend/**"
|
||||
- ".github/actions/prepare-backend/**"
|
||||
- ".github/workflows/run-tests.yml"
|
||||
# Every suite's gate calls it, so a change here can turn any suite off.
|
||||
- ".github/workflows/test.gate.yml"
|
||||
|
||||
shared_e2e: &shared_e2e
|
||||
- "e2e/runner/**"
|
||||
@@ -246,14 +248,14 @@ embedding_sdk_ci: &embedding_sdk_ci
|
||||
- ".github/actions/prepare-cypress/**"
|
||||
- ".github/workflows/embedding-sdk*.yml"
|
||||
|
||||
embedding_sdk_components:
|
||||
embedding_sdk_components: &embedding_sdk_components
|
||||
- *default
|
||||
- *embedding_sdk_ci
|
||||
- *sources
|
||||
- *shared_e2e
|
||||
- "e2e/test-component/**/*.cy.*.{js,jsx,ts,tsx}"
|
||||
|
||||
embedding_sdk_host_sample_apps:
|
||||
embedding_sdk_host_sample_apps: &embedding_sdk_host_sample_apps
|
||||
- *default
|
||||
- *embedding_sdk_ci
|
||||
- *frontend_sources
|
||||
@@ -283,7 +285,7 @@ e2e_infra: &e2e_infra
|
||||
- ".github/scripts/build-e2e-matrix.js"
|
||||
- ".github/file-paths.yaml"
|
||||
|
||||
e2e_all:
|
||||
e2e_all: &e2e_all
|
||||
- *default
|
||||
- *e2e_ci
|
||||
- *sources
|
||||
@@ -299,10 +301,28 @@ documentation:
|
||||
docs:
|
||||
- "docs/**"
|
||||
|
||||
embedding_documentation:
|
||||
embedding_documentation: &embedding_documentation
|
||||
- ".typedoc/**"
|
||||
- "docs/embedding/**"
|
||||
|
||||
# Everything the SDK package build feeds, so the build itself follows the union of its consumers.
|
||||
build_embedding_sdk_package:
|
||||
- *embedding_sdk_ci
|
||||
- *embedding_sdk_components
|
||||
- *embedding_sdk_host_sample_apps
|
||||
- *embedding_documentation
|
||||
- *frontend_sources
|
||||
|
||||
# The uberjar only exists to feed e2e, the SDK suites that boot a Metabase instance, bundle-size and
|
||||
# containerize, so it is worth building exactly when one of them might run. The SDK's type checks run
|
||||
# against the package artifact alone, so embedding docs do not belong here.
|
||||
build_uberjar:
|
||||
- *backend_all
|
||||
- *frontend_all
|
||||
- *e2e_all
|
||||
- *embedding_sdk_components
|
||||
- *embedding_sdk_host_sample_apps
|
||||
|
||||
yaml:
|
||||
- "**/*.yml"
|
||||
- "**/*.yaml"
|
||||
|
||||
50
.github/scripts/file-paths.unit.spec.ts
vendored
@@ -117,6 +117,56 @@ describe("file-paths.yaml", () => {
|
||||
expect(matches("ci_scripts", path)).toBe(true);
|
||||
});
|
||||
|
||||
// Every suite's gate calls the same workflow, so a change to it has to reach every suite.
|
||||
it.each([
|
||||
"backend_all",
|
||||
"frontend_all",
|
||||
"frontend_loki_all",
|
||||
"e2e_all",
|
||||
"embedding_sdk_components",
|
||||
"embedding_sdk_host_sample_apps",
|
||||
"ci_scripts",
|
||||
])("runs %s when the test gate changes", (filter) => {
|
||||
expect(matches(filter, ".github/workflows/test.gate.yml")).toBe(true);
|
||||
});
|
||||
|
||||
// The build filters replace what used to be an inline condition in run-tests.yml, so what they
|
||||
// leave out is the whole point: a diff that changes nothing an artifact ships gets no build.
|
||||
it.each([
|
||||
"src/metabase/core.clj",
|
||||
"frontend/src/metabase/App.tsx",
|
||||
"e2e/test/scenarios/question/foo.cy.spec.js",
|
||||
// The SDK component and host-app suites boot a Metabase instance, so their CI entry points
|
||||
// have to build one.
|
||||
".github/workflows/embedding-sdk.yml",
|
||||
])("builds the uberjar for %s", (path) => {
|
||||
expect(matches("build_uberjar", path)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
".clj-kondo/ratchets.edn",
|
||||
"README.md",
|
||||
"docs/questions/introduction.md",
|
||||
// The SDK type checks read the package artifact, not a running instance.
|
||||
"docs/embedding/sdk/introduction.md",
|
||||
])("skips the uberjar for %s", (path) => {
|
||||
expect(matches("build_uberjar", path)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"enterprise/frontend/src/embedding-sdk-package/index.ts",
|
||||
"docs/embedding/sdk/introduction.md",
|
||||
".github/workflows/embedding-sdk.yml",
|
||||
])("builds the SDK package for %s", (path) => {
|
||||
expect(matches("build_embedding_sdk_package", path)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not build the SDK package for unrelated documentation", () => {
|
||||
expect(
|
||||
matches("build_embedding_sdk_package", "docs/questions/introduction.md"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the ratchet check on the ratchets file", () => {
|
||||
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
|
||||
true,
|
||||
|
||||
27
.github/workflows/app-db.yml
vendored
@@ -2,14 +2,6 @@ name: App DB Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
force-run:
|
||||
description: Run the full suite regardless of the diff (master / release branches).
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-app-db
|
||||
@@ -21,10 +13,19 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Read by the test-driver action. This suite is only ever dispatched by test.profile-backend.yml,
|
||||
# so a dispatch is a profiling run.
|
||||
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: backend_all
|
||||
|
||||
app-db-matrix:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
name: Determine app DB versions
|
||||
@@ -38,7 +39,7 @@ jobs:
|
||||
id: versions
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
RUN_ALL: ${{ inputs.force-run }}
|
||||
RUN_ALL: ${{ needs.gate.outputs.force-run }}
|
||||
with:
|
||||
script: | # js
|
||||
const runAll = process.env.RUN_ALL === 'true';
|
||||
@@ -130,7 +131,6 @@ jobs:
|
||||
|
||||
be-tests-mariadb:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'mariadb') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -191,7 +191,6 @@ jobs:
|
||||
|
||||
be-tests-mysql:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'mysql') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -250,7 +249,6 @@ jobs:
|
||||
|
||||
be-tests-postgres:
|
||||
needs: app-db-matrix
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'postgres') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -292,6 +290,7 @@ jobs:
|
||||
|
||||
app-db-tests-result:
|
||||
needs:
|
||||
- gate
|
||||
- app-db-matrix
|
||||
- be-tests-mariadb
|
||||
- be-tests-mysql
|
||||
@@ -299,7 +298,7 @@ jobs:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
name: app-db-tests-result
|
||||
if: always() && !cancelled()
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Check job status
|
||||
uses: actions/github-script@v9
|
||||
|
||||
123
.github/workflows/backend.yml
vendored
@@ -2,26 +2,6 @@ name: Backend
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
force-run:
|
||||
description: Run the full suite regardless of the diff (master / release branches).
|
||||
type: boolean
|
||||
default: false
|
||||
run-project-backend-checks:
|
||||
description: Run project-level backend checks.
|
||||
type: boolean
|
||||
default: false
|
||||
run-project-migration-checks:
|
||||
description: Run project-level migration checks.
|
||||
type: boolean
|
||||
default: false
|
||||
run-project-ratchet-checks:
|
||||
description: Run the ratchet check for a diff that touches nothing but the ratchets file.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-backend
|
||||
@@ -35,13 +15,47 @@ env:
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: backend_all project_backend_checks project_migration_checks project_ratchet_checks
|
||||
|
||||
# Generated static-viz output can shift without any backend source changing, so the suite follows
|
||||
# it too. Only worth probing when nothing else has already decided the suite.
|
||||
static-viz-files-changed:
|
||||
name: Check whether static-viz files changed
|
||||
needs: gate
|
||||
if: >-
|
||||
${{ needs.gate.outputs.verdict == 'defer'
|
||||
&& !contains(fromJSON(needs.gate.outputs.matched), 'backend_all') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
static_viz: ${{ steps.static_viz.outputs.static_viz }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Build static-viz frontend
|
||||
run: bun run build-static-viz
|
||||
env:
|
||||
MB_EDITION: ee
|
||||
- name: Check for static viz changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: static_viz
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/static-viz-sources.yaml
|
||||
|
||||
project-checks:
|
||||
name: 'Project tests: Suites'
|
||||
needs: gate
|
||||
if: >-
|
||||
${{ inputs.force-run ||
|
||||
inputs.run-project-backend-checks ||
|
||||
inputs.run-project-migration-checks ||
|
||||
inputs.run-project-ratchet-checks }}
|
||||
${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks')
|
||||
|| contains(fromJSON(needs.gate.outputs.matched), 'project_migration_checks')
|
||||
|| contains(fromJSON(needs.gate.outputs.matched), 'project_ratchet_checks') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -53,21 +67,23 @@ jobs:
|
||||
run: ./bin/mage
|
||||
- parallel:
|
||||
- name: Run backend checks
|
||||
if: ${{ inputs.force-run || inputs.run-project-backend-checks }}
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks') }}
|
||||
run: ./bin/mage project-tests backend
|
||||
- name: Run migration checks
|
||||
if: ${{ inputs.force-run || inputs.run-project-migration-checks }}
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'project_migration_checks') }}
|
||||
run: ./bin/mage project-tests migrations
|
||||
# Babashka plus a scoped clj-kondo run, not a JVM test run, so it is cheap enough to run alongside the
|
||||
# backend checks. It needs the JDK and Clojure CLI prepare-backend installs.
|
||||
- name: Run ratchet checks
|
||||
if: ${{ inputs.force-run || inputs.run-project-backend-checks || inputs.run-project-ratchet-checks }}
|
||||
if: >-
|
||||
${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks')
|
||||
|| contains(fromJSON(needs.gate.outputs.matched), 'project_ratchet_checks') }}
|
||||
run: ./bin/mage kondo-ratchets
|
||||
|
||||
project-tests:
|
||||
name: Project tests
|
||||
needs: project-checks
|
||||
if: ${{ always() && !cancelled() }}
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -125,7 +141,14 @@ jobs:
|
||||
process.exit(1);
|
||||
|
||||
be-linter-clj-kondo:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: &backend-gate-needs [gate, static-viz-files-changed]
|
||||
# `!cancelled()` so a skipped static-viz probe -- the suite was already decided -- does not skip
|
||||
# this job; its failure still does.
|
||||
if: &backend-gate >-
|
||||
${{ !cancelled() && needs.gate.result == 'success'
|
||||
&& needs.static-viz-files-changed.result != 'failure'
|
||||
&& (contains(fromJSON(needs.gate.outputs.matched), 'backend_all')
|
||||
|| needs.static-viz-files-changed.outputs.static_viz == 'true') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
name: Clj-Kondo
|
||||
@@ -137,9 +160,10 @@ jobs:
|
||||
run: ./bin/mage kondo
|
||||
|
||||
be-linter-eastwood:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: *backend-gate-needs
|
||||
if: *backend-gate
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
name: Eastwood
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -149,10 +173,14 @@ jobs:
|
||||
name: Run Eastwood linter
|
||||
|
||||
be-linter-eastwood-test:
|
||||
# Master / release safety net: lint every backend test namespace.
|
||||
if: ${{ !inputs.skip && inputs.force-run }}
|
||||
# Master / release safety net: lint every backend test namespace. A force-run never probes
|
||||
# static-viz, so the gate alone decides this one.
|
||||
needs: gate
|
||||
if: >-
|
||||
${{ needs.gate.outputs.force-run == 'true'
|
||||
&& contains(fromJSON(needs.gate.outputs.matched), 'backend_all') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 50
|
||||
name: Eastwood (tests only)
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -163,7 +191,13 @@ jobs:
|
||||
|
||||
be-linter-eastwood-test-changed:
|
||||
# Lint only the test namespaces touched by the diff.
|
||||
if: ${{ !inputs.skip && !inputs.force-run && github.event_name == 'pull_request' }}
|
||||
needs: *backend-gate-needs
|
||||
if: >-
|
||||
${{ !cancelled() && needs.gate.result == 'success'
|
||||
&& needs.static-viz-files-changed.result != 'failure'
|
||||
&& needs.gate.outputs.force-run != 'true' && github.event_name == 'pull_request'
|
||||
&& (contains(fromJSON(needs.gate.outputs.matched), 'backend_all')
|
||||
|| needs.static-viz-files-changed.outputs.static_viz == 'true') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
name: Eastwood (changed tests only)
|
||||
@@ -228,7 +262,8 @@ jobs:
|
||||
run: clojure -X:dev:dev/test:ee:ee-dev:drivers:drivers-dev:test:eastwood/test :namespaces "[$NAMESPACES]"
|
||||
|
||||
be-tests:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: *backend-gate-needs
|
||||
if: *backend-gate
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -352,14 +387,14 @@ jobs:
|
||||
$BASE :exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]' ${{ matrix.job.test-args }}
|
||||
fi
|
||||
env:
|
||||
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. On a
|
||||
# normal CI run there are no dispatch inputs, so these render `false` and empty and the
|
||||
# Both come from test.profile-backend.yml, the only workflow that dispatches this suite, so a
|
||||
# dispatch is a profiling run. On a normal CI run these render `false` and empty and the
|
||||
# leg's own selection stands.
|
||||
#
|
||||
# ONLY_TESTS replaces the leg's own test selection, so every matrix leg runs the whole
|
||||
# list: the partition args no longer apply, and legs that hold none of the named tests run
|
||||
# nothing.
|
||||
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
|
||||
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
ONLY_TESTS: ${{ github.event.inputs.tests }}
|
||||
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
|
||||
continue-on-error: true
|
||||
@@ -435,7 +470,8 @@ jobs:
|
||||
# checks that all the namespaces we actually ship can be compiled, without any dependencies that we don't ship (such
|
||||
# as `:dev` dependencies). See #27009 for more context.
|
||||
be-check:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: *backend-gate-needs
|
||||
if: *backend-gate
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
@@ -451,7 +487,8 @@ jobs:
|
||||
run: clojure -M:ee:drivers:load-namespaces
|
||||
|
||||
be-cljfmt:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: *backend-gate-needs
|
||||
if: *backend-gate
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
name: Cljfmt
|
||||
@@ -464,6 +501,8 @@ jobs:
|
||||
|
||||
be-tests-result:
|
||||
needs:
|
||||
- gate
|
||||
- static-viz-files-changed
|
||||
- project-tests
|
||||
- be-linter-eastwood
|
||||
- be-linter-eastwood-test
|
||||
@@ -472,7 +511,7 @@ jobs:
|
||||
- be-cljfmt
|
||||
- be-check
|
||||
- be-tests
|
||||
if: always() && !cancelled()
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
|
||||
17
.github/workflows/build-scripts.yml
vendored
@@ -2,18 +2,16 @@ name: Build scripts
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip-build-scripts:
|
||||
type: boolean
|
||||
default: false
|
||||
skip-embedder-plugin:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: build_scripts embedder_plugin
|
||||
|
||||
test-embedder-plugin:
|
||||
if: ${{ !inputs.skip-embedder-plugin }}
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedder_plugin') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -63,7 +61,8 @@ jobs:
|
||||
'[metabase-enterprise.semantic-search.appdb-pgvector-mode-test/appdb-pgvector-in-process-embedder-round-trip-test]'
|
||||
|
||||
test-build-scripts:
|
||||
if: ${{ !inputs.skip-build-scripts }}
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'build_scripts') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
|
||||
10
.github/workflows/bundle-size.yml
vendored
@@ -13,8 +13,18 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: frontend_sources
|
||||
|
||||
bundle-size-check:
|
||||
name: "Bundle size check"
|
||||
needs: gate
|
||||
# There is no base uberjar to compare against outside a pull request.
|
||||
if: >-
|
||||
${{ github.event_name == 'pull_request'
|
||||
&& contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
|
||||
continue-on-error: true
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
|
||||
11
.github/workflows/docs-generate-base.yml
vendored
@@ -86,6 +86,16 @@ jobs:
|
||||
clojure -M:ee:doc ai-providers-documentation
|
||||
continue-on-error: false
|
||||
|
||||
- name: Generate MCP tool documentation
|
||||
id: generate_mcp_tool_docs
|
||||
# Same reason as the AI provider step: release branches without the generator would fail the command.
|
||||
if: hashFiles('src/metabase/cmd/mcp_tools_dox.clj') != ''
|
||||
run: |
|
||||
set -e # Exit on any error
|
||||
echo "Generating MCP tool documentation..."
|
||||
clojure -M:ee:doc mcp-tools-documentation
|
||||
continue-on-error: false
|
||||
|
||||
- name: Generate usage analytics documentation
|
||||
run: ./bin/generate-usage-analytics-docs.bb
|
||||
|
||||
@@ -176,6 +186,7 @@ jobs:
|
||||
- \`clojure -M:ee:doc api-documentation\`
|
||||
- \`clojure -M:ee:doc command-documentation\`
|
||||
- \`clojure -M:ee:doc ai-providers-documentation\`
|
||||
- \`clojure -M:ee:doc mcp-tools-documentation\`
|
||||
- \`./bin/generate-usage-analytics-docs.bb\`
|
||||
- \`bun run embedding-sdk:docs:generate\`
|
||||
- \`bun run embedding-eajs:docs:generate\`
|
||||
|
||||
32
.github/workflows/drivers.yml
vendored
@@ -2,14 +2,6 @@ name: Driver Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
force-run:
|
||||
description: Run the full suite regardless of the diff (master / release branches, ci:run-all label).
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-drivers
|
||||
@@ -21,11 +13,21 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Read by the test-driver action. This suite is only ever dispatched by test.profile-backend.yml,
|
||||
# so a dispatch is a profiling run.
|
||||
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: backend_all
|
||||
|
||||
# This job determines which driver tests should run based on PR context.
|
||||
# All decision logic is consolidated in mage driver-decisions.
|
||||
determine-driver-skips:
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
@@ -69,8 +71,6 @@ jobs:
|
||||
--git-ref="${BASE_REF}"
|
||||
--force-run="${FORCE_RUN}"
|
||||
--pr-labels="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
--skip="${{ inputs.skip }}"
|
||||
--only-driver="${PROFILE_DRIVER}"
|
||||
)
|
||||
echo "Running:"
|
||||
echo "./bin/mage -driver-decisions ${MAGE_ARGS[*]}"
|
||||
@@ -84,10 +84,7 @@ jobs:
|
||||
./bin/mage -driver-decisions "${MAGE_ARGS[@]}" --github-output-only >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref || github.ref_name }}
|
||||
FORCE_RUN: ${{ inputs.force-run }}
|
||||
# Set only by test.profile-backend.yml. Absent on a normal CI run, which leaves the decision rules
|
||||
# alone; mage rejects a name that is not a driver.
|
||||
PROFILE_DRIVER: ${{ github.event.inputs.db }}
|
||||
FORCE_RUN: ${{ needs.gate.outputs.force-run == 'true' }}
|
||||
shell: bash
|
||||
|
||||
be-tests-athena-ee:
|
||||
@@ -1247,6 +1244,7 @@ jobs:
|
||||
|
||||
drivers-tests-result:
|
||||
needs:
|
||||
- gate
|
||||
- determine-driver-skips
|
||||
# if you add a driver job, you must add it to this list for it to be a required check
|
||||
- be-tests-athena-ee
|
||||
@@ -1274,7 +1272,7 @@ jobs:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
name: drivers-tests-result
|
||||
if: always() && !cancelled()
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Check job status
|
||||
uses: actions/github-script@v9
|
||||
@@ -1284,8 +1282,8 @@ jobs:
|
||||
script: | # js
|
||||
const needs = JSON.parse(process.env.needs);
|
||||
|
||||
// Drop the determine-driver-skips bookkeeping job; every remaining job is a driver
|
||||
// job whose result gates this check.
|
||||
// Drop the determine-driver-skips bookkeeping job; the rest -- the gate and the
|
||||
// driver jobs -- gate this check.
|
||||
const jobs = Object.entries(needs)
|
||||
.filter(([jobName]) => jobName !== 'determine-driver-skips')
|
||||
.map(([jobName, jobValues]) => ({
|
||||
|
||||
31
.github/workflows/e2e-tests.yml
vendored
@@ -3,9 +3,6 @@ name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
test-plan-artifact-id:
|
||||
description: "ID of this run's test-plan.json artifact, '' to run the full suite."
|
||||
type: string
|
||||
@@ -17,8 +14,14 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: e2e_all
|
||||
|
||||
e2e-matrix-builder:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||
uses: ./.github/workflows/e2e-matrix-builder.yml
|
||||
with:
|
||||
chunks: 50
|
||||
@@ -60,10 +63,9 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-test.yml
|
||||
needs:
|
||||
- e2e-matrix-builder
|
||||
if: |
|
||||
!cancelled() && !inputs.skip &&
|
||||
needs.e2e-matrix-builder.result == 'success' &&
|
||||
needs.e2e-matrix-builder.outputs.hasTests == 'true'
|
||||
# No status function, so a skipped or failed matrix builder keeps this out: the strategy below
|
||||
# cannot parse a matrix it never produced. The builder itself only runs when the gate says so.
|
||||
if: ${{ needs.e2e-matrix-builder.outputs.hasTests == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.e2e-matrix-builder.outputs.matrix) }}
|
||||
@@ -83,7 +85,7 @@ jobs:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
needs: e2e-tests
|
||||
if: always() && !cancelled() && github.ref == 'refs/heads/master'
|
||||
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -142,21 +144,26 @@ jobs:
|
||||
|
||||
e2e-tests-result:
|
||||
needs:
|
||||
- gate
|
||||
- e2e-matrix-builder
|
||||
- e2e-tests
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
name: e2e-tests-result
|
||||
if: always() && !cancelled()
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
E2E_TESTS_RESULT: ${{ needs.e2e-tests.result }}
|
||||
MATRIX_RESULT: ${{ needs.e2e-matrix-builder.result }}
|
||||
HAS_TESTS: ${{ needs.e2e-matrix-builder.outputs.hasTests }}
|
||||
SKIP: ${{ inputs.skip }}
|
||||
GATE_RESULT: ${{ needs.gate.result }}
|
||||
RUN: ${{ needs.gate.outputs.run }}
|
||||
steps:
|
||||
- name: Test results
|
||||
run: | # sh
|
||||
if [ "$SKIP" = "true" ]; then
|
||||
if [ "$GATE_RESULT" != "success" ]; then
|
||||
echo "The test gate failed, so the suite never ran"
|
||||
exit 1
|
||||
elif [ "$RUN" != "true" ]; then
|
||||
echo "Didn't run due to conditional filtering"
|
||||
elif [ "$MATRIX_RESULT" != "success" ]; then
|
||||
echo "E2E matrix builder failed"
|
||||
|
||||
51
.github/workflows/embedding-sdk.yml
vendored
@@ -2,39 +2,23 @@ name: SDK
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
cli-snippets-type-check:
|
||||
description: "Whether to run cli snippets type check."
|
||||
required: true
|
||||
type: boolean
|
||||
docs-snippets-type-check:
|
||||
description: "Whether to run docs snippets type check."
|
||||
required: true
|
||||
type: boolean
|
||||
documentation-validation:
|
||||
description: "Whether to run documentation generation validation check."
|
||||
required: true
|
||||
type: boolean
|
||||
component-tests:
|
||||
description: "Whether to run component tests."
|
||||
required: true
|
||||
type: boolean
|
||||
host-sample-apps-tests:
|
||||
description: "Whether to run host and sample apps compatibility tests."
|
||||
required: true
|
||||
type: boolean
|
||||
skip:
|
||||
description: Whether to skip running tests due to one of the artifacts failing to build.
|
||||
required: true
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Each job below maps to its own filter, so they read `matched` rather than `run`.
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: >-
|
||||
frontend_sources embedding_documentation embedding_sdk_components
|
||||
embedding_sdk_host_sample_apps
|
||||
|
||||
embedding-sdk-cli-snippets-type-check:
|
||||
if: inputs.cli-snippets-type-check && !inputs.skip
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -50,7 +34,8 @@ jobs:
|
||||
run: bun run embedding-sdk:cli-snippets:type-check
|
||||
|
||||
embedding-sdk-docs-snippets-type-check:
|
||||
if: inputs.docs-snippets-type-check && !inputs.skip
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_documentation') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -66,7 +51,8 @@ jobs:
|
||||
run: bun run embedding-sdk:docs-snippets:type-check
|
||||
|
||||
embedding-sdk-api-documentation-generation-validation:
|
||||
if: inputs.documentation-validation && !inputs.skip
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -116,23 +102,26 @@ jobs:
|
||||
# See `Component e2e tests` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
|
||||
embedding-sdk-component-tests:
|
||||
name: "SDK component tests"
|
||||
if: inputs.component-tests && !inputs.skip
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_sdk_components') }}
|
||||
uses: ./.github/workflows/embedding-sdk-component-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
not-required-embedding-sdk-apps-compatibility-tests:
|
||||
name: "SDK e2e tests for compatibility with (host/sample) apps"
|
||||
if: inputs.host-sample-apps-tests && !inputs.skip
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_sdk_host_sample_apps') }}
|
||||
uses: ./.github/workflows/embedding-sdk-host-sample-apps-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
embedding-sdk-tests-result:
|
||||
needs:
|
||||
- gate
|
||||
- embedding-sdk-cli-snippets-type-check
|
||||
- embedding-sdk-docs-snippets-type-check
|
||||
- embedding-sdk-api-documentation-generation-validation
|
||||
- embedding-sdk-component-tests
|
||||
if: always() && !cancelled()
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
|
||||
62
.github/workflows/frontend.yml
vendored
@@ -3,36 +3,10 @@ name: Frontend
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
skip-lint:
|
||||
type: boolean
|
||||
default: false
|
||||
skip-custom-viz:
|
||||
type: boolean
|
||||
default: false
|
||||
# ID of this run's test-plan.json artifact, or '' to run the full suite.
|
||||
test-plan-artifact-id:
|
||||
type: string
|
||||
default: ""
|
||||
skip-ci-scripts:
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip:
|
||||
description: "Skip main frontend jobs (type-check, unit tests, timezone tests)"
|
||||
type: boolean
|
||||
default: false
|
||||
skip-lint:
|
||||
description: "Skip linting jobs (oxfmt, oxlint, css variables check)"
|
||||
type: boolean
|
||||
default: false
|
||||
skip-custom-viz:
|
||||
description: "Skip custom-viz package tests"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-frontend
|
||||
@@ -46,32 +20,39 @@ env:
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
|
||||
jobs:
|
||||
# Each job below maps to its own filter, so they read `matched` rather than `run`.
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: frontend_all e2e_all custom_viz_all ci_scripts
|
||||
|
||||
fe-lint:
|
||||
if: ${{ !inputs.skip-lint }}
|
||||
needs: gate
|
||||
# Linting covers the e2e specs too.
|
||||
if: >-
|
||||
${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all')
|
||||
|| contains(fromJSON(needs.gate.outputs.matched), 'e2e_all') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
|
||||
- name: Compile CLJS
|
||||
run: bun run build-pure:cljs
|
||||
- name: Find CSS variables that are used but never defined
|
||||
run: bun run find-never-defined-css-variables
|
||||
- name: Run oxfmt formatting linter
|
||||
run: bun run lint-format-pure
|
||||
- name: Run oxlint linter (including module boundaries)
|
||||
run: bun run lint-oxlint-pure
|
||||
run: bun run lint-oxlint
|
||||
- name: Verify lint configuration and behavior
|
||||
run: bun run test-oxlint
|
||||
- name: Run CSS linter
|
||||
run: bun run lint-css
|
||||
|
||||
fe-type-check:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -86,7 +67,8 @@ jobs:
|
||||
name: Check types
|
||||
|
||||
fe-tests-unit:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
@@ -150,7 +132,8 @@ jobs:
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
|
||||
fe-tests-timezones:
|
||||
if: ${{ !inputs.skip }}
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -162,7 +145,8 @@ jobs:
|
||||
|
||||
# No CLJS compile: the CI-script specs don't import app code.
|
||||
fe-tests-ci-scripts:
|
||||
if: ${{ !inputs.skip-ci-scripts }}
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'ci_scripts') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -175,7 +159,8 @@ jobs:
|
||||
# The custom-viz package is an isolated Bun workspace under
|
||||
# enterprise/frontend/src/custom-viz
|
||||
fe-custom-viz:
|
||||
if: ${{ !inputs.skip-custom-viz }}
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'custom_viz_all') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
@@ -198,13 +183,14 @@ jobs:
|
||||
|
||||
fe-tests-result:
|
||||
needs:
|
||||
- gate
|
||||
- fe-lint
|
||||
- fe-type-check
|
||||
- fe-tests-unit
|
||||
- fe-tests-timezones
|
||||
- fe-tests-ci-scripts
|
||||
- fe-custom-viz
|
||||
if: always() && !cancelled()
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
|
||||
29
.github/workflows/loki.yml
vendored
@@ -3,10 +3,6 @@ name: Loki Visual Regression Testing
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
description: "Skip the Loki visual tests."
|
||||
type: boolean
|
||||
default: false
|
||||
test-plan-artifact-id:
|
||||
description: "ID of this run's test-plan.json artifact, or '' to run the full suite."
|
||||
type: string
|
||||
@@ -17,8 +13,15 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: frontend_loki_all
|
||||
|
||||
# Without a plan there is nothing to narrow, so this is skipped and every story runs.
|
||||
story-selection:
|
||||
if: ${{ !inputs.skip && inputs.test-plan-artifact-id != '' }}
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' && inputs.test-plan-artifact-id != '' }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -46,10 +49,14 @@ jobs:
|
||||
|
||||
visual-test:
|
||||
name: loki-run
|
||||
needs: story-selection
|
||||
# A missing job output compares equal to '0' in GitHub expressions,
|
||||
# so an empty selection is marked with a non-numeric value.
|
||||
if: ${{ !cancelled() && !inputs.skip && needs.story-selection.outputs.selection != 'empty' }}
|
||||
needs: [gate, story-selection]
|
||||
# `!cancelled()` so a skipped selection (no plan) still runs every story. A missing job output
|
||||
# compares equal to '0' in GitHub expressions, so an empty selection is marked with a
|
||||
# non-numeric value.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.gate.outputs.run == 'true'
|
||||
&& needs.story-selection.result != 'failure'
|
||||
&& needs.story-selection.outputs.selection != 'empty' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
@@ -110,8 +117,8 @@ jobs:
|
||||
|
||||
loki-tests-result:
|
||||
name: visual-test
|
||||
needs: [story-selection, visual-test]
|
||||
if: always() && !cancelled()
|
||||
needs: [gate, story-selection, visual-test]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -13,8 +13,8 @@ on:
|
||||
default: 7
|
||||
required: false
|
||||
schedule:
|
||||
# Run weekly on Sundays at 6 AM UTC
|
||||
- cron: "0 6 * * 0"
|
||||
# Run daily at 6 AM UTC
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
jobs:
|
||||
collect-and-average-timings:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DAYS_BACK: ${{ inputs.days_back || 7 }}
|
||||
DAYS_BACK: ${{ inputs.days_back || 1 }}
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
uses: actions/create-github-app-token@v1
|
||||
|
||||
379
.github/workflows/run-tests.yml
vendored
@@ -7,7 +7,6 @@ on:
|
||||
- "release-**"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -18,153 +17,53 @@ permissions:
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
# `force-run` and `force-skip` are global overrides; `defer` means this
|
||||
# job has no opinion and every downstream gate behaves as it always has.
|
||||
#
|
||||
# Only the lowest unmerged PR of a stack runs CI: it is the one whose base
|
||||
# ref is the stack base. Every PR above it is force-skipped and gets tested
|
||||
# once it reaches the bottom of the stack.
|
||||
should-run:
|
||||
name: Decide which jobs should run
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
verdict: ${{ steps.decide.outputs.verdict }}
|
||||
steps:
|
||||
- name: Decide
|
||||
id: decide
|
||||
# Precedence matters for the conditions below.
|
||||
env:
|
||||
VERDICT: >-
|
||||
${{ (github.ref_name == 'master' || startsWith(github.ref_name, 'release-x.')) && 'force-run'
|
||||
|| contains(github.event.pull_request.labels.*.name, 'ci:run-all') && 'force-run'
|
||||
|| contains(github.event.pull_request.labels.*.name, 'ci:skip') && 'force-skip'
|
||||
|| (github.event.pull_request.stack != null && github.event.pull_request.stack.base.ref != github.event.pull_request.base.ref) && 'force-skip'
|
||||
|| 'defer' }}
|
||||
run: echo "verdict=$VERDICT" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
files-changed:
|
||||
needs: should-run
|
||||
name: Detect changed files
|
||||
if: ${{ needs.should-run.outputs.verdict == 'defer' }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
e2e_all: ${{ steps.changes.outputs.e2e_all }}
|
||||
e2e_specs: ${{ steps.changes.outputs.e2e_specs }}
|
||||
e2e_infra: ${{ steps.changes.outputs.e2e_infra }}
|
||||
backend_all: ${{ steps.changes.outputs.backend_all }}
|
||||
build_scripts: ${{ steps.changes.outputs.build_scripts }}
|
||||
embedder_plugin: ${{ steps.changes.outputs.embedder_plugin }}
|
||||
frontend_all: ${{ steps.changes.outputs.frontend_all }}
|
||||
frontend_sources: ${{ steps.changes.outputs.frontend_sources }}
|
||||
frontend_unit_infra: ${{ steps.changes.outputs.frontend_unit_infra }}
|
||||
frontend_loki_infra: ${{ steps.changes.outputs.frontend_loki_infra }}
|
||||
frontend_loki_all: ${{ steps.changes.outputs.frontend_loki_all }}
|
||||
shared_sources: ${{ steps.changes.outputs.shared_sources }}
|
||||
documentation: ${{ steps.changes.outputs.documentation }}
|
||||
embedding_documentation: ${{ steps.changes.outputs.embedding_documentation }}
|
||||
embedding_sdk_components: ${{ steps.changes.outputs.embedding_sdk_components }}
|
||||
embedding_sdk_host_sample_apps: ${{ steps.changes.outputs.embedding_sdk_host_sample_apps }}
|
||||
embedding_sdk_ci: ${{ steps.changes.outputs.embedding_sdk_ci }}
|
||||
custom_viz_all: ${{ steps.changes.outputs.custom_viz_all }}
|
||||
ci_scripts: ${{ steps.changes.outputs.ci_scripts }}
|
||||
project_backend_checks: ${{ steps.changes.outputs.project_backend_checks }}
|
||||
project_migration_checks: ${{ steps.changes.outputs.project_migration_checks }}
|
||||
project_ratchet_checks: ${{ steps.changes.outputs.project_ratchet_checks }}
|
||||
# A ratchets-only change (the shrink automation PR) needs the ratchet check, not an uberjar.
|
||||
no_source_code_changes: >-
|
||||
${{ (steps.changes.outputs.documentation == 'true'
|
||||
|| steps.changes.outputs.project_ratchet_checks == 'true')
|
||||
&& steps.changes.outputs.backend_all == 'false'
|
||||
&& steps.changes.outputs.frontend_all == 'false'
|
||||
&& steps.changes.outputs.e2e_all == 'false' }}
|
||||
|
||||
all_changed_files: ${{ steps.changes.outputs.all_changed_files }}
|
||||
e2e_all_files: ${{ steps.changes.outputs.e2e_all_files }}
|
||||
e2e_specs_files: ${{ steps.changes.outputs.e2e_specs_files }}
|
||||
frontend_sources_files: ${{ steps.changes.outputs.frontend_sources_files }}
|
||||
backend_sources_files: ${{ steps.changes.outputs.backend_sources_files }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
# comma separated list of files
|
||||
list-files: csv
|
||||
filters: .github/file-paths.yaml
|
||||
# Force-cancel is the only decision left here. The jobs below read `matched`;
|
||||
# each suite that owns its gating calls the gate itself with the filters it cares about.
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: build_embedding_sdk_package build_uberjar e2e_all
|
||||
|
||||
# The plan only ever narrows a PR's suites; every other run skips this and they run in full. A
|
||||
# planner failure does the same, so it must not fail the run either.
|
||||
create-test-plan:
|
||||
needs: [should-run, files-changed]
|
||||
needs: gate
|
||||
name: Create test plan
|
||||
if: ${{ github.event_name == 'pull_request' && needs.should-run.outputs.verdict == 'defer' }}
|
||||
if: ${{ github.event_name == 'pull_request' && needs.gate.outputs.verdict == 'defer' }}
|
||||
continue-on-error: true
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
stats: ${{ steps.compute.outputs.stats }}
|
||||
artifact-id: ${{ steps.compute.outputs.artifact-id }}
|
||||
# The artifact is uploaded before the planner's last steps, so a later failure would otherwise
|
||||
# hand the suites a plan from a run that errored. Anything short of a clean run means no plan.
|
||||
artifact-id: ${{ steps.compute.outcome == 'success' && steps.compute.outputs.artifact-id || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Create test plan
|
||||
id: compute
|
||||
uses: ./.github/actions/create-test-plan
|
||||
with:
|
||||
changed-files: ${{ needs.files-changed.outputs.all_changed_files }}
|
||||
fe-changed-files: ${{ needs.files-changed.outputs.frontend_sources_files }}
|
||||
be-changed-files: ${{ needs.files-changed.outputs.backend_sources_files }}
|
||||
unit-infra-touched: ${{ needs.files-changed.outputs.frontend_unit_infra }}
|
||||
loki-infra-touched: ${{ needs.files-changed.outputs.frontend_loki_infra }}
|
||||
e2e-infra-touched: ${{ needs.files-changed.outputs.e2e_infra }}
|
||||
shared-sources-touched: ${{ needs.files-changed.outputs.shared_sources }}
|
||||
|
||||
upload-affected-tests-stats:
|
||||
needs: create-test-plan
|
||||
# Only land rows for PRs from the upstream repo so forks don't pollute the table.
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.create-test-plan.result == 'success' &&
|
||||
github.event.pull_request.head.repo.full_name == 'metabase/metabase'
|
||||
# Stats upload is best-effort — a failure here shouldn't fail the PR run.
|
||||
continue-on-error: true
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: ./.github/actions/prepare-node-bun
|
||||
- name: Upload affected tests stats
|
||||
env:
|
||||
STATS_JSON: ${{ needs.create-test-plan.outputs.stats }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
ENG_STATS_URL: ${{ secrets.ENG_STATS_URL }}
|
||||
API_KEY: ${{ secrets.ENG_STATS_API_KEY }}
|
||||
run: bun .github/scripts/upload-affected-tests-stats.ts
|
||||
stats-url: ${{ secrets.ENG_STATS_URL }}
|
||||
stats-api-key: ${{ secrets.ENG_STATS_API_KEY }}
|
||||
|
||||
uberjar:
|
||||
needs: [should-run, files-changed]
|
||||
if: |
|
||||
!cancelled() && needs.should-run.outputs.verdict != 'force-skip' &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.outputs.no_source_code_changes != 'true')
|
||||
needs: gate
|
||||
if: >-
|
||||
${{ needs.gate.outputs.verdict != 'force-skip'
|
||||
&& contains(fromJSON(needs.gate.outputs.matched), 'build_uberjar') }}
|
||||
uses: ./.github/workflows/uberjar.yml
|
||||
secrets: inherit
|
||||
|
||||
containerize:
|
||||
needs: [uberjar]
|
||||
needs: uberjar
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.uberjar.result == 'success' &&
|
||||
(
|
||||
(github.event_name == 'push' && github.ref_name == 'master') ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'build-docker-uberjar'))
|
||||
)
|
||||
${{ (github.event_name == 'push' && github.ref_name == 'master')
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'build-docker-uberjar')) }}
|
||||
name: containerize (${{ matrix.edition }})
|
||||
strategy:
|
||||
matrix:
|
||||
edition: ['ee', 'oss']
|
||||
edition: ["ee", "oss"]
|
||||
uses: ./.github/workflows/containerize-jar.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
@@ -174,16 +73,17 @@ jobs:
|
||||
edition: ${{ matrix.edition }}
|
||||
release: false
|
||||
|
||||
bundle-size:
|
||||
needs: uberjar
|
||||
uses: ./.github/workflows/bundle-size.yml
|
||||
secrets: inherit
|
||||
|
||||
# Compile CLJS in parallel with the uberjar and pass it to each e2e group.
|
||||
# The compile is doesn't depend on MB_EDITION
|
||||
e2e-cljs:
|
||||
name: Compile CLJS for e2e
|
||||
needs: [should-run, files-changed]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
(needs.files-changed.result == 'success' &&
|
||||
needs.files-changed.outputs.e2e_all == 'true'))
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'e2e_all') }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -204,223 +104,100 @@ jobs:
|
||||
# replace it rather than fail on a re-run.
|
||||
overwrite: true
|
||||
|
||||
static-viz-files-changed:
|
||||
name: Check whether static-viz files changed
|
||||
needs: should-run
|
||||
# Its only consumer is the backend skip gate, which a force-run overrides.
|
||||
if: ${{ needs.should-run.outputs.verdict == 'defer' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
static_viz: ${{ steps.static_viz.outputs.static_viz }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Build static-viz frontend
|
||||
run: bun run build-static-viz
|
||||
env:
|
||||
MB_EDITION: ee
|
||||
- name: Check for static viz changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: static_viz
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/static-viz-sources.yaml
|
||||
|
||||
backend-tests:
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.result == 'success')
|
||||
needs: [should-run, files-changed, static-viz-files-changed]
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
|
||||
uses: ./.github/workflows/backend.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.backend_all != 'true' &&
|
||||
needs.static-viz-files-changed.outputs.static_viz != 'true' }}
|
||||
force-run: ${{ needs.should-run.outputs.verdict == 'force-run' }}
|
||||
run-project-backend-checks: ${{ needs.files-changed.outputs.project_backend_checks == 'true' }}
|
||||
run-project-migration-checks: ${{ needs.files-changed.outputs.project_migration_checks == 'true' }}
|
||||
run-project-ratchet-checks: ${{ needs.files-changed.outputs.project_ratchet_checks == 'true' }}
|
||||
|
||||
# These suites gate themselves; the only thing decided here is whether to call them at all.
|
||||
semantic-search-tests:
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.result == 'success')
|
||||
needs: [should-run, files-changed]
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
|
||||
uses: ./.github/workflows/semantic-search.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.backend_all != 'true' }}
|
||||
smoke-only: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'ci:run-semantic-search-tests')
|
||||
}}
|
||||
|
||||
app-db-tests:
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.result == 'success')
|
||||
needs: [should-run, files-changed]
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
|
||||
uses: ./.github/workflows/app-db.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.backend_all != 'true' }}
|
||||
force-run: ${{ needs.should-run.outputs.verdict == 'force-run' }}
|
||||
|
||||
driver-tests:
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.result == 'success')
|
||||
needs: [should-run, files-changed]
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
|
||||
uses: ./.github/workflows/drivers.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.backend_all != 'true' }}
|
||||
force-run: ${{ needs.should-run.outputs.verdict == 'force-run' }}
|
||||
|
||||
frontend-tests:
|
||||
needs: [should-run, files-changed, create-test-plan]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.result == 'success')
|
||||
needs: [gate, create-test-plan]
|
||||
# `!cancelled()` so a skipped planner (no plan) still runs the suite.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.gate.result == 'success'
|
||||
&& needs.gate.outputs.verdict != 'force-skip' }}
|
||||
uses: ./.github/workflows/frontend.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.frontend_all != 'true' }}
|
||||
# A skipped/failed planner supplies no artifact, so the suite runs in full.
|
||||
test-plan-artifact-id: ${{ needs.create-test-plan.result == 'success' && needs.create-test-plan.outputs.artifact-id || '' }}
|
||||
skip-lint: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.frontend_all != 'true' &&
|
||||
needs.files-changed.outputs.e2e_all != 'true' }}
|
||||
skip-custom-viz: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.custom_viz_all != 'true' }}
|
||||
skip-ci-scripts: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.ci_scripts != 'true' }}
|
||||
# No artifact means the suite runs in full.
|
||||
test-plan-artifact-id: ${{ needs.create-test-plan.outputs.artifact-id }}
|
||||
|
||||
loki-tests:
|
||||
needs: [should-run, files-changed, create-test-plan]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.result == 'success')
|
||||
needs: [gate, create-test-plan]
|
||||
# `!cancelled()` so a skipped planner (no plan) still runs the suite.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.gate.result == 'success'
|
||||
&& needs.gate.outputs.verdict != 'force-skip' }}
|
||||
uses: ./.github/workflows/loki.yml
|
||||
with:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.frontend_loki_all != 'true' }}
|
||||
# A skipped/failed planner supplies no artifact, so the suite runs in full.
|
||||
test-plan-artifact-id: ${{ needs.create-test-plan.result == 'success' && needs.create-test-plan.outputs.artifact-id || '' }}
|
||||
# No artifact means the suite runs in full.
|
||||
test-plan-artifact-id: ${{ needs.create-test-plan.outputs.artifact-id }}
|
||||
|
||||
e2e-tests:
|
||||
needs: [should-run, files-changed, uberjar, create-test-plan]
|
||||
needs: [gate, uberjar, create-test-plan]
|
||||
# Skip e2e tests when the uberjar build failed or was cancelled — the
|
||||
# downstream tests can't fetch a missing artifact, and we don't want to
|
||||
# mask the uberjar failure with a wave of misleading e2e errors. We still
|
||||
# enter the workflow when uberjar is `skipped` (docs-only PRs), because
|
||||
# the inner `skip` input handles that path and reports the required
|
||||
# the suite's own gate handles that path and reports the required
|
||||
# `e2e-tests-result` check as success. When the uberjar genuinely failed,
|
||||
# this whole job is skipped, the inner required checks never report, and
|
||||
# branch protection blocks the merge as pending.
|
||||
if: |
|
||||
always() && !cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' || needs.files-changed.result == 'success') &&
|
||||
!cancelled() &&
|
||||
needs.gate.result == 'success' &&
|
||||
needs.gate.outputs.verdict != 'force-skip' &&
|
||||
needs.uberjar.result != 'failure' &&
|
||||
needs.uberjar.result != 'cancelled'
|
||||
uses: ./.github/workflows/e2e-tests.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
skip: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.e2e_all != 'true' }}
|
||||
# Only narrow specs on pull requests that haven't been forced to run everything.
|
||||
# Push and merge-queue runs always use the full suite.
|
||||
test-plan-artifact-id: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.create-test-plan.result == 'success' &&
|
||||
needs.create-test-plan.outputs.artifact-id || '' }}
|
||||
# No artifact means the suite runs in full.
|
||||
test-plan-artifact-id: ${{ needs.create-test-plan.outputs.artifact-id }}
|
||||
|
||||
embedding-sdk-package:
|
||||
needs: [should-run, files-changed]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.outputs.embedding_sdk_components == 'true' ||
|
||||
needs.files-changed.outputs.frontend_sources == 'true' ||
|
||||
needs.files-changed.outputs.embedding_documentation == 'true' ||
|
||||
needs.files-changed.outputs.embedding_sdk_ci == 'true' ||
|
||||
needs.files-changed.outputs.embedding_sdk_host_sample_apps == 'true')
|
||||
needs: gate
|
||||
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'build_embedding_sdk_package') }}
|
||||
uses: ./.github/workflows/embedding-sdk-package-build.yml
|
||||
secrets: inherit
|
||||
|
||||
sdk-tests:
|
||||
needs: [should-run, uberjar, embedding-sdk-package, files-changed]
|
||||
if: always() && !cancelled()
|
||||
needs: [gate, uberjar, embedding-sdk-package]
|
||||
# Same rule as e2e-tests: a `skipped` artifact build still enters the suite, whose own gate then
|
||||
# skips every job and reports the required result check. A genuine failure keeps this job out, so
|
||||
# the check stays pending instead of being buried under missing-artifact errors. A failed gate
|
||||
# skips both builds for a reason the suite's own gate cannot see, so it has to keep this out too.
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.gate.result == 'success' &&
|
||||
needs.uberjar.result != 'failure' &&
|
||||
needs.uberjar.result != 'cancelled' &&
|
||||
needs.embedding-sdk-package.result != 'failure' &&
|
||||
needs.embedding-sdk-package.result != 'cancelled'
|
||||
uses: ./.github/workflows/embedding-sdk.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
cli-snippets-type-check: >-
|
||||
${{ needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.outputs.frontend_sources == 'true' }}
|
||||
docs-snippets-type-check: >-
|
||||
${{ needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.outputs.embedding_documentation == 'true' }}
|
||||
documentation-validation: >-
|
||||
${{ needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.outputs.frontend_sources == 'true' }}
|
||||
component-tests: >-
|
||||
${{ needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.outputs.embedding_sdk_components == 'true' }}
|
||||
host-sample-apps-tests: >-
|
||||
${{ needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.outputs.embedding_sdk_host_sample_apps == 'true' }}
|
||||
skip: >-
|
||||
${{ needs.embedding-sdk-package.result != 'success' ||
|
||||
needs.uberjar.result != 'success' }}
|
||||
|
||||
build-scripts-tests:
|
||||
needs: [should-run, files-changed]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
(needs.should-run.outputs.verdict == 'force-run' ||
|
||||
needs.files-changed.result == 'success')
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
|
||||
uses: ./.github/workflows/build-scripts.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
skip-build-scripts: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.build_scripts != 'true' }}
|
||||
skip-embedder-plugin: >-
|
||||
${{ needs.should-run.outputs.verdict != 'force-run' &&
|
||||
needs.files-changed.outputs.embedder_plugin != 'true' }}
|
||||
|
||||
bundle-size:
|
||||
needs: [uberjar, files-changed]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.uberjar.result == 'success' &&
|
||||
needs.files-changed.outputs.frontend_sources == 'true'
|
||||
uses: ./.github/workflows/bundle-size.yml
|
||||
secrets: inherit
|
||||
|
||||
38
.github/workflows/semantic-search.yml
vendored
@@ -2,14 +2,6 @@ name: Semantic Search Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
smoke-only:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Only run Postgres Latest to do a quick resource-efficient sanity check, intended to be set for PRs.
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-semantic-search
|
||||
@@ -21,10 +13,21 @@ concurrency:
|
||||
env:
|
||||
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
|
||||
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
|
||||
# Read by the test-driver action. This suite is only ever dispatched by test.profile-backend.yml,
|
||||
# so a dispatch is a profiling run.
|
||||
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
jobs:
|
||||
# A CI run is a smoke run -- Postgres Latest alone, to keep PRs cheap -- unless the whole suite is
|
||||
# forced to run (master, release branches, `ci:run-all`).
|
||||
gate:
|
||||
uses: ./.github/workflows/test.gate.yml
|
||||
with:
|
||||
filters: backend_all
|
||||
|
||||
semantic-search-tests-h2:
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'h2') }}
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.force-run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -68,7 +71,8 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-mariadb:
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'mariadb') }}
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.force-run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -133,7 +137,8 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-mysql:
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'mysql') }}
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.force-run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -205,7 +210,8 @@ jobs:
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
|
||||
semantic-search-tests-postgres:
|
||||
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'postgres') }}
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -221,7 +227,7 @@ jobs:
|
||||
postgres-image: postgres:latest
|
||||
pgvector-image: pgvector/pgvector:pg17
|
||||
exclude: >-
|
||||
${{ inputs.smoke-only
|
||||
${{ needs.gate.outputs.force-run != 'true'
|
||||
&& fromJSON('[{"version":{"name":"Postgres 14.x with pgvector pg17"}}]')
|
||||
|| fromJSON('[]') }}
|
||||
job:
|
||||
@@ -276,7 +282,8 @@ jobs:
|
||||
# MB_PGVECTOR_DB_URL is deliberately unset. The dedicated-harness tests gate themselves off; the
|
||||
# appdb-pgvector-mode round trip (and the URL-independent unit tests) run for real.
|
||||
semantic-search-tests-appdb-mode:
|
||||
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'appdb-mode') }}
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.force-run == 'true' }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
@@ -328,6 +335,7 @@ jobs:
|
||||
|
||||
semantic-search-tests-result:
|
||||
needs:
|
||||
- gate
|
||||
- semantic-search-tests-h2
|
||||
- semantic-search-tests-mariadb
|
||||
- semantic-search-tests-mysql
|
||||
@@ -336,7 +344,7 @@ jobs:
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
name: semantic-search-tests-result
|
||||
if: always() && !cancelled()
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Check job status
|
||||
uses: actions/github-script@v9
|
||||
|
||||
119
.github/workflows/test.gate.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
name: Test gate
|
||||
|
||||
# Decides whether a test suite should run, so each suite owns its own gating
|
||||
# instead of the caller computing a `skip` input for it.
|
||||
#
|
||||
# `force-run` and `force-skip` are global overrides; `defer` hands the decision
|
||||
# to the paths filter, which is the only path that pays for a checkout.
|
||||
#
|
||||
# Only the lowest unmerged PR of a stack runs CI: it is the one whose base ref
|
||||
# is the stack base. Every PR above it is force-skipped and gets tested once it
|
||||
# reaches the bottom of the stack.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
filters:
|
||||
description: >-
|
||||
Filter names from .github/file-paths.yaml, comma- or
|
||||
whitespace-separated. The suite runs when the diff matches any of them.
|
||||
Leave empty to decide on the overrides alone and skip the paths filter.
|
||||
type: string
|
||||
default: ''
|
||||
outputs:
|
||||
run:
|
||||
description: Whether the calling suite should run.
|
||||
value: >-
|
||||
${{ jobs.should-run.outputs.verdict != 'force-skip'
|
||||
&& (inputs.filters == ''
|
||||
|| jobs.should-run.outputs.verdict == 'force-run'
|
||||
|| jobs.files-changed.outputs.any == 'true') }}
|
||||
matched:
|
||||
description: >-
|
||||
Which of the requested filters the diff touched, as a JSON array, for suites whose jobs
|
||||
map to different filters. A force-run reports every requested filter.
|
||||
value: >-
|
||||
${{ (jobs.should-run.outputs.verdict == 'force-run' && jobs.should-run.outputs.filters)
|
||||
|| jobs.files-changed.outputs.matched || '[]' }}
|
||||
force-run:
|
||||
description: Whether the suite should run in full, ignoring the diff.
|
||||
value: ${{ jobs.should-run.outputs.verdict == 'force-run' }}
|
||||
verdict:
|
||||
description: force-run, force-skip, or defer.
|
||||
value: ${{ jobs.should-run.outputs.verdict }}
|
||||
|
||||
jobs:
|
||||
should-run:
|
||||
name: Decide whether to run
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
verdict: ${{ steps.decide.outputs.verdict }}
|
||||
filters: ${{ steps.decide.outputs.filters }}
|
||||
steps:
|
||||
- name: Decide
|
||||
id: decide
|
||||
# Precedence matters for the conditions below.
|
||||
env:
|
||||
FILTERS: ${{ inputs.filters }}
|
||||
VERDICT: >-
|
||||
${{ (github.ref_name == 'master' || startsWith(github.ref_name, 'release-x.')) && 'force-run'
|
||||
|| contains(github.event.pull_request.labels.*.name, 'ci:run-all') && 'force-run'
|
||||
|| contains(github.event.pull_request.labels.*.name, 'ci:skip') && 'force-skip'
|
||||
|| (github.event.pull_request.stack != null && github.event.pull_request.stack.base.ref != github.event.pull_request.base.ref) && 'force-skip'
|
||||
|| 'defer' }}
|
||||
run: |
|
||||
echo "verdict=$VERDICT" | tee -a "$GITHUB_OUTPUT"
|
||||
# Echoed back as JSON so a force-run can report every requested filter as matched.
|
||||
echo "filters=$(jq -Rc 'split("[,\\s]+";"") | map(select(length > 0))' <<< "$FILTERS")" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
files-changed:
|
||||
name: Detect changed files
|
||||
needs: should-run
|
||||
if: ${{ needs.should-run.outputs.verdict == 'defer' && inputs.filters != '' }}
|
||||
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
matched: ${{ steps.match.outputs.matched }}
|
||||
any: ${{ steps.match.outputs.any }}
|
||||
steps:
|
||||
# The filter definitions are the only thing on disk that paths-filter reads.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
# No file lists: one filter's list can outgrow the 128 KiB a single environment variable may
|
||||
# hold on Linux, and every caller after the gate pays for it. A job that needs the paths runs
|
||||
# its own paths filter.
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
- name: Match the diff against the requested filters
|
||||
id: match
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
REQUESTED: ${{ inputs.filters }}
|
||||
# The names of every filter the diff matched, as a JSON array.
|
||||
CHANGED: ${{ steps.changes.outputs.changes }}
|
||||
with:
|
||||
script: | # js
|
||||
const fs = require('fs');
|
||||
|
||||
const requested = process.env.REQUESTED.split(/[\s,]+/).filter(Boolean);
|
||||
const changed = JSON.parse(process.env.CHANGED);
|
||||
|
||||
// A filter name that no longer exists would silently gate the suite off forever.
|
||||
const defined = [...fs.readFileSync('.github/file-paths.yaml', 'utf8')
|
||||
.matchAll(/^(\w+):/gm)].map(([, name]) => name);
|
||||
const unknown = requested.filter((filter) => !defined.includes(filter));
|
||||
if (unknown.length) {
|
||||
core.setFailed(`Unknown filter(s) in .github/file-paths.yaml: ${unknown.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = requested.filter((filter) => changed.includes(filter));
|
||||
core.info(`requested: ${requested.join(', ')}`);
|
||||
core.info(`matched: ${matched.join(', ') || 'none'}`);
|
||||
core.setOutput('matched', JSON.stringify(matched));
|
||||
core.setOutput('any', matched.length > 0);
|
||||
30
.github/workflows/test.profile-backend.yml
vendored
@@ -1,23 +1,22 @@
|
||||
name: Profile Backend
|
||||
|
||||
# The only on-demand entry point for the backend test suites. Dispatching this workflow runs one
|
||||
# job of one suite against the ref you dispatch on, under clj-async-profiler, and attaches a
|
||||
# flamegraph to the job.
|
||||
# suite against the ref you dispatch on, under clj-async-profiler, and attaches a flamegraph to
|
||||
# each job.
|
||||
#
|
||||
# The suite workflows below are called unchanged: services, matrix legs, test args and quarantine
|
||||
# gating are whatever the normal CI run uses, because they are the same jobs. They declare nothing
|
||||
# for this feature -- they read `suite`, `db` and `tests` off `github.event.inputs`, which inside a
|
||||
# called workflow is this dispatch's payload.
|
||||
# for this feature beyond treating a `workflow_dispatch` as a profiling run, and reading `tests` off
|
||||
# `github.event.inputs`, which inside a called workflow is this dispatch's payload.
|
||||
#
|
||||
# `suite` picks the workflow, `db` picks the leg within it. `db` is free text so that adding a
|
||||
# driver or an app-DB leg needs no edit here; an unknown driver fails the drivers run through
|
||||
# mage's `--only-driver`, and an unknown app-DB or semantic-search leg matches nothing and runs no
|
||||
# jobs.
|
||||
# The suite is gated like any other run: dispatched on master or a release branch it runs in full;
|
||||
# anywhere else it runs only if the ref's diff from master touches the backend, and semantic-search
|
||||
# runs its Postgres-latest smoke leg alone.
|
||||
#
|
||||
# Renaming an input here means updating every reader -- `grep -rn github.event.inputs .github/`.
|
||||
# Nothing enforces that, deliberately: a stale name reads as empty, which runs every leg of the
|
||||
# suite unprofiled, on the run you are watching. It cannot affect a normal CI run, where those
|
||||
# references are empty either way.
|
||||
# Renaming `tests` means updating its readers -- `grep -rn github.event.inputs.tests .github/`.
|
||||
# Nothing enforces that, deliberately: a stale name reads as empty, which profiles each leg's usual
|
||||
# set instead of the tests you named, on the run you are watching. It cannot affect a normal CI
|
||||
# run, where the reference is empty either way.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -30,13 +29,8 @@ on:
|
||||
- app-db
|
||||
- drivers
|
||||
- semantic-search
|
||||
db:
|
||||
description: 'Which leg of the suite: app-db takes mariadb, mysql or postgres; drivers takes a driver name, e.g. bigquery or mongo-ssl; semantic-search takes h2, mariadb, mysql, postgres or appdb-mode. Ignored by backend. Blank runs the whole suite -- every leg for app-db and semantic-search, mage''s usual decisions for drivers.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
tests:
|
||||
description: 'Optional: profile only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank profiles the leg''s usual set.'
|
||||
description: 'Optional: profile only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank profiles each leg''s usual set.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
@@ -1,6 +1,7 @@
|
||||
frontend/src/cljs
|
||||
docs/configuring-metabase/environment-variables.md
|
||||
docs/ai/providers.md
|
||||
docs/ai/mcp-tools.md
|
||||
docs/api/*
|
||||
docs/api-documentation.md
|
||||
frontend/src/embedding-sdk-shared/.storybook/msw-public/mockServiceWorker.js
|
||||
|
||||
@@ -61,6 +61,15 @@ const config: StorybookConfig = {
|
||||
build: {
|
||||
test: {
|
||||
disableTreeShaking: false,
|
||||
// Setting this replaces the addons --test disables by default,
|
||||
// so the first three repeat Storybook's defaults.
|
||||
disabledAddons: [
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-essentials/docs",
|
||||
"@storybook/addon-coverage",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-interactions",
|
||||
],
|
||||
},
|
||||
},
|
||||
typescript: {
|
||||
|
||||
@@ -49,16 +49,17 @@ if (
|
||||
document.head.appendChild(fontFaceStyle);
|
||||
}
|
||||
|
||||
// Force every registered font to load before the story renders. This ensures we
|
||||
// use same fonts for tests every time, instead of using generic fallback
|
||||
// family like `sans-serif` which might resolve to different specific fonts (depending
|
||||
// on what available on current machine) which might have different metrics, in turn
|
||||
// producing inconsistent behavior in code relying on measuring text (e.g., column
|
||||
// autosize in table visualization). Awaiting all loads here makes measurements
|
||||
// deterministic across platforms.
|
||||
const PRELOADED_FONT_FAMILIES = ["Lato", "JetBrains Mono"];
|
||||
|
||||
// Load the app's text and monospace fonts before the story renders,
|
||||
// so code that measures text, like the table's column autosize, gets the same metrics on every machine.
|
||||
const fontsReady = async () => {
|
||||
const loads: Promise<unknown>[] = [];
|
||||
document.fonts.forEach((face) => loads.push(face.load()));
|
||||
document.fonts.forEach((face) => {
|
||||
if (PRELOADED_FONT_FAMILIES.includes(face.family)) {
|
||||
loads.push(face.load());
|
||||
}
|
||||
});
|
||||
await Promise.all(loads);
|
||||
};
|
||||
|
||||
|
||||
24
bb.edn
@@ -253,6 +253,30 @@
|
||||
:requires [[mage.quick-test-runner :as qtr]]
|
||||
:task (task! (qtr/go parsed))}
|
||||
|
||||
openapi-diff
|
||||
{:doc "Semantic diff of two OpenAPI specs, classified breaking-first, for the API changelog"
|
||||
:examples [["./bin/mage openapi-diff old.json new.json" "Full diff, breaking changes first"]
|
||||
["./bin/mage openapi-diff old.json new.json --severity breaking" "Only what needs a changelog entry"]
|
||||
["./bin/mage openapi-diff --refs release-x.63.x release-x.64.x --severity breaking" "Generate both specs from source, then diff"]
|
||||
["./bin/mage openapi-diff --refs --committed release-x.63.x master" "Use each ref's committed spec (fast, may be stale)"]
|
||||
["./bin/mage openapi-diff --refs --grouped release-x.63.x master --severity breaking" "One line per distinct change, widest blast radius first"]]
|
||||
:arg-schema [:tuple
|
||||
[:string {:name "old" :description "Path to the older openapi.json, or a git ref with --refs"}]
|
||||
[:string {:name "new" :description "Path to the newer openapi.json, or a git ref with --refs"}]]
|
||||
:options [["-s" "--severity LEVEL" "Only show findings at or above LEVEL: breaking or additive"]
|
||||
["-r" "--refs" "Treat the arguments as git refs and generate each spec from its source"]
|
||||
["-c" "--committed" "With --refs, read each ref's committed spec instead of generating (fast, may be stale)"]
|
||||
["-g" "--grouped" "Collapse identical findings across endpoints: one line per distinct change"]]
|
||||
:requires [[mage.openapi-diff]]
|
||||
:task (task! (mage.openapi-diff/cli-diff parsed))}
|
||||
|
||||
openapi-staleness
|
||||
{:doc "Is the committed OpenAPI spec current relative to the API source? (a stale spec under-reports)"
|
||||
:examples [["./bin/mage openapi-staleness"]]
|
||||
:arg-schema [:tuple]
|
||||
:requires [[mage.openapi-diff]]
|
||||
:task (task! (mage.openapi-diff/cli-staleness parsed))}
|
||||
|
||||
affected-modules
|
||||
{:doc "Which backend modules have changes?"
|
||||
:examples [["./bin/mage affected-modules master" "prints changes compared to `master`"]
|
||||
|
||||
@@ -159,6 +159,7 @@ Metabase's reference documentation.
|
||||
- [AI system prompts](./ai/system-prompts.md)
|
||||
- [Agent API](./ai/agent-api.md)
|
||||
- [MCP server](./ai/mcp.md)
|
||||
- [MCP server tools](./ai/mcp-tools.md)
|
||||
- [Metabot in Slack](./ai/metabot-slack.md)
|
||||
- [AI privacy](./ai/privacy.md)
|
||||
|
||||
|
||||
@@ -113,9 +113,7 @@ Set up Remote Sync in your production Metabase in Read-only mode pointed at the
|
||||
|
||||
### 2. Create a branch from the Metabase UI
|
||||
|
||||
Switch branches in Metabase, as the Metabase UI is the source of truth for which branch the development instance pushes to and pulls from.
|
||||
|
||||
In your development Metabase, click the **branch dropdown** at the top and [create a new branch](../installation-and-operation/remote-sync.md#creating-a-branch) for your work, like `feature/support-dashboard`.
|
||||
In your development Metabase, [create a new branch](../installation-and-operation/remote-sync.md#creating-a-branch) for your work, like `feature/support-dashboard`. Metabase switches to the new branch. Your local Git checkout doesn't change which branch Metabase pushes to and pulls from.
|
||||
|
||||
### 3. Ask the agent to create content
|
||||
|
||||
|
||||
469
docs/ai/mcp-tools.md
Normal file
@@ -0,0 +1,469 @@
|
||||
---
|
||||
title: MCP server tools
|
||||
summary: The tools Metabase's MCP server exposes to AI clients, the permission each one needs, and the arguments each one takes.
|
||||
---
|
||||
|
||||
# MCP server tools
|
||||
|
||||
_This documentation was generated from source by running:_
|
||||
|
||||
```
|
||||
clojure -M:ee:doc mcp-tools-documentation
|
||||
```
|
||||
|
||||
These are the tools an AI client can call once you've [connected it to your Metabase's MCP server](./mcp.md). Every tool runs as you, scoped to your permissions, so a tool can never reach data you couldn't see in Metabase yourself.
|
||||
|
||||
Some clients (like Claude Desktop) ask you to approve or block each tool the first time it's used.
|
||||
|
||||
The argument notes here are exactly what your agent sees (which is why they sound robotic). Several tools take or return a `query_handle`. A handle stands for a query that already ran (or was validated), so your agent can visualize or save exactly that query without sending it again. By default, handles expire after 24 hours.
|
||||
|
||||
Your agent will use `execute_query` for anything Metabase's query language can express (counts, sums, grouping, filtering, joins) and `execute_sql` for the rest (window functions, CTEs, engine-specific functions), or when you ask for SQL outright.
|
||||
|
||||
## Alert write
|
||||
|
||||
- Tool name: `alert_write`
|
||||
- Permission scope: `agent:delivery:write` — Set up scheduled delivery of your data to email addresses and Slack channels it chooses
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `active` | boolean | false pauses the alert, true resumes it (resuming needs the agent:query:run scope). Defaults to true on create. Alerts have no archived state and cannot be deleted here. |
|
||||
| `card_id` | integer or string | Numeric id of the saved question the alert runs. Fixed at creation. 21-character entity_id of the saved question the alert runs. Fixed at creation. |
|
||||
| `channel` | string | One of: `email`, `slack`. Where to deliver: "email" (default) with `recipients`, or "slack" with `slack_channel`. Passing any of channel, slack_channel, or recipients on update replaces the alert's delivery; omit all three to leave it alone. |
|
||||
| `condition` | object | When the alert sends. Defaults to sending whenever the question returns rows; the goal conditions need a goal line on the question's chart. |
|
||||
| `id` | integer or string | Numeric id of the alert to update. The same numeric id as a string. Alerts have no entity_id. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new alert (requires `card_id` and `schedule`); "update" edits the one named by `id`, changing only the fields you pass. |
|
||||
| `recipients` | array | Who gets the email: numeric user ids, or email addresses. Defaults to you. On update this replaces the current list. Not used for channel "slack". |
|
||||
| `schedule` | object | When the question runs, in the instance's report time zone. Required on create; never a cron string. |
|
||||
| `slack_channel` | string | Slack channel name to post to, e.g. "#data-team". Required for channel "slack". |
|
||||
|
||||
## Bookmark content
|
||||
|
||||
- Tool name: `bookmark_content`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Creates or changes content.
|
||||
- Running it again with the same arguments has the same effect as running it once.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `bookmarked` | boolean | true bookmarks the item, false removes the bookmark. |
|
||||
| `id` | integer or string | Numeric id. A 21-character entity_id. |
|
||||
| `type` | string | One of: `collection`, `dashboard`, `document`, `metric`, `model`, `question`. The content type, as returned by search/browse_collection. |
|
||||
|
||||
## Browse collection
|
||||
|
||||
- Tool name: `browse_collection`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `created_by` | string | One of: `me`. items mode: me restricts results to items the current user created (questions, models, metrics, dashboards, documents — other types return nothing under this filter). Composes with type. |
|
||||
| `depth` | integer | tree mode: subcollection levels to expand (default 2). Deeper or trimmed nodes carry a truncation marker naming the re-rooting call. |
|
||||
| `fields` | array of string | items mode: dot-paths picked from the detailed row shape, item-relative (e.g. "last-edit-info.email"). Mutually exclusive with response_format. |
|
||||
| `id` | integer or string | Numeric collection id. A 21-character entity_id, "root" (the per-namespace root), or "trash" (items mode only — archived content, making restore discoverable). |
|
||||
| `limit` | integer | items mode: maximum rows to return (default 50). |
|
||||
| `mode` | string | One of: `items`, `tree`. items (default) lists the collection's contents; tree returns the nested subcollection structure (collections only, no items, no pagination). |
|
||||
| `namespace` | string | One of: `content`, `snippets`, `transforms`, `analytics`. Which collection partition to browse; only meaningful with id: "root" (a real collection id already carries its namespace). content (default) holds questions/dashboards/etc.; snippets holds snippet folders and snippets; transforms holds transform folders and transforms; analytics is the read-only usage-analytics tree. |
|
||||
| `offset` | integer | items mode: rows to skip, for paging. |
|
||||
| `pinned_state` | string | One of: `all`, `is_pinned`, `is_not_pinned`. items mode: all (default) interleaves pinned and unpinned rows. Pinned-first as in the product is two calls — is_pinned, then is_not_pinned — each paging independently. |
|
||||
| `response_format` | string | One of: `concise`, `detailed`. items mode: concise (default) returns {id, name, model, description, collection_position} rows; detailed adds entity_id, collection_id, archived, location, and last-edit info. |
|
||||
| `sort_column` | string | One of: `name`, `last_edited_at`, `model`. items mode: sort key (default name). |
|
||||
| `sort_direction` | string | One of: `asc`, `desc`. items mode: sort direction (default asc). |
|
||||
| `type` | array of string | One of: `question`, `model`, `metric`, `dashboard`, `collection`, `document`. items mode, content namespace only: return only these item types. |
|
||||
|
||||
## Browse data
|
||||
|
||||
- Tool name: `browse_data`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `action` | string | One of: `list_databases`, `list_schemas`, `list_tables`, `list_models`, `get_fields`. What to browse. list_databases → list_schemas → list_tables → get_fields walks the hierarchy; list_models lists the models built on a database. |
|
||||
| `database_id` | integer | Numeric database id (databases have no entity_id). Required for list_schemas, list_tables, and list_models. Ignored by get_fields. |
|
||||
| `fields` | array of string | Dot-paths picked from the detailed row shape, item-relative (e.g. "fields.name"). Mutually exclusive with response_format. Not supported for list_schemas. |
|
||||
| `include_hidden` | boolean | Include hidden schemas/tables (list_schemas, list_tables) or hidden fields (get_fields). Sensitive fields are always excluded. Default false. |
|
||||
| `limit` | integer | list_* actions: maximum rows to return (default 50). |
|
||||
| `offset` | integer | list_* actions: rows to skip, for paging. For get_fields it pages the fields of a single oversized table, as directed by the continuation message. |
|
||||
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns the essential columns; detailed adds the full projection (for get_fields: effective_type, coercion_strategy, database_type, fingerprint, has_field_values, and inline values for list-type fields). Values and fingerprints are permission-dependent, and absent rather than empty when withheld: values need permission to query the table, not just to read its metadata, and fingerprints are withheld entirely when your row access to the table is narrowed. |
|
||||
| `schema` | string | list_tables only: the schema to list. Omit (or pass "") for databases without schemas. |
|
||||
| `search` | string | list_tables only: case-insensitive substring filter on table name or display name, applied before paging. |
|
||||
| `table_ids` | array of integer | get_fields only: numeric table ids (tables have no entity_id), at most 20 per call. |
|
||||
|
||||
## Collection write
|
||||
|
||||
- Tool name: `collection_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves the collection and its contents to the trash, false restores them. Archiving is the only removal path — there is no hard delete. Omit to leave the collection's trashed state alone. |
|
||||
| `authority_level` | string | One of: `official`. Marks the collection Official. Requires an admin on an instance with the Official Collections feature. To make a collection unofficial again, name it in `clear` — `clear: ["authority_level"]`. |
|
||||
| `clear` | array of string | One of: `description`, `authority_level`. Update only: property names to unset (description, authority_level). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `description` | string | Optional human-readable description. To remove one, name it in `clear` — `clear: ["description"]`. |
|
||||
| `id` | integer or string | Numeric id of the collection to update. 21-character entity_id of the collection to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new collection (requires `name`); "update" edits the one named by `id`. |
|
||||
| `name` | string | Required on create; editable on update: display name of the collection. |
|
||||
| `namespace` | string | One of: `snippets`, `transforms`. Create only: puts the collection in a separate hierarchy instead of the normal one. "snippets" holds SQL snippet folders; "transforms" holds transform folders, the ones transform_write's `collection_id` names. Omit for a normal collection. |
|
||||
| `parent_id` | integer or string | The collection to nest under (create) or move into (update). Numeric id, 21-character entity_id, or "root" for the top level. Omitted on create means your personal collection. You need write access to the parent. |
|
||||
|
||||
## Dashboard write
|
||||
|
||||
- Tool name: `dashboard_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
|
||||
| `auto_apply_filters` | boolean | When false, filter changes wait for the viewer to press Apply. Default true. |
|
||||
| `cache_ttl` | integer | Cache lifetime for this dashboard's results, in hours. |
|
||||
| `clear` | array of string | One of: `description`, `collection_position`, `cache_ttl`. Update only: property names to unset (description, collection_position, cache_ttl). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the collection to put it in. Omit on create for your personal collection. Collection entity_id, or "root" for the top-level collection. |
|
||||
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
|
||||
| `description` | string | One or two sentences on what the dashboard answers. |
|
||||
| `id` | integer or string | Numeric id of the dashboard to update. 21-character entity_id of the dashboard to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new dashboard (requires `name`); "update" edits the one named by `id`. |
|
||||
| `name` | string | Dashboard title. |
|
||||
| `ops` | array of object | Editor operations, applied in order. One atomic save on update; on create the dashboard row is written before the ops are applied. |
|
||||
| `validate_only` | boolean | Dry run: returns the layout the ops would produce, writing nothing. |
|
||||
| `width` | string | One of: `fixed`, `full`. "fixed" (default) centers the grid; "full" stretches it to the browser width. |
|
||||
|
||||
## Document write
|
||||
|
||||
- Tool name: `document_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. |
|
||||
| `clear` | array of string | One of: `collection_position`. Update only: property names to unset (collection_position). A null cannot say this — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the collection to put it in. Omit on create for your personal collection. Collection entity_id, or "root" for the top-level collection. |
|
||||
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
|
||||
| `content_markdown` | string | The full body in Metabase-flavored Markdown: CommonMark plus card embeds, entity links, and ::: layout containers (learn("documents")). Required on create. On update it is a deliberate full-body rewrite that orphans every comment thread anchored to the body; pass `edits` to change text in place instead. |
|
||||
| `edits` | array of object | Update only: surgical text edits, each {old_str, new_str, replace_all?}, applied in order against the current server-side Markdown. Exactly one of `edits` or `content_markdown`. An empty list changes only name, collection_id, collection_position, or archived without touching the body. |
|
||||
| `id` | integer or string | Numeric id of the document to update. 21-character entity_id of the document to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new document (requires `name` and `content_markdown`); "update" edits the one named by `id`. |
|
||||
| `name` | string | Document title. Required on create; on update, renames it. |
|
||||
|
||||
## Duplicate content
|
||||
|
||||
- Tool name: `duplicate_content`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `collection_id` | integer or string | Collection to copy into. Omit to copy into your personal collection; pass "root" for the root collection. A 21-character collection entity_id, or "root". |
|
||||
| `id` | integer or string | Numeric id of the item to copy. A 21-character entity_id of the item to copy. |
|
||||
| `is_deep_copy` | boolean | Dashboards only: also copy the dashboard's questions into the destination collection, instead of pointing the copy at the originals. |
|
||||
| `new_name` | string | Name for the copy. Defaults to "Copy of <source name>". |
|
||||
| `type` | string | One of: `question`, `dashboard`, `document`. The kind of content to copy. Card flavors other than question (model, metric) aren't supported yet. |
|
||||
|
||||
## Execute query
|
||||
|
||||
- Tool name: `execute_query`
|
||||
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `cursor` | string | The next_cursor from a previous truncated response — fetches the next page. Exactly one of query \| query_handle \| cursor, but row_limit may accompany it and sets this page's size. |
|
||||
| `prompt` | string | The user's original request, stored with the minted query_handle and carried along its cursor pages. |
|
||||
| `query` | object | A fresh structured query (shape and examples in the tool description): numeric table/field ids from browse_data, never base64, never SQL. Exactly one of query \| query_handle \| cursor. |
|
||||
| `query_handle` | string | A query_handle from a previous call — re-validates and re-runs the exact stored query. Exactly one of query \| query_handle \| cursor. |
|
||||
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000) — the page size, not a bound on the result; the bound is limit: N in the query's stage. |
|
||||
| `validate_only` | boolean | true validates against schema + database metadata and mints a query_handle without executing (default false). |
|
||||
|
||||
## Execute SQL
|
||||
|
||||
- Tool name: `execute_sql`
|
||||
- Permission scope: `agent:sql:run` — Write and run its own raw SQL on your connected databases
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `database_id` | integer | Numeric id of the database to run the SQL against. Requires native-query permission on it. |
|
||||
| `prompt` | string | The user's original request, stored with the minted query_handle and carried along its cursor pages. |
|
||||
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000). |
|
||||
| `sql` | string | The raw SQL text, run verbatim against the database. Put caller-supplied values behind {% raw %}{{tag}}{% endraw %} placeholders bound via template_tag_values — never splice them into this string. |
|
||||
| `template_tag_values` | object | Values for the {% raw %}{{tag}}{% endraw %} placeholders in sql, keyed by tag name. Each value binds as a driver-level prepared-statement parameter (injection-safe): strings bind as text, numbers as numbers. Snippet ({% raw %}{{snippet: …}}{% endraw %}) and card-reference ({% raw %}{{#123}}{% endraw %}) tags cannot be populated here. |
|
||||
| `validate_only` | boolean | true mints a query_handle without executing — template tags and permissions are checked, the SQL text itself is not (default false). |
|
||||
|
||||
## Get content
|
||||
|
||||
- Tool name: `get_content`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `include` | array of string | One of: `definition`, `fields`, `visualization_settings`, `parameters`, `layout`, `dimensions`, `comments`. Extra sections, each applied to every item whose type supports it and ignored for the rest — so a mixed-type batch can ask for several at once: definition (query-bearing types, returned as the stored query — numeric ids, the shape execute_query and question_write accept back verbatim), fields (question/model column metadata), visualization_settings (question/model stored chart settings, the shape question_write takes back; {} when nothing is stored), parameters (dashboard's full parameter array), layout (dashboard grid + tabs, document block outline), dimensions (metric/measure), comments (document comment threads, each anchored into the returned content_markdown by {start, end, text} character offsets — the exact slice of the block the thread is attached to; comments attach to whole blocks, a block nested inside a list/blockquote anchors to the span of the nearest enclosing block that has one, and an empty block gives start == end; threads whose block no longer exists come back under orphaned_comments so they can be re-anchored by editing the right block, and if the document read fell back to flattened text no thread carries an anchor). A section no item in the batch supports is an error. |
|
||||
| `items` | array of object | The content to fetch — up to 10 items, mixed types allowed (e.g. a dashboard and its questions in one call). |
|
||||
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns each type's essential shape; detailed adds entity_id, creator, timestamps, and other secondary columns. |
|
||||
|
||||
## Get parameter values
|
||||
|
||||
- Tool name: `get_parameter_values`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| -------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `constraints` | object | Chain filtering: the current selections of the dashboard's OTHER filters, keyed by their parameter ids, narrowing this filter to the values still valid alongside them. Dashboards only. |
|
||||
| `id` | integer or string | Numeric id. A 21-character entity_id. |
|
||||
| `limit` | integer | Maximum values to return in this call (default 100, max 1000). |
|
||||
| `offset` | integer | Index of the first value to return (default 0) — continue a truncated response. Each page refetches from the source, which returns at most 1000 values, so narrow with `query` rather than paging to reach anything past that. |
|
||||
| `parameter_id` | string | The parameter's id, as returned by get_content under `parameters` — not its name or slug. |
|
||||
| `query` | string | Return only values matching this search string. Use it to narrow a large value list. |
|
||||
| `target` | string | One of: `dashboard`, `question`. Whether id names a dashboard or a card. "question" covers any card — question, model, or metric. |
|
||||
|
||||
## Glossary
|
||||
|
||||
- Tool name: `glossary`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| -------- | ------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `limit` | integer | Maximum entries to return (default 50, max 500). Ignored with "term", which is a lookup, not a page. |
|
||||
| `offset` | integer | Number of entries to skip, for paging (default 0). Ignored with "term". |
|
||||
| `term` | string | The term to define, matched case-insensitively. Omit to list terms with their definitions. |
|
||||
|
||||
## Learn
|
||||
|
||||
- Tool name: `learn`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------- | ------ | -------------------------------------------------------------------------- |
|
||||
| `reference` | string | A reference file of `topic`, by the name the skill (or the catalog) lists. |
|
||||
| `topic` | string | A topic from the catalog. Omit to list all topics. |
|
||||
|
||||
## Measure write
|
||||
|
||||
- Tool name: `measure_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
|
||||
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `definition` | object or array | Either (a) the aggregation clause — what get_content's "definition" include returns for a measure (one-element array or bare clause) and what execute_query takes in stages[0].aggregation — reassembled onto `table_id`; or (b) a full single-stage query ("lib/type": "mbql/query") holding exactly one aggregation. No filters, breakouts, joins, expressions, or limits. May reference other measures but not metrics; cycles are rejected. |
|
||||
| `description` | string | Optional human-readable description. |
|
||||
| `id` | integer or string | Numeric id of the measure to update. 21-character entity_id of the measure to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new measure (requires `table_id`, `name`, `definition`); "update" edits the one named by `id` (requires `revision_message`). |
|
||||
| `name` | string | Create only (editable on update): display name of the measure. |
|
||||
| `revision_message` | string | Update only, required: a short sentence describing the change, recorded in the revision history. |
|
||||
| `table_id` | integer | Create only: numeric table id (tables have no entity_ids). A bare-clause `definition` is reassembled into a query on this table; a full-query one must name this same source table — a mismatch is a teaching error, not silently reconciled. |
|
||||
|
||||
## Metric write
|
||||
|
||||
- Tool name: `metric_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `archived` | boolean | Update only: true moves the metric to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
|
||||
| `clear` | array of string | One of: `description`, `collection_position`. Update only: property names to unset (description, collection_position). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the collection to save the metric in. 21-character entity_id of the collection, or "root" for the root collection. |
|
||||
| `collection_position` | integer | Pins the metric at this position in its collection. |
|
||||
| `definition` | object | The metric's query: a full single-stage query holding exactly one aggregation and at most one breakout. Accepts the same numeric-id shape get_content's "definition" include returns for a metric and execute_query takes. Pass this or query_handle, not both. |
|
||||
| `description` | string | Optional human-readable description. |
|
||||
| `display` | string | One of: `table`, `bar`, `line`, `pie`, `scatter`, `area`, `row`, `combo`, `pivot`, `scalar`, `smartscalar`, `gauge`, `progress`, `funnel`, `map`, `waterfall`, `sankey`. How the metric's result is visualized. Defaults to "scalar" — right for a single number, wrong for a metric with a grouping, which usually wants "line" (a grouping over time) or "bar" (a grouping over categories). Editable on update. |
|
||||
| `id` | integer or string | Numeric id of the metric to update. 21-character entity_id of the metric to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new metric (requires `name` and one query source); "update" edits the one named by `id`. |
|
||||
| `name` | string | Create only (editable on update): display name of the metric. |
|
||||
| `query_handle` | string | A query_handle from execute_query — saves exactly the query that ran. Pass this or definition, not both. |
|
||||
|
||||
## Question write
|
||||
|
||||
- Tool name: `question_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. |
|
||||
| `cache_ttl` | integer | Legacy per-question cache TTL. Stored and echoed back, but no longer read: caching is configured through cache policies in admin settings. |
|
||||
| `card_type` | string | One of: `question`, `model`. "question" (default) or "model". |
|
||||
| `clear` | array of string | One of: `description`, `collection_position`, `cache_ttl`. Update only: property names to unset (description, collection_position, cache_ttl). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the collection to save it in. Omit on create for your personal collection. Exclusive with `dashboard_id`. Collection entity_id, or "root" for the top-level collection. |
|
||||
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
|
||||
| `column_metadata` | array of object | Per-column result metadata to set, matched to the query's result columns by name. Typically used with card_type "model". |
|
||||
| `dashboard_id` | integer or string | Numeric id of a dashboard to save the question inside; it inherits that dashboard's collection. On update, moves the card into that dashboard. Exclusive with `collection_id`. 21-character entity_id of the dashboard to save the question inside. |
|
||||
| `description` | string | One or two sentences on what the question answers. |
|
||||
| `display` | string | One of: `table`, `list`, `bar`, `line`, `pie`, `scatter`, `area`, `row`, `combo`, `pivot`, `scalar`, `smartscalar`, `gauge`, `progress`, `funnel`, `map`, `waterfall`, `sankey`. Visualization type. learn("visualization-settings") covers the choice. |
|
||||
| `id` | integer or string | Numeric id of the question to update. 21-character entity_id of the question to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new question or model (requires `name` and exactly one of `query_handle`, `query`, or `native`); "update" edits the one named by `id`. |
|
||||
| `name` | string | Question title. Required on create. |
|
||||
| `native` | object | A native SQL query to save: {database_id, sql, template_tags?}. Requires the agent:sql:run scope and the mcp-execute-sql-enabled setting. Call learn("native-parameters") before first passing template_tags. |
|
||||
| `query` | object | An inline query with numeric ids and a top-level database id (learn("query-dialect")). Prefer `query_handle`. |
|
||||
| `query_handle` | string | A handle returned by execute_query, execute_sql, or visualize_query. The preferred query source on create: it saves exactly the query that tool validated. |
|
||||
| `visualization_settings` | object | Display settings for the chosen `display`; learn("visualization-settings") lists the keys. |
|
||||
|
||||
## Render drill through
|
||||
|
||||
- Tool name: `render_drill_through`
|
||||
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
|
||||
- Read-only.
|
||||
- Interactive: renders a chart inline in your AI client. Only available in clients that support inline visualizations.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| -------------- | ------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `query_handle` | string | The handle UUID from the user's drill-through message. Pass it through verbatim — do not run the query yourself. |
|
||||
|
||||
## Run saved question
|
||||
|
||||
- Tool name: `run_saved_question`
|
||||
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | integer or string | Numeric card id. A 21-character entity_id. |
|
||||
| `parameters` | array of object | Parameter values to apply, each {id, value} where id is the parameter's id or slug. The card's stored target and type always apply. Discover a card's parameters with get_content. |
|
||||
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000). |
|
||||
|
||||
## Search
|
||||
|
||||
- Tool name: `search`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | true searches the trash instead of active content. |
|
||||
| `collection_id` | integer or string | Numeric collection id. A 21-character entity_id, or "root" for no scoping. |
|
||||
| `created_by` | string | One of: `me`. "me" restricts results to items you created. Only question, model, metric, dashboard, document, measure, and action index a creator. |
|
||||
| `fields` | array of string | Dot-paths picked from the detailed row shape, item-relative (e.g. "collection.name"). Mutually exclusive with response_format. |
|
||||
| `limit` | integer | Maximum results to return (default 20, max 50). |
|
||||
| `offset` | integer | Number of results to skip, for paging (default 0). |
|
||||
| `recent` | boolean | true returns your recently viewed items instead of searching. Combines with type (only question, model, metric, dashboard, document, collection, table are tracked) but not with queries or other filters. Reflects Metabase UI views only — content you read through these tools is not recorded as a view, so a fresh token's recents can be empty even after get_content calls in the same session. |
|
||||
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns {type, id, name, collection_path, description} rows; detailed returns the full search-result rows. |
|
||||
| `semantic_queries` | array of string | A natural-language query matched by semantic similarity when a semantic engine is active (keyword-ranked otherwise). Each query runs separately; results are merged by rank. |
|
||||
| `term_queries` | array of string | A keyword query matched against names and descriptions via full-text search. Each query runs separately; results are merged by rank. |
|
||||
| `type` | array of string | One of: `question`, `model`, `metric`, `measure`, `segment`, `dashboard`, `document`, `collection`, `table`, `database`, `snippet`, `transform`, `action`. Restrict results to these entity types. "snippet" is served by a separate listing (snippets aren't in the search index), requires the agent:content:read scope, and must be requested on its own — combining it with other types is an error. Omit to search every type this tool supports except snippets. |
|
||||
|
||||
## Segment write
|
||||
|
||||
- Tool name: `segment_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
|
||||
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `definition` | object or array | Either (a) an array of filter clauses — what get_content's "definition" include returns for a segment and what execute_query takes in stages[0].filters — reassembled onto `table_id`; or (b) a full single-stage query. Filters only: no aggregations, breakouts, joins, expressions, or limits. May reference other segments; cycles are rejected. |
|
||||
| `description` | string | Optional human-readable description. |
|
||||
| `id` | integer or string | Numeric id of the segment to update. 21-character entity_id of the segment to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new segment (requires `table_id`, `name`, `definition`); "update" edits the one named by `id` (requires `revision_message`). |
|
||||
| `name` | string | Create only (editable on update): display name of the segment. |
|
||||
| `revision_message` | string | Update only, required: a short sentence describing the change, recorded in the revision history. |
|
||||
| `table_id` | integer | Create only: numeric table id (tables have no entity_ids). A bare-clause `definition` is reassembled into a query on this table; a full-query one must name this same source table — a mismatch is a teaching error, not silently reconciled. |
|
||||
|
||||
## Subscription write
|
||||
|
||||
- Tool name: `subscription_write`
|
||||
- Permission scope: `agent:delivery:write` — Set up scheduled delivery of your data to email addresses and Slack channels it chooses
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true pauses the subscription and moves it to the trash, false restores it. This is the only removal path. |
|
||||
| `channel` | string | One of: `email`, `slack`. Where to deliver. Defaults to "email" on create; on update, to the subscription's channel when it has exactly one. |
|
||||
| `dashboard_id` | integer or string | Numeric id of the dashboard to deliver. 21-character entity_id of the dashboard to deliver. |
|
||||
| `id` | integer or string | Numeric id of the subscription to update. 21-character entity_id of the subscription to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" subscribes to the dashboard named by `dashboard_id`; "update" edits the subscription named by `id`. |
|
||||
| `parameters` | array of object | Filter values applied to the dashboard before it is sent, making a filtered subscription. Only ids the dashboard actually has are accepted. |
|
||||
| `recipients` | array | Who gets the email: numeric user ids, or raw email addresses for people without a Metabase account. Defaults to you. On update this replaces the current list. Numeric id of a Metabase user. An email address. |
|
||||
| `schedule` | object | When to deliver. Metabase sends at the top of the hour, in the instance's report time zone. |
|
||||
| `skip_if_empty` | boolean | When true, no email is sent if every card comes back empty. Default false. |
|
||||
| `slack_channel` | string | Slack channel or username to post to, e.g. "data-team". Required for channel "slack". |
|
||||
|
||||
## Transform write
|
||||
|
||||
- Tool name: `transform_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the transform folder to file the transform in. 21-character entity_id of the transform folder, or "root" for the top level of the transforms tree. |
|
||||
| `definition` | object | The transform's source: {"type": "query", "query": …}, the shape get_content's "definition" include returns for a transform. The query inside is the same numeric-id dialect execute_query takes, and may be native SQL. Pass this or query_handle, not both. |
|
||||
| `description` | string | Optional human-readable description. |
|
||||
| `id` | integer or string | Numeric id of the transform to update. 21-character entity_id of the transform to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new transform (requires `name`, `target`, and one query source); "update" edits the one named by `id`. |
|
||||
| `name` | string | Create only (editable on update): display name of the transform. |
|
||||
| `query_handle` | string | A query_handle from execute_query or execute_sql — saves exactly the query that ran. Pass this or definition, not both. |
|
||||
| `tag_ids` | array of integer | Numeric ids of transform tags to label the transform with; jobs select transforms by tag. Replaces the current list — pass [] to clear it. |
|
||||
| `target` | object | The table the transform writes, recreated on every run. On update this patches the current target, so passing only `name` renames the table and keeps its schema. A target read back from get_content can be passed back unchanged. |
|
||||
|
||||
## Visualize query
|
||||
|
||||
- Tool name: `visualize_query`
|
||||
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
|
||||
- Read-only.
|
||||
- Interactive: renders a chart inline in your AI client. Only available in clients that support inline visualizations.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `prompt` | string | The user's original request, recorded with a freshly minted handle for the iframe's feedback flow. Ignored alongside `query_handle`: the stored prompt is fixed at mint time and there is no update path, so pass `prompt` on the call that mints the handle. |
|
||||
| `query` | object | A fresh query in the same dialect execute_query takes: numeric table/field ids from browse_data, never base64. Exactly one of query \| query_handle. |
|
||||
| `query_handle` | string | A query_handle from a previous execute_query / execute_sql call — visualizes the exact stored query, MBQL or native SQL. Preferred over query. Exactly one of query \| query_handle. |
|
||||
121
docs/ai/mcp.md
@@ -13,17 +13,19 @@ Metabase includes an [MCP (Model Context Protocol)](https://modelcontextprotocol
|
||||
|
||||
Your Metabase's MCP server is served from the `/api/metabase-mcp` endpoint.
|
||||
|
||||
If your admin has turned on [your Metabase's MCP server](#enable-mcp-server), all you need to do is point your MCP client at your Metabase's MCP server's URL.
|
||||
Once an admin has [turned on the MCP server](#turn-on-the-mcp-server), point your MCP client at:
|
||||
|
||||
```
|
||||
https://{your-metabase.example.com}/api/metabase-mcp
|
||||
```
|
||||
|
||||
Replace `{your-metabase.example.com}` with your Metabase's URL. Admins can also find your instance's MCP URL in **Admin > AI > MCP > Settings > MCP server URL**.
|
||||
Replace `{your-metabase.example.com}` with your Metabase's URL. Admins can also copy the URL from **Admin > AI > MCP > Settings > MCP server URL**.
|
||||
|
||||
Your client will direct you to an authentication page for your Metabase.
|
||||
|
||||
Once authenticated, you can approve or block the tools you want your Agent to have access to in your client (not in your Metabase).
|
||||
Once authenticated, you can approve or block the tools in your client (not in your Metabase).
|
||||
|
||||
Connected a client before Metabase 0.64? It'll ask you to sign in again after you upgrade.
|
||||
|
||||
### Connecting Claude Code to your Metabase's MCP server
|
||||
|
||||
@@ -45,6 +47,8 @@ Replace `{your-metabase-url}` with your Metabase address.
|
||||
|
||||
5. Click **Authenticate**, and authenticate with your Metabase. (You may first need to **Enable** the Metabase MCP server).
|
||||
|
||||
If Claude asks you to choose authentication options, see [Choose authentication options in Claude](#choose-authentication-options-in-claude).
|
||||
|
||||
Once authenticated, ask your agent about your Metabase. You should see your agent use Metabase tools to interact with your Metabase.
|
||||
|
||||
See [Claude Code MCP docs](https://code.claude.com/docs/en/mcp).
|
||||
@@ -53,97 +57,94 @@ See [Claude Code MCP docs](https://code.claude.com/docs/en/mcp).
|
||||
|
||||
If you use Claude on the web or Claude Desktop, go to [Claude's connector directory](https://claude.ai/directory/connectors/metabase) and enter your [Metabase's MCP URL](#connect-a-client-to-your-metabase-mcp-servers-url).
|
||||
|
||||
One of your Metabase admins will still need to have [turned on your Metabase's MCP server](#enable-mcp-server). You'll authenticate against your own Metabase during setup.
|
||||
One of your Metabase admins will still need to have [turned on your Metabase's MCP server](#turn-on-the-mcp-server). You'll authenticate against your own Metabase during setup. If Claude asks you to choose authentication options, see [Choose authentication options in Claude](#choose-authentication-options-in-claude).
|
||||
|
||||
## Available tools
|
||||
## MCP tools
|
||||
|
||||
Some clients (like Claude Desktop) will ask you to approve each tool the first time it's used. We've grouped the tools below, but your client may simply list the available tools.
|
||||
The MCP server gives your agent tools for searching, browsing, querying, and creating content in your Metabase. For the full list and the permission each one needs, see [MCP server tools](./mcp-tools.md).
|
||||
|
||||
### Interactive tools
|
||||
## Using the MCP server
|
||||
|
||||
These render inline visualizations in your AI client, and only work in clients that support inline charts.
|
||||
The MCP server will return results as either text or an inline chart, depending on the question you asked.
|
||||
|
||||
- **Render drill through** (`render_drill_through`): Render the chart a person drilled into from an inline visualization.
|
||||
- **Visualize query** (`visualize_query`): Render a query's results as an inline chart in your AI client.
|
||||
If you want the MCP server to return an inline chart, ask it to "show" or "visualize" the data:
|
||||
|
||||
### Read-only tools
|
||||

|
||||
|
||||
- **Construct query** (`construct_query`): Construct a query against a table or metric. Returns an opaque query handle that can be passed to `execute_query`.
|
||||
- **Execute query** (`execute_query`): Execute a previously constructed query and return the results with column metadata, row count, and execution time.
|
||||
- **Query tables and metrics** (`query`): Run a query and return results, paging through large result sets with a continuation token. Accepts a query handle from `construct_query`, a fresh query, or a continuation token. Paging returns up to 200 rows per page and 2,000 rows total.
|
||||
- **Read resource** (`read_resource`): Read up to five Metabase entities (or lists) per call by `metabase://` URI. List URIs (like a collection's items or a table's fields) return up to 25 items each. Entities available to read include:
|
||||
- collection
|
||||
- dashboard
|
||||
- database
|
||||
- metric
|
||||
- model
|
||||
- question
|
||||
- schema
|
||||
- table
|
||||
- transform
|
||||
- **Search tables and metrics** (`search`): Find tables and metrics using keyword or natural language search.
|
||||
Metabase picks a chart type based on the shape of the result. You can switch between bar, line, and table (when the data fits), and if the chart has a date axis, change the time range and granularity. You can also drill through the chart: zooming and sorting update the chart in place, and other drills either re-render in place or, in Claude, open the question in your Metabase. Every chart has an **Explore in Metabase** button that opens the question in Metabase.
|
||||
|
||||
### Write/delete tools
|
||||
|
||||
- **Create collection** (`create_collection`): Create a new collection, optionally nested under a parent collection.
|
||||
- **Create dashboard** (`create_dashboard`): Create a new dashboard, optionally populated with saved questions.
|
||||
- **Create question** (`create_question`): Save a query as a named question.
|
||||
- **Execute SQL** (`execute_sql`): Execute a raw SQL query against a database. Requires native-query permission on the target database. An admin can disable this tool instance-wide via the `mcp-execute-sql-enabled` setting (enabled by default).
|
||||
- **Update dashboard** (`update_dashboard`): Update a dashboard's metadata (name, description, collection, archived).
|
||||
- **Update question** (`update_question`): Update a saved question. Setting `collection_id` moves the question to another collection.
|
||||
Clients that can't render inline charts (like Claude Code) won't list the `visualize_query` tool at all. That's expected, not a sign of a broken connection.
|
||||
|
||||
## MCP server settings
|
||||
|
||||
_Admin > AI > MCP_
|
||||
|
||||
### Enable MCP server
|
||||
### Turn on the MCP server
|
||||
|
||||
From **Admin > AI**, open the **MCP** tab in the left sidebar, and use the **MCP server** toggle to turn external access to the MCP server on or off.
|
||||
From **Admin > AI**, open the **MCP** tab in the left sidebar, and use the **MCP server** toggle to turn the MCP server on or off.
|
||||
|
||||
The MCP server also requires that [AI features](./overview.md) are enabled for your instance. You don't need to configure an AI provider to use the MCP server, but if **AI features** are turned off in **Admin > AI**, the MCP server stays off too.
|
||||
The MCP server also needs [AI features](./overview.md) on. If **Disable all AI features** is on in **Admin > AI**, the MCP server stays off and the **MCP** settings are grayed out.
|
||||
|
||||
You don't need an [AI provider](settings.md#choose-ai-provider) set up in Metabase to use the MCP server. Your client brings the model; Metabase just brings the tools. So MCP calls don't count against your Metabase's AI connection, even if you've set up a provider for Metabot. Results from the MCP server go to your client, which may forward them to an AI provider depending on how the client is set up. See [AI privacy](./privacy.md).
|
||||
|
||||
### Show inline charts in these MCP clients
|
||||
|
||||
These toggles control which browser-based clients can display [inline charts](#using-the-mcp-server) generated by Metabase. Switch on any of the supported clients:
|
||||
|
||||
- **Claude** (Claude Desktop and Claude on the web)
|
||||
- **Claude** (Claude Desktop and Claude on the web). In Claude Desktop, use Chat, _not_ Cowork: inline charts don't work in Cowork.
|
||||
- **Cursor and VS Code**
|
||||
- **ChatGPT**
|
||||
|
||||
Toggling on a client automatically adds that client's sandbox domains to Metabase's CORS allowlist, which is what lets the client render Metabase's charts in its browser sandbox.
|
||||
Turning on a client adds its domains to Metabase's CORS allowlist, so the client's browser sandbox can load Metabase's charts.
|
||||
|
||||
These toggles only control inline charts; they don't gate whether a client can connect. Any MCP client can connect to your MCP server (subject to [authentication](#authentication)), and clients that run outside the browser (like Claude Code on your own machine) don't need a CORS allowlist entry at all.
|
||||
These toggles only control inline charts; they don't gate whether a client can connect. Any MCP client can connect (subject to [authentication](#authentication)), and clients that run outside the browser (like Claude Code on your own machine) don't need an allowlist entry at all.
|
||||
|
||||
### Custom inline chart origins
|
||||
### Allowed origins for custom MCP clients
|
||||
|
||||
The **Custom inline chart origins** field is for browser-based MCP clients that render [inline charts](#using-the-mcp-server) but aren't in the supported list (like a self-hosted client).
|
||||
Use the **Allowed origins for custom MCP clients** field for browser-based clients that render [inline charts](#using-the-mcp-server) but aren't in the list above (like a self-hosted client). It adds the origin to the same allowlist as the toggles.
|
||||
|
||||

|
||||
|
||||
Adding an origin here puts it on Metabase's CORS allowlist, just like toggling on one of the supported clients. Clients that run outside the browser (like Claude Code on your own machine) don't need an entry here.
|
||||
|
||||
Add the client's origin to the field. Separate values with a space, for example:
|
||||
Add the client's origin, separating multiple origins with spaces:
|
||||
|
||||
```
|
||||
https://mcp.internal.example.com https://*.staging.example.com
|
||||
```
|
||||
|
||||
The field accepts wildcards (`*`) for subdomains. Changes take effect in about a minute. Might be a good time to get up and pour yourself a glass of water.
|
||||
Wildcards (`*`) work for subdomains. Origins can't include a path. Changes take effect within a minute.
|
||||
|
||||
## Authentication
|
||||
|
||||
MCP clients authenticate with Metabase using OAuth 2.0. Metabase runs its own embedded OAuth server, so you don't need to set up an external OAuth provider.
|
||||
|
||||
Your MCP client should try to connect to your Metabase. You'll see a Metabase-branded consent page asking you to approve the connection to your Metabase.
|
||||
|
||||
A first-time connection will go something like this:
|
||||
|
||||
1. The client discovers Metabase's OAuth endpoints.
|
||||
2. The client registers itself with Metabase.
|
||||
3. You're redirected to Metabase to log in (if you aren't already) and approve the connection.
|
||||
3. You're redirected to a Metabase consent page to log in (if you aren't already) and approve the connection.
|
||||
4. The client receives an access token scoped to the permissions you have in Metabase.
|
||||
|
||||
Results returned by the MCP server are sent to your MCP client, which may forward them to an AI provider depending on how the client is configured. See [AI privacy](./privacy.md).
|
||||
### Choose authentication options in Claude
|
||||
|
||||
If Claude asks you to choose authentication options when you add the Metabase connector, keep the detected defaults:
|
||||
|
||||
- **Authentication**: **Sign in now**. None of the tools work without an account, so **Sign in when needed** just delays the same sign-in, and **No sign-in** won't connect.
|
||||
- **OAuth client**: **Register automatically**. Metabase supports dynamic client registration, but not **Use Claude's published identity** or **Use your own OAuth client**.
|
||||
- **Request headers**: leave empty. The OAuth token is all Metabase needs.
|
||||
|
||||
### Permission scopes
|
||||
|
||||
The consent page lists the permission scopes the client is asking for. Each [tool](./mcp-tools.md) needs one of these:
|
||||
|
||||
- `agent:content:read`: see your Metabase content and data structure.
|
||||
- `agent:content:write`: create, edit, and trash Metabase content.
|
||||
- `agent:query:run`: run queries against your connected databases and see the results.
|
||||
- `agent:sql:run`: write and run raw SQL on your connected databases.
|
||||
- `agent:delivery:write`: create alerts and subscriptions that email or Slack your data. Creating one also needs `agent:query:run`, since it runs a query when it fires.
|
||||
|
||||
The consent page may also list `agent:resource:read` (shown as **Read MCP resources**). No tool needs it; it covers the server's own reference material: the list of fields your agent can ask `get_content` to return for each content type.
|
||||
|
||||
Most clients ask for all of them. A scope never grants more than your Metabase permissions allow: `agent:sql:run` won't let a client run SQL against a database you can't write native queries on.
|
||||
|
||||
## Authorization logs
|
||||
|
||||
@@ -159,27 +160,13 @@ Each record includes:
|
||||
|
||||
Use the event filter to narrow the list to a single event type.
|
||||
|
||||
## With the MCP server, your client provides the AI
|
||||
## MCP analytics
|
||||
|
||||
MCP server requests are handled by whatever AI client you're using (like a desktop AI app or editor plugin). The MCP server just provides tools (like searching for an entity or running the query) for your AI.
|
||||
On [Pro and Enterprise plans](https://www.metabase.com/pricing), admins can see how people use the MCP server. Go to **Monitor > AI Auditing > MCP analytics**.
|
||||
|
||||
For example, if you ask your AI client to use your Metabase's MCP server "what's our q3 revenue," your client will interact with the MCP server to figure out which tools it needs to field your request. Your AI can decide that it needs to use the tools **construct_query** and **execute_query**, and what those queries might be. Then your client will call those tools for Metabase to run.
|
||||
The **Usage** tab charts calls over time by client, tool, and user, plus errors by type when there are any. The **Tool calls** tab lists each call with its tool, client, user, status, duration, and error type. Error messages only show up if you've turned on PII retention.
|
||||
|
||||
You don't need to have an [AI provider](settings.md#choose-ai-provider) configured in Metabase to use your Metabase's MCP server. If you _do_ have an AI provider configured in Metabase to power Metabot, that provider will _not_ be used for MCP server requests. MCP calls by your local client have no effect on token usage for your Metabase's AI connection.
|
||||
|
||||
## Using the MCP server
|
||||
|
||||
The MCP server will return results as either text or an inline chart, depending on the question you asked.
|
||||
|
||||
If you want the MCP server to return an inline chart, ask it to "show" or "visualize" the data:
|
||||
|
||||

|
||||
|
||||
Inline charts are limited to bar, line, or table charts (which you can toggle between). You can also drill through the charts or change their time granularity. Depending on which client you're using, drilling through will either let you keep exploring the chart right there in your client, or give you a link to continue your exploration in your Metabase.
|
||||
|
||||
If your client is connected to other MCP servers, you can ask questions that combine data from multiple sources. For example, you can ask a question about your customers that combines data from Metabase, your CRM, and your support ticket platform (Though maybe you should put all that data into your Metabase).
|
||||
|
||||
See [Available tools](#available-tools) for the list of functionality supported by the MCP server.
|
||||
MCP calls don't appear in [AI usage auditing](./usage-auditing.md), since they don't go through Metabot.
|
||||
|
||||
## Use the MCP server with agent-driven development
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ See [full docs for Metabot](metabot.md) and [embedded AI chat](../embedding/ai-c
|
||||
|
||||
Connect your favorite third-party AI tool - like Claude or Codex - to the Metabase MCP server. MCP servers are designed for answering in-the-moment questions like "hey btw what's our q3 revenue?" Metabase's MCP server is also useful when combined with other MCP servers. For example, you can ask Claude a question about your customers that combines data from Metabase, your CRM, and your support ticket platform.
|
||||
|
||||
**Controls**: Metabase MCP server requires people to authenticate into Metabase, and all the responses it provides will be scoped to their permissions. However, unlike built-in Metabot, MCP server doesn't come with granular control over which _tools_ people can use, or disable MCP server altogether.
|
||||
Compared to the built-in Metabot, the MCP server has a narrower set of tools. See [MCP server tools](mcp-tools.md) for what it can do.
|
||||
|
||||
**Controls**: People log in to Metabase to use the MCP server, and everything it returns is scoped to their permissions. Admins can [turn the MCP server off](mcp.md#turn-on-the-mcp-server) for everyone.
|
||||
|
||||
**Provider**: Requests to Metabase's MCP server are handled by the provider you choose to connect to the MCP server (e.g. Claude, Cursor, etc).
|
||||
|
||||
|
||||
@@ -35,9 +35,11 @@ Supported models:
|
||||
| Claude Opus 4.7 | `claude-opus-4-7` | 1,000,000 |
|
||||
| Claude Opus 4.8 | `claude-opus-4-8` | 1,000,000 |
|
||||
| Claude Opus 5 | `claude-opus-5` | 1,000,000 |
|
||||
| Claude Opus 5.5 | `claude-opus-5-5` | 1,000,000 |
|
||||
| Claude Sonnet 4.5 | `claude-sonnet-4-5-20250929` | 200,000 |
|
||||
| Claude Sonnet 4.6 | `claude-sonnet-4-6` | 1,000,000 |
|
||||
| Claude Sonnet 5 | `claude-sonnet-5` | 1,000,000 |
|
||||
| Claude Sonnet 5.5 | `claude-sonnet-5-5` | 1,000,000 |
|
||||
|
||||
Credentials:
|
||||
|
||||
@@ -214,8 +216,10 @@ Supported models:
|
||||
| Claude Haiku 4.5 | `anthropic/claude-haiku-4-5@20251001` |
|
||||
| Claude Opus 4.6 | `anthropic/claude-opus-4-6` |
|
||||
| Claude Opus 5 | `anthropic/claude-opus-5` |
|
||||
| Claude Opus 5.5 | `anthropic/claude-opus-5-5` |
|
||||
| Claude Sonnet 4.6 | `anthropic/claude-sonnet-4-6` |
|
||||
| Claude Sonnet 5 | `anthropic/claude-sonnet-5` |
|
||||
| Claude Sonnet 5.5 | `anthropic/claude-sonnet-5-5` |
|
||||
| Gemini 3.5 Flash | `google/gemini-3.5-flash` |
|
||||
| Gemini 3.6 Flash | `google/gemini-3.6-flash` |
|
||||
| Gemini 3.7 Flash | `google/gemini-3.7-flash` |
|
||||
@@ -267,19 +271,21 @@ Credentials:
|
||||
|
||||
Supported models:
|
||||
|
||||
| Model | Model ID | Context window (tokens) |
|
||||
| -------------------- | ---------------------------- | ----------------------- |
|
||||
| Claude Fable 5 | `anthropic.claude-fable-5` | 1,000,000 |
|
||||
| Claude Haiku 4.5 | `anthropic.claude-haiku-4-5` | 200,000 |
|
||||
| Claude Opus 4.7 | `anthropic.claude-opus-4-7` | 1,000,000 |
|
||||
| Claude Opus 4.8 | `anthropic.claude-opus-4-8` | 1,000,000 |
|
||||
| Claude Opus 5 | `anthropic.claude-opus-5` | 1,000,000 |
|
||||
| Claude Sonnet 5 | `anthropic.claude-sonnet-5` | 1,000,000 |
|
||||
| GPT-5.4 | `openai.gpt-5.4` | 272,000 |
|
||||
| GPT-5.4 (2026-03-05) | `openai.gpt-5.4-2026-03-05` | 272,000 |
|
||||
| GPT-5.5 | `openai.gpt-5.5` | 272,000 |
|
||||
| GPT-5.5 (2026-04-23) | `openai.gpt-5.5-2026-04-23` | 272,000 |
|
||||
| GPT-6 Astra | `openai.gpt-6-astra` | 922,000 |
|
||||
| Model | Model ID | Context window (tokens) |
|
||||
| -------------------- | ----------------------------- | ----------------------- |
|
||||
| Claude Fable 5 | `anthropic.claude-fable-5` | 1,000,000 |
|
||||
| Claude Haiku 4.5 | `anthropic.claude-haiku-4-5` | 200,000 |
|
||||
| Claude Opus 4.7 | `anthropic.claude-opus-4-7` | 1,000,000 |
|
||||
| Claude Opus 4.8 | `anthropic.claude-opus-4-8` | 1,000,000 |
|
||||
| Claude Opus 5 | `anthropic.claude-opus-5` | 1,000,000 |
|
||||
| Claude Opus 5.5 | `anthropic.claude-opus-5-5` | 1,000,000 |
|
||||
| Claude Sonnet 5 | `anthropic.claude-sonnet-5` | 1,000,000 |
|
||||
| Claude Sonnet 5.5 | `anthropic.claude-sonnet-5-5` | 1,000,000 |
|
||||
| GPT-5.4 | `openai.gpt-5.4` | 272,000 |
|
||||
| GPT-5.4 (2026-03-05) | `openai.gpt-5.4-2026-03-05` | 272,000 |
|
||||
| GPT-5.5 | `openai.gpt-5.5` | 272,000 |
|
||||
| GPT-5.5 (2026-04-23) | `openai.gpt-5.5-2026-04-23` | 272,000 |
|
||||
| GPT-6 Astra | `openai.gpt-6-astra` | 922,000 |
|
||||
|
||||
Credentials:
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ You pick which AI providers Metabase can use:
|
||||
- If you're **self-hosting Metabase** and want to use Metabot, you'll need to [connect to an AI provider](#connect-to-an-ai-provider) with your own credentials.
|
||||
- On **Metabase Cloud**, you can [connect to an AI provider](#connect-to-an-ai-provider) with your own credentials, [use the Metabase AI service](#metabase-ai-service), or both.
|
||||
|
||||
The providers you set up in AI settings power Metabase's built-in AI functionality, not the MCP server. With the MCP server, [your client provides the AI](mcp.md#with-the-mcp-server-your-client-provides-the-ai).
|
||||
The providers you set up here power Metabase's built-in AI features. The [MCP server](mcp.md#turn-on-the-mcp-server) doesn't use them; your MCP client brings its own model.
|
||||
|
||||
### Metabase AI service
|
||||
|
||||
@@ -122,7 +122,7 @@ _Admin > AI_
|
||||
|
||||

|
||||
|
||||
The **Metabot settings** card has two tabs — **Internal** and **Embedded** — so you can configure Metabot for your internal Metabase separately from [embedded](../embedding/introduction.md) Metabase contexts. That way you can, for example, use Metabot in your Metabase while not granting access to Metabot in your embedded Metabase. Each tab has its own enable toggle, verified-content setting, allowed collection, and prompt suggestions, all configured independently.
|
||||
The **Metabot settings** card has two tabs, **Internal** and **Embedded**, so you can configure Metabot in different contexts. Each tab has its own enable toggle, verified or curated content setting, allowed collection, and prompt suggestions.
|
||||
|
||||
### Enable Metabot
|
||||
|
||||
@@ -143,13 +143,13 @@ The **Enable Embedded Metabot** toggle turns embedded Metabot on or off. The tog
|
||||
- [Full-app embedding](../embedding/full-app-embedding.md): The Metabot icon and keyboard shortcuts are only available when Metabot is enabled. Turning off Embedded Metabot will hide these icons and disable the keyboard shortcuts.
|
||||
- [Modular embedding](../embedding/modular-embedding.md): The toggle doesn't add Metabot anywhere; you have to explicitly include a chat component (like the SDK's [`MetabotQuestion`](../embedding/ai-chat.md)) in your application. If, however, you've added a component, and you turn off the Embedded Metabot toggle, your chat component will stop working, so you should also remove or hide the component in your application.
|
||||
|
||||
### Verified content
|
||||
### Verified or curated content
|
||||
|
||||
{% include plans-blockquote.html feature="Verified or curated content" %}
|
||||
|
||||
_Available on both the Internal and Embedded tabs, configured independently._
|
||||
|
||||
Admins on Pro and Enterprise plans can tell Metabot to only work with [models](../data-modeling/models/models.md) and [metrics](../data-modeling/semantic-layer/metrics.md) that have been [verified](../exploration-and-organization/content-verification.md).
|
||||
|
||||
Restricting Metabot to verified models and metrics (and only models and metrics) helps Metabot produce more reliable answers, since you know someone has at least vetted the data Metabot can use.
|
||||
When turned on, Metabot only uses content that's [verified](../exploration-and-organization/content-verification.md), in an [official collection](../exploration-and-organization/collections.md#official-collections), or published to the [Library](../data-modeling/semantic-layer/library.md). Published tables only count if their [visibility layer](../data-modeling/metadata/managing-tables.md#visibility-layer) is **Final**.
|
||||
|
||||
### Collection for natural language querying
|
||||
|
||||
@@ -169,7 +169,7 @@ If you're embedding the Metabot component in an app, you can point embedded Meta
|
||||
|
||||
Picking **Our analytics** is the same as picking no collection at all, so pick something narrower if you want the scoping to do anything. And once you set a collection, tables drop out of embedded Metabot's search results, so pick a collection with the metrics and models you want people building on.
|
||||
|
||||
This setting narrows where embedded Metabot searches; it's _not_ a substitute for setting permissions. Embedded Metabot can still read and query anything the person using it has permissions for. Embedded Metabot can also see the items that person viewed recently, whichever collection those live in. Restricting Metabot to [verified content](#verified-content) narrows those recent items to verified, official, and [Library](../data-modeling/semantic-layer/library.md) content, but it doesn't confine them to the collection you picked. To control what data people can get to in an embed, set [data permissions](../permissions/embedding.md). See also [Set up AI chat in Metabase](../embedding/ai-chat.md#set-up-ai-chat-in-metabase).
|
||||
This setting narrows where embedded Metabot searches; it's _not_ a substitute for setting permissions. Embedded Metabot can still read and query anything the person using it has permissions for. Embedded Metabot can also see the items that person viewed recently, whichever collection those live in. Restricting Metabot to [verified or curated content](#verified-or-curated-content) narrows those recent items to verified, official, and [Library](../data-modeling/semantic-layer/library.md) content, but it doesn't confine them to the collection you picked. To control what data people can get to in an embed, set [data permissions](../permissions/embedding.md). See also [Set up AI chat in Metabase](../embedding/ai-chat.md#set-up-ai-chat-in-metabase).
|
||||
|
||||
### Prompt suggestions
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ A REST API for building headless, agentic BI applications on top of Metabase's s
|
||||
|
||||
Connect MCP-compatible AI clients to your Metabase.
|
||||
|
||||
## [MCP server tools](./mcp-tools.md)
|
||||
|
||||
The tools an AI client can call through the MCP server, and the permission each one needs.
|
||||
|
||||
## [Metabot in Slack](./metabot-slack.md)
|
||||
|
||||
Chat with Metabot directly in Slack — ask questions, get charts, and manage subscriptions without leaving Slack.
|
||||
|
||||
6
docs/api.json
generated
@@ -32850,7 +32850,7 @@
|
||||
"post" : {
|
||||
"operationId" : "post-api-apps-slug-query-table-dependencies",
|
||||
"summary" : "POST /api/apps/{slug}/query-table-dependencies",
|
||||
"description" : "Return the tables read by already-saved queries, including ones reached only through an implicit join.\n\n Sync copies models, actions and metrics whose queries it never resolves through `/query`, and only\n this metadata-based lookup sees an implicit join: the id of a table reached through a foreign key\n appears nowhere in the query itself.",
|
||||
"description" : "Return the tables read by already-saved queries, including ones reached only through an implicit join.\n\n Sync copies actions and metrics whose queries it never resolves through `/query`, and only\n this metadata-based lookup sees an implicit join: the id of a table reached through a foreign key\n appears nowhere in the query itself.",
|
||||
"parameters" : [ {
|
||||
"in" : "path",
|
||||
"name" : "slug",
|
||||
@@ -74289,7 +74289,7 @@
|
||||
"description" : "Whether to include the entire metric library."
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "include-models",
|
||||
"name" : "include-actions",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"oneOf" : [ {
|
||||
@@ -74298,7 +74298,7 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"description" : "Include models with actions. Database scope filters models; library scope does not. Without a scope, returns models only."
|
||||
"description" : "Include query actions that belong to no model. Database scope filters them; library scope does not. Without a scope, returns actions only."
|
||||
} ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
|
||||
@@ -244,6 +244,8 @@ config:
|
||||
llm-vllm-api-base-url: null
|
||||
llm-vllm-api-key: null
|
||||
llm-vllm-request-timeout-ms: 300000
|
||||
llm-xai-api-base-url: https://api.x.ai/v1
|
||||
llm-xai-api-key: null
|
||||
llm-zai-api-base-url: https://api.z.ai/api/paas/v4
|
||||
llm-zai-api-key: null
|
||||
loading-message: doing-science
|
||||
|
||||
@@ -1681,6 +1681,26 @@ Backed by the vllm connection in the admin AI settings provider list: reads and
|
||||
|
||||
Socket timeout in milliseconds for requests to your vLLM server.
|
||||
|
||||
### `MB_LLM_XAI_API_BASE_URL`
|
||||
|
||||
- Type: string
|
||||
- Default: `https://api.x.ai/v1`
|
||||
- [Configuration file name](./config-file.md): `llm-xai-api-base-url`
|
||||
|
||||
The xAI API base URL used for Chat Completions.
|
||||
|
||||
Backed by the xai connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection.
|
||||
|
||||
### `MB_LLM_XAI_API_KEY`
|
||||
|
||||
- Type: string
|
||||
- Default: `null`
|
||||
- [Configuration file name](./config-file.md): `llm-xai-api-key`
|
||||
|
||||
The xAI API Key.
|
||||
|
||||
Backed by the xai connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection.
|
||||
|
||||
### `MB_LLM_ZAI_API_BASE_URL`
|
||||
|
||||
- Type: string
|
||||
|
||||
@@ -86,7 +86,7 @@ Once you're done here simply follow the link to **Take me to Metabase**. And if
|
||||
|
||||
If you click **Set up AI**, Metabase walks you through [connecting an AI provider](../ai/settings.md).
|
||||
|
||||
The providers you set up in AI settings power Metabase's built-in AI functionality, not the MCP server. With the MCP server, [your client provides the AI](../ai/mcp.md#with-the-mcp-server-your-client-provides-the-ai).
|
||||
The providers you set up here power Metabase's built-in AI features. The [MCP server](../ai/mcp.md#turn-on-the-mcp-server) doesn't use them; your MCP client brings its own model.
|
||||
|
||||
## Getting started with Metabase
|
||||
|
||||
|
||||
BIN
docs/data-modeling/images/create-index.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/data-modeling/images/index-tab.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/data-modeling/images/lookback-window.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 129 KiB |
@@ -11,7 +11,7 @@ redirect_from:
|
||||
|
||||
_Data Studio > Connected data_
|
||||
|
||||

|
||||

|
||||
|
||||
You can manage table settings and metadata to make it easier for people to work with your data in **Data Studio > Connected data**.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Once you [create the Library](#create-the-library):
|
||||
|
||||
- A **Library** section appears in the navigation sidebar of the main app, listing the tables and metrics in your semantic layer.
|
||||
- The data picker in the query builder defaults to showing tables and metrics from the Library to encourage people to use your vetted content.
|
||||
- If you turn on Metabot's [Verified or curated content](../../ai/settings.md#verified-content) setting, Metabot will only use content that's verified, in an official collection, or published to the Library.
|
||||
- If you turn on Metabot's [Verified or curated content](../../ai/settings.md#verified-or-curated-content) setting, Metabot will only use content that's verified, in an official collection, or published to the Library.
|
||||
- You can [sync the Library to Git](#versioning-the-library) to version your semantic layer and glossary.
|
||||
|
||||
### Semantic layer
|
||||
|
||||
@@ -55,7 +55,7 @@ To create a metric:
|
||||
|
||||
You can also create a new metric by going to **Browse > Metrics** in the navigation sidebar and clicking on **+**.
|
||||
|
||||
If you're creating a [Library metric](./library.md#metrics), you can also [navigate to the Library](./library.md) and click **+ New > Metric**.
|
||||
If you're creating a [Library metric](./library.md#metrics), you can also go to [Data Studio > Semantic layer](./library.md) and click **+ New > Metric**.
|
||||
|
||||
2. Select your starting data. You can start from a table, saved question, model, or another metric.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ _Data Studio > Semantic layer_
|
||||
|
||||
Published tables are the tables you add to your [semantic layer](library.md#semantic-layer). They live in the **Data** collection.
|
||||
|
||||
Published tables also show up in the **Library** section of the main app's navigation sidebar. When people pick data for a new question, Metabase shows published tables first. You can also tell [Metabot](../../ai/metabot.md) to only use [curated content](../../ai/settings.md#verified-content) like published tables, and you can [sync published tables to Git](library.md#versioning-the-library), along with their metadata, segments, and measures.
|
||||
Published tables also show up in the **Library** section of the main app's navigation sidebar. When people pick data for a new question, Metabase shows published tables first. You can also tell [Metabot](../../ai/metabot.md) to only use [curated content](../../ai/settings.md#verified-or-curated-content) like published tables, and you can [sync published tables to Git](library.md#versioning-the-library), along with their metadata, segments, and measures.
|
||||
|
||||
We use the word "publish" because the tables in your Library should be finished, polished tables. If your tables need to be cleaned or combined before they're ready for analytical queries, check out [transforms](../transforms/transforms-overview.md).
|
||||
|
||||
|
||||
@@ -39,11 +39,11 @@ With basic transforms, you can:
|
||||
|
||||
- **Metabase Cloud**: Basic transform functionality on Metabase Cloud - Starter, Pro, or Enterprise - comes with an additional small fee per successful transform run, see [Pricing](https://www.metabase.com/pricing).
|
||||
|
||||
Only people logged in with an email of a [Metabase Store admin](../../cloud/accounts-and-billing.md#add-managers) (not just Metabase _instance_ admins) can [enable basic transforms](./transforms-overview.md#enable-transforms).
|
||||
Only admins can [enable basic transforms](./transforms-overview.md#enable-transforms).
|
||||
|
||||
### Cancel basic transforms
|
||||
|
||||
Once basic transforms are enabled on your Metabase Cloud instance, they can't be disabled.
|
||||
Once basic transforms are enabled on your Metabase Cloud instance, you can't cancel the add-on. To stop paying for runs, an admin can [turn transforms off](./transforms-overview.md#turn-transforms-off).
|
||||
|
||||
## Advanced transforms
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ redirect_from:
|
||||
|
||||
> Transform inspector requires the [Advanced transforms add-on](addons.md).
|
||||
|
||||
_Data Studio > Transforms > [transform name] > Inspect_
|
||||
_Data Studio > Data transformation > [transform name] > Inspect_
|
||||
|
||||
The transform inspector gives you a diagnostic view of your transforms. Instead of writing your own SQL to check row counts, join match rates, or column distributions, you can open the **Inspect** tab and let Metabase analyze your transform's inputs and outputs for you. The inspector is especially useful for catching data quality issues (like unmatched rows in joins) before they cause problems downstream.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ redirect_from:
|
||||
|
||||
# Transform jobs
|
||||
|
||||
_Data Studio > Jobs_
|
||||
_Data Studio > Data transformation > Jobs_
|
||||
|
||||
Jobs are scheduled runs of transforms based on the transform's tags.
|
||||
|
||||
@@ -20,7 +20,7 @@ Add tags to transforms so that you can use jobs to run the transforms on a sched
|
||||
To add a tag to a transform:
|
||||
|
||||
1. Make sure you have [permissions to edit transforms](./transforms-overview.md#permissions-for-transforms).
|
||||
2. Visit the transform in **Data studio > Transforms**.
|
||||
2. Visit the transform in **Data Studio > Data transformation**.
|
||||
3. On the **Settings** page for a transform, add transform tags.
|
||||
|
||||
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**.
|
||||
@@ -29,8 +29,6 @@ Once you've tagged a transform, you can create a job that uses that tag to run t
|
||||
|
||||
## Jobs
|
||||
|
||||
_Data Studio > Jobs_
|
||||
|
||||

|
||||
|
||||
Jobs run one or more transforms on schedule based on transform tags.
|
||||
@@ -44,15 +42,11 @@ Jobs will run all transforms tagged with any of the tags, plus any transforms th
|
||||
|
||||
You can see which transforms a job will run and in which order on the job's page.
|
||||
|
||||
### See all jobs
|
||||
|
||||
To see all jobs, go to **Data Studio** and click the **Jobs** at the bottom of the left sidebar.
|
||||
|
||||
### Create a job
|
||||
|
||||
To create a new job:
|
||||
|
||||
1. Go to **Data Studio > Jobs**
|
||||
1. Go to **Data Studio > Data transformation > Jobs**
|
||||
2. Click the **+ New** button in the top right.
|
||||
3. Specify the schedule: select one of the built-in schedules or use cron syntax to specify a custom schedule,
|
||||
|
||||
@@ -62,17 +56,17 @@ To create a new job:
|
||||
|
||||
### Disable jobs
|
||||
|
||||
You can disable jobs without deleting them. Unlike deletion (which is permanent), disabling a job just means it won't run until you re-enable it. This is useful when you want to temporarily stop transforms from running - for example, for debugging purposes. This way you don't your lose configuration settings like tags and schedules.
|
||||
You can disable a job without deleting it. A disabled job keeps its tags and schedule but won't run until you re-enable it, which is handy when you're debugging.
|
||||
|
||||
To disable a specific job:
|
||||
|
||||
1. Go to **Data studio > Jobs**.
|
||||
1. Go to **Data Studio > Data transformation > Jobs**.
|
||||
2. Find the job you want to disable and click the **three dots** icon to the right of the job's name.
|
||||
3. Select **Disable**
|
||||
|
||||
To disable all jobs:
|
||||
|
||||
1. Go to **Data studio > Jobs**.
|
||||
1. Go to **Data Studio > Data transformation > Jobs**.
|
||||
2. Click the **three dots** icon above the table with all the jobs, and select **Disable all**.
|
||||
|
||||

|
||||
@@ -81,18 +75,7 @@ Even if you disable all jobs, new jobs will still be created enabled by default.
|
||||
|
||||
### Re-enable jobs
|
||||
|
||||
If you [disabled any jobs](#disable-jobs), you can later re-enable them:
|
||||
|
||||
To re-enable a specific job:
|
||||
|
||||
1. Go to **Data studio > Jobs**.
|
||||
2. Find the job you want to re-enable and click the **three dots** icon to the right of the job's name.
|
||||
3. Select **Re-enable**
|
||||
|
||||
To re-enable all jobs:
|
||||
|
||||
1. Go to **Data studio > Jobs**.
|
||||
2. Click the **three dots** icon above the table with all the jobs, and select **Re-enable all**.
|
||||
To re-enable a job, follow the [steps for disabling jobs](#disable-jobs) and select **Re-enable** (or **Re-enable all**).
|
||||
|
||||
### Delete a job
|
||||
|
||||
@@ -102,7 +85,7 @@ Deleted jobs can't be restored. If you want to temporarily stop a job from runni
|
||||
|
||||
To delete a job:
|
||||
|
||||
1. Go to **Data Studio > Jobs**.
|
||||
1. Go to **Data Studio > Data transformation > Jobs**.
|
||||
2. Find the job you want to delete and click the **three dots** icon to the right of the job's name.
|
||||
3. Select **Delete**.
|
||||
|
||||
@@ -121,15 +104,15 @@ Metabase skips a dependency when the dependency:
|
||||
|
||||
## See which transforms a job will run
|
||||
|
||||
The job's page in **Data Studio > Jobs** lists every transform the job will run and in which order. The **Notes** column tells you when Metabase will skip a dependency, and flags dependencies that have no schedule of their own.
|
||||
The job's page in **Data Studio > Data transformation > Jobs** lists every transform the job will run and in which order. The **Notes** column tells you when Metabase will skip a dependency, and flags dependencies that have no schedule of their own.
|
||||
|
||||
## Runs
|
||||
|
||||

|
||||
|
||||
You can see all past and current transform runs (both manual and scheduled) by going to **Data Studio** and clicking on **Runs** at the bottom of the left sidebar. The transform run times will be given in Greenwich Mean Time (GMT).
|
||||
You can see all past and current transform runs (both manual and scheduled) in **Data Studio > Data transformation > Runs**. Run times are in your Metabase server's timezone (hover the info icon next to **Started at** to see which).
|
||||
|
||||
You can click on any transform run to see more details about the run, like the error logs. To go to the transform definition from the transform run page, click on the icon next the transform name in the right sidebar.
|
||||
You can click on any transform run to see more details about the run, like the error logs. To go to the transform definition from the transform run page, click on the icon next to the transform name in the right sidebar.
|
||||
|
||||
The "Tags" column in the **Runs** table will only show the transform's specific tags. But the run might not have anything to do with those tags. Another job with different tags could have run the transform because [jobs include all dependent transforms](#jobs-include-all-dependent-transforms).
|
||||
|
||||
|
||||
@@ -200,6 +200,6 @@ Then bring everything up and try a transform:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. You'll need a Pro or Enterprise license with the [Advanced transforms add-on](addons.md). In Metabase, [enable transforms](transforms-overview.md#enable-transforms), then [create a Python transform](python.md#create-a-python-transform) and click **Run**.
|
||||
3. You'll need a Pro or Enterprise license with the [Advanced transforms add-on](addons.md). In Metabase, [enable transforms](transforms-overview.md#enable-transforms), then [create a Python transform](python.md#create-a-python-transform) and click **Run now** on its **Run** tab.
|
||||
|
||||
4. Open **Data Studio > Jobs > Runs** and find your run. If it failed, the run's logs will usually tell you whether Metabase couldn't reach the runner, or the runner couldn't reach MinIO.
|
||||
4. Open **Data Studio > Data transformation > Runs** and find your run. If it failed, the run's logs will usually tell you whether Metabase couldn't reach the runner, or the runner couldn't reach MinIO.
|
||||
|
||||
@@ -37,7 +37,7 @@ To execute Python transforms, you'll need a _Python runner_ - a dedicated enviro
|
||||
|
||||
Once you've [set up the Python runner](#set-up-a-python-runner):
|
||||
|
||||
1. Go to **Data studio > Transforms**.
|
||||
1. Go to **Data Studio > Data transformation**.
|
||||
2. Click on **+ New** and select **Python script**.
|
||||
|
||||
3. Select a database that has the data you want to transform. See [Databases that support transforms](transforms-overview.md#databases-that-support-transforms).
|
||||
@@ -95,7 +95,7 @@ If you have functions or classes you'd like to reuse across multiple transforms,
|
||||
|
||||
To add things to the common Python library:
|
||||
|
||||
1. Go to **Data studio > Transforms**.
|
||||
1. Go to **Data Studio > Data transformation**.
|
||||
2. Scroll to the very bottom of the transforms list and click on **Python library**.
|
||||
|
||||
3. Add a Python function or class.
|
||||
@@ -120,10 +120,11 @@ To update a table incrementally, your data has to have a certain structure. See
|
||||
|
||||
To make a Python transform incremental:
|
||||
|
||||
1. Go to the transform's page in **Data studio > Transforms**.
|
||||
1. Go to the transform's page in **Data Studio > Data transformation**.
|
||||
2. Switch to **Settings** tab.
|
||||
3. In **Field to check for new values**, select the field in one of the source tables that Metabase should scan to determine which records are new or changed. Only some fields are eligible. See [prerequisites for incremental transforms](./transforms-overview.md#prerequisites-for-incremental-transforms).
|
||||
4. (Optional) To update matching rows instead of appending new ones, [add a merge key](./transforms-overview.md#add-merge-keys-to-upsert-rows).
|
||||
4. (Optional) To catch late-arriving rows, [set a lookback window](./transforms-overview.md#catch-late-arriving-rows-with-a-lookback-window).
|
||||
5. (Optional) To update matching rows instead of appending new ones, [add a merge key](./transforms-overview.md#add-merge-keys-to-upsert-rows).
|
||||
|
||||
## Current limitations of Python transforms
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ For general information about Metabase transforms, see [Transforms](transforms-o
|
||||
On Metabase Cloud, you need the [**Basic transforms** add-on](addons.md) to create query-based transforms.
|
||||
|
||||
1. [Enable transforms](transforms-overview.md#enable-transforms).
|
||||
2. Go to **Data studio > Transforms**.
|
||||
2. Go to **Data Studio > Data transformation**.
|
||||
|
||||
3. Click **+ New** and pick "Query builder", "SQL", or "Copy of existing question".
|
||||
3. Click **+ New** and pick **Query builder**, **SQL query**, or **Copy of a saved question**.
|
||||
|
||||
Currently, you can't convert between different transform types (like converting 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.
|
||||
|
||||
@@ -154,10 +154,11 @@ Once your query has table aliases, you can mark the transform as incremental usi
|
||||
|
||||
To make a query transform incremental:
|
||||
|
||||
1. Go to the transform's page in **Data studio > Transforms**.
|
||||
1. Go to the transform's page in **Data Studio > Data transformation**.
|
||||
2. Switch to **Settings** tab.
|
||||
3. In **Field to check for new values**, select the field in one of the source tables that Metabase should scan to determine which records are new or changed. Only some fields are eligible. See [prerequisites for incremental transforms](./transforms-overview.md#prerequisites-for-incremental-transforms).
|
||||
4. (Optional) To update matching rows instead of appending new ones, [add a merge key](./transforms-overview.md#add-merge-keys-to-upsert-rows).
|
||||
4. (Optional) To catch late-arriving rows, [set a lookback window](./transforms-overview.md#catch-late-arriving-rows-with-a-lookback-window).
|
||||
5. (Optional) To update matching rows instead of appending new ones, [add a merge key](./transforms-overview.md#add-merge-keys-to-upsert-rows).
|
||||
|
||||
## Convert models to transforms
|
||||
|
||||
@@ -175,7 +176,7 @@ You must be an admin to convert models, and the model's database must [support t
|
||||
To convert a model into a transform:
|
||||
|
||||
1. Review [Replacing data sources](../tools/replace-data-sources.md) docs for overview and limitations of the process.
|
||||
2. Open **Data Studio** and select **Transforms** in the sidebar.
|
||||
2. Go to **Data Studio > Data transformation**.
|
||||
3. Click **Tools > Migrate models**.
|
||||
4. Find the model you want to convert and click it to open its details panel. The panel shows the model's name, database, and collection, and a list of items that depend on it.
|
||||
5. Click **Convert to a transform**.
|
||||
|
||||
@@ -7,14 +7,12 @@ redirect_from:
|
||||
|
||||
# Transforms
|
||||
|
||||
_Data Studio > Transforms_
|
||||
_Data Studio > Data transformation_
|
||||
|
||||
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 (created either with SQL or the query builder) or Python scripts that write back to your database and create a new, persistent table. Use transforms to clean, join, or pre-aggregate data.
|
||||
@@ -36,9 +34,9 @@ Currently, Metabase can create transforms on the following databases:
|
||||
- Snowflake
|
||||
- SQL Server
|
||||
|
||||
You can't create transforms on databases that have [Database routing](../../permissions/database-routing.md) enabled, or on Metabase's Sample Database.
|
||||
You can't create transforms on databases that have [Database routing](../../permissions/database-routing.md) enabled, or on Metabase's Sample Database (which is SQLite).
|
||||
|
||||
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). We suggest using a [Writable connection](../../databases/writable-connection.md) for your database.
|
||||
Transforms will create tables in your database, so the database user you use for your connection must have [appropriate privileges](../../databases/users-roles-privileges.md). We suggest using a [Writable connection](../../databases/writable-connection.md) for your database.
|
||||
|
||||
## Types of transforms
|
||||
|
||||
@@ -56,7 +54,7 @@ Permission configuration for transform depends on your plan.
|
||||
- **Metabase Pro/Enterprise** comes with additional permission controls for transforms: a special [Data Analysts](../../people-and-groups/managing.md) group for non-Admins with potential transform access, and granular transform permissions for each database:
|
||||
|
||||
- To **see** the list of transforms on your instance, people need to be able to access Data Studio, so they need to be either an Admin or a member of the special [Data Analyst group](../../people-and-groups/managing.md).
|
||||
- To **execute** transforms on a database, people need to be either Admins on belong to the special [Data Analyst group](../../people-and-groups/managing.md). Additionally people need to have the [Transform permissions](../../permissions/data.md) for that database.
|
||||
- To **execute** transforms on a database, people need to be either Admins or belong to the [Data Analyst group](../../people-and-groups/managing.md). Data Analysts also need [Transform permissions](../../permissions/data.md) for that database.
|
||||
|
||||
## Set up transforms on a self-hosted Metabase
|
||||
|
||||
@@ -72,32 +70,27 @@ If you run Metabase yourself, here's the path to working transforms:
|
||||
|
||||
Before you can start writing transforms, you'll need to enable transforms in your Metabase instance.
|
||||
|
||||
If you're on a Metabase Cloud plan, only people logged in with an email of a [Metabase Store admin](../../cloud/accounts-and-billing.md#add-managers) (not just Metabase _instance_ admins) can enable basic transforms, because transforms incur a cost per run on Metabase Cloud.
|
||||
If you're on a Metabase Cloud plan, only admins can enable basic transforms, because transforms incur a cost per run on Metabase Cloud.
|
||||
|
||||
To enable transforms:
|
||||
|
||||
1. Navigate to [**Data Studio**](../data-studio.md) by click the **grid icon** in top right corner of your Metabase and selecing **Data Studio**.
|
||||
2. In Data Studio, click **Transforms** in the right sidebar.
|
||||
3. If the transforms have not been enabled on your instance yet, you'll see a prompt to enable them. You can do just that.
|
||||
1. Navigate to [**Data Studio**](../data-studio.md) by clicking the **grid icon** in the top right corner of your Metabase and selecting **Data Studio**.
|
||||
2. Click **Data transformation**.
|
||||
3. Click **Enable transforms**. On Metabase Cloud, you'll then need to agree to the Basic transforms add-on pricing.
|
||||
|
||||
Once you've enabled transforms, you can [configure permissions](#permissions-for-transforms) and start [creating transforms](#create-a-transform).
|
||||
|
||||
## See all transforms
|
||||
### Turn transforms off
|
||||
|
||||
_Data Studio > Transforms_
|
||||
_Data Studio > Settings_
|
||||
|
||||
See [permissions needed to see transforms](#permissions-for-transforms).
|
||||
Admins can turn transforms off with the **Transforms** toggle. While transforms are off, **Data transformation** disappears from Data Studio and no transforms run, manually or on a schedule. Metabase keeps your transforms, jobs, and tables.
|
||||
|
||||
You can see all your Metabase's transforms:
|
||||
|
||||
1. Click the **grid** icon on top right and go to **Data Studio**.
|
||||
2. In the left sidebar, select **Transforms**.
|
||||
|
||||

|
||||
If you've set [`MB_TRANSFORMS_ENABLED`](../../configuring-metabase/environment-variables.md#mb_transforms_enabled), the environment variable wins: you can still flip the toggle, but it won't do anything.
|
||||
|
||||
## Create a transform
|
||||
|
||||
_Data Studio > Transforms_
|
||||
_Data Studio > Data transformation_
|
||||
|
||||
> If you're using remote sync, you won't be able to create transforms if your instance is in ["read-only" sync mode](../../installation-and-operation/remote-sync.md).
|
||||
|
||||
@@ -107,7 +100,7 @@ To create a transform:
|
||||
|
||||
1. [Enable transforms](#enable-transforms).
|
||||
2. Click the **grid** icon on top right and go to **Data Studio**.
|
||||
3. In the left sidebar, select **Transforms**.
|
||||
3. Click **Data transformation**.
|
||||
4. Click **+ New** and select a source for your transform.
|
||||
|
||||

|
||||
@@ -134,13 +127,11 @@ To create a transform:
|
||||
- **Folder** (optional): The folder where the transform should live. Click on the field to pick a different folder or create a new one.
|
||||
- **Incremental transformation** (optional): see [Incremental query-based transforms](query.md#incremental-query-transforms) or [Incremental Python transforms](python.md)
|
||||
|
||||

|
||||
|
||||
7. Optionally, once the transform is saved, assign tags to your transform. Tags are used by [jobs](./jobs-and-runs.md) to run transforms on schedule.
|
||||
|
||||
## Edit a transform
|
||||
|
||||
_Data Studio > Transforms_
|
||||
_Data Studio > Data transformation_
|
||||
|
||||
You can edit the transform's name and description, query/script, target table, and incremental settings. You can edit the transform even if it already ran or is scheduled to run.
|
||||
|
||||
@@ -150,13 +141,13 @@ If you're using remote sync, you won't be able to edit transforms if your instan
|
||||
|
||||
### Edit transform's query or script
|
||||
|
||||
_Data Studio > Transforms > Definition_
|
||||
_Data Studio > Data transformation > [transform name] > Definition_
|
||||
|
||||
See [permissions to edit transforms](#permissions-for-transforms).
|
||||
|
||||
To edit the transform's query or script:
|
||||
|
||||
1. Go to **Data Studio > Transforms**.
|
||||
1. Go to **Data Studio > Data transformation**.
|
||||
2. Find the transform you'd like to edit and click on **Edit definition** above the transform definition.
|
||||
3. Edit the query or script.
|
||||
|
||||
@@ -166,7 +157,7 @@ Once you change the transform's query or script, the next transform run (manual
|
||||
|
||||
### Edit transform's target
|
||||
|
||||
_Data Studio > Transforms > Settings_
|
||||
_Data Studio > Data transformation > [transform name] > Settings_
|
||||
|
||||
To edit transform's target table, i.e., the table where the query results are written, go the transforms **Settings** tab and 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.
|
||||
|
||||
@@ -176,7 +167,7 @@ To edit transform's target table, i.e., the table where the query results are wr
|
||||
|
||||
You can run a transform manually or schedule the transform using tags and jobs.
|
||||
|
||||
- To run a transform manually, visit the transform in **Data Studio > Transforms > Runs** and click **Run**.
|
||||
- To run a transform manually, open the transform in **Data Studio > Data transformation**, go to its **Run** tab, and click **Run now**.
|
||||
|
||||
- To schedule a transform, you'll need to assign one or more tags to it, then create a [scheduled job](./jobs-and-runs.md) that picks up those tags.
|
||||
|
||||
@@ -186,9 +177,31 @@ You can see the time and status of the latest transform run on the transform's p
|
||||
|
||||
For Python transforms, you'll also see the transform's execution logs.
|
||||
|
||||
## Add indexes to a transform's table
|
||||
|
||||
_Data Studio > Data transformation > [transform name] > Indexes_
|
||||
|
||||
Indexes can speed up queries on a transform's table. Metabase saves the indexes you add here and recreates indexes whenever the transform rebuilds its table. Indexes created directly in your database show as **Unmanaged**: you can't edit or delete them in Metabase, and they disappear the next time the transform rebuilds its table. To keep an index, add it here.
|
||||
|
||||

|
||||
|
||||
To add an index:
|
||||
|
||||
1. Run the transform at least once, so its table exists.
|
||||
2. On the transform's **Indexes** tab, click **Create index**.
|
||||
3. Pick an index type and the columns to index. Types depend on your database: for example, B-Tree indexes on PostgreSQL, sort keys on Redshift, clustering keys on Snowflake, or clustering on BigQuery.
|
||||
|
||||

|
||||
|
||||
4. Run the transform (or wait for its next scheduled run).
|
||||
|
||||
Index edits and deletions take effect on the next run. On an incremental transform, the next run after _any_ index change _rebuilds the whole table_.
|
||||
|
||||
If Metabase can't create an index, the index shows as **Failed** and the run usually fails too. Every run retries the index until it succeeds, or until you edit or delete it. On an incremental transform, each of these runs rebuilds the whole table.
|
||||
|
||||
## Inspect a transform
|
||||
|
||||
_Data Studio > Transforms > [transform name] > Inspect_
|
||||
_Data Studio > Data transformation > [transform name] > Inspect_
|
||||
|
||||
> Transform inspector requires the [Advanced transforms add-on](addons.md)
|
||||
|
||||
@@ -198,7 +211,7 @@ The [transform inspector](./inspector.md) lets you poke at the input and outputs
|
||||
|
||||
{% include plans-blockquote.html feature="Transform dependencies" is_plural=true%}
|
||||
|
||||
_Data Studio > Transforms > Dependencies_
|
||||
_Data Studio > Data transformation > [transform name] > Dependencies_
|
||||
|
||||

|
||||
|
||||
@@ -206,13 +219,11 @@ Transform queries can use the data from other transforms, and query-based transf
|
||||
|
||||
Metabase will track transform dependencies and execute transforms in a reasonable order. So for example, if transform B relies on a table created by transform A, Metabase will run transform A first, then run transform B.
|
||||
|
||||
On Metabase Pro or Enterprise plans, you can see the transform dependencies graph by going to **Data Studio > Transforms** and go to the **Dependencies** tab.
|
||||
|
||||
If a job includes a transform that depends on a table created by another transform, then the job will run all the tagged transforms, plus any of their [dependencies that aren't already up to date](jobs-and-runs.md#jobs-include-all-dependent-transforms).
|
||||
|
||||
## Incremental transforms
|
||||
|
||||
_Data Studio > Transforms > Settings_
|
||||
_Data Studio > Data transformation > [transform name] > Settings_
|
||||
|
||||
Incremental transforms only process the data that's new since the previous transform run. For example, you might have new transaction data coming in every day, and run the transform nightly. With each run, the incremental transform would only handle the rows added after the previous run the night before.
|
||||
|
||||
@@ -223,7 +234,7 @@ In this case, the checkpoint field decides which rows count as new, and the merg
|
||||
### Prerequisites for incremental transforms
|
||||
|
||||
- There is a column in your data that Metabase can check for new values to determine which data is new. We'll refer to this as a "Checkpoint" column.
|
||||
- The checkpoint column has to have increasing values, like a sequential ID or timestamp column. Metabase will determine what "new" data is by looking for values that are _greater than_ already-written checkpoint values.
|
||||
- The checkpoint column has to have increasing values, like a sequential ID or timestamp column. Metabase will determine what "new" data is by looking for values that are _greater than_ already-written checkpoint values. Columns that only store a time of day can't be checkpoints, since their values start over every day.
|
||||
- Your schema is stable, meaning that the structure of the tables is not going to change from run to run.
|
||||
|
||||
### Add merge keys to upsert rows
|
||||
@@ -234,7 +245,7 @@ Why you'd want to add a merge key: some source tables get a new row each time a
|
||||
|
||||
To add a merge key:
|
||||
|
||||
1. Go to the transform's page in **Data studio > Transforms**.
|
||||
1. Go to the transform's page in **Data Studio > Data transformation**.
|
||||
2. Switch to the **Settings** tab and turn on **Only process new data**.
|
||||
3. In **Merge key**, choose the columns that identify a record. If the target table already exists, you'll pick from a list of its columns. If the transform hasn't run yet, there's no table to pick from, so type each column name and press comma or enter. In that case, type the column's name in the database, which may differ from the display name you'd see in the list.
|
||||
|
||||
@@ -242,6 +253,22 @@ The merge key refers to columns in the _target_ table (the columns your transfor
|
||||
|
||||
Merge keys need a checkpoint field that increases every time a row is written, like an `updated_at` timestamp, otherwise the transform never sees the change.
|
||||
|
||||
### Catch late-arriving rows with a lookback window
|
||||
|
||||
If, for example, a row stamped 9:55 is added to a table _after_ a transform run has already processed everything through 10:00, later runs won't pick up the row. To catch late-arriving rows, set a lookback window, so each run also reprocesses some data it's already seen.
|
||||
|
||||
To set a lookback window:
|
||||
|
||||
1. Go to the transform's **Settings** tab and turn on **Only process new data**.
|
||||
2. In **Field to check for new values**, pick a date or datetime column, like `updated_at`. The **Lookback window** field only shows up for date or datetime checkpoint columns, not number columns like IDs.
|
||||
3. In **Lookback window**, enter a number and pick a unit, like 4 hours. Each run then also reprocesses rows from the four hours before the checkpoint. Date-only columns can't use units smaller than days.
|
||||
|
||||

|
||||
|
||||
If you later switch the checkpoint to a column that doesn't support a lookback, Metabase clears the lookback window. If you switch to a date-only column, Metabase changes a minutes or hours unit to days, so a 4-hour window becomes 4 days.
|
||||
|
||||
You should pair a lookback window with a [merge key](#add-merge-keys-to-upsert-rows). Otherwise, every run appends the reprocessed rows again as duplicates.
|
||||
|
||||
### Reprocess all data to rebuild the target table
|
||||
|
||||
Once an incremental transform has run, its **Settings** tab shows **Last processed [checkpoint field]**, followed by the highest checkpoint value Metabase has written so far. That value is how Metabase knows where to pick up, since each run only handles rows past it.
|
||||
@@ -256,7 +283,7 @@ Incremental transforms work differently for query-based transforms and Python tr
|
||||
|
||||
## Versioning transforms
|
||||
|
||||
_Admin > General > Remote sync_
|
||||
_Admin > Settings > Remote sync_
|
||||
|
||||
{% include plans-blockquote.html feature="Versioning transforms" %}
|
||||
|
||||
@@ -265,8 +292,8 @@ You can check your transforms into git with [Remote Sync](../../installation-and
|
||||
To enable git sync of transforms:
|
||||
|
||||
1. Go to Admin settings by clicking the **grid** icon in top right and select **Admin**.
|
||||
2. On the **General** tab, pick **Remote sync** in the left sidebar.
|
||||
3. Follow the steps to [Set up Remote Sync](../../installation-and-operation/remote-sync.md), and toggle "Transforms" sync on.
|
||||
2. On the **Settings** tab, pick **Remote sync** in the left sidebar.
|
||||
3. Follow the steps to [Set up Remote Sync](../../installation-and-operation/remote-sync.md), and toggle **Transforms** sync on.
|
||||
|
||||
Keep in mind that this setting only controls whether transforms are checked _into_ the git repo. The transform sync setting does _not_ affect how the instance behaves in read-only mode. If your instance is in read-only mode, you will not be able to create or edit transforms.
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ The first rule of frontend style, is we want to avoid talking about frontend sty
|
||||
|
||||
We use [oxfmt](https://oxc.rs/) to format our JavaScript and TypeScript code, and it is enforced by CI. We recommend setting your editor to "format on save". You can also format code using `bun run format`, and verify it has been formatted correctly using `bun run lint-format-pure`. Oxfmt also sorts imports, so `bun run format` fixes import order too.
|
||||
|
||||
We use [oxlint](https://oxc.rs/) to enforce JavaScript and TypeScript lint rules, including module boundaries. Run `bun run lint-oxlint` to lint, or `bun run lint-oxlint-fix` to apply lint fixes. Both commands build the CLJS output first. If you've already built it, `bun run lint-oxlint-pure` skips that step. For how the lint configuration works, see `frontend/lint/OXLINT.md`.
|
||||
We use [oxlint](https://oxc.rs/) to enforce JavaScript and TypeScript lint rules, including module boundaries. Run `bun run lint-oxlint` to lint, or `bun run lint-oxlint-fix` to apply lint fixes. For how the lint configuration works, see `frontend/lint/OXLINT.md`.
|
||||
|
||||
### Miscellaneous notes on coding style
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ These components are available via the `InteractiveQuestion` namespace (like `<I
|
||||
- [InteractiveQuestion.QuestionSettings](./sdk/api/InteractiveQuestion.html#questionsettings)
|
||||
- [InteractiveQuestion.QuestionSettingsDropdown](./sdk/api/InteractiveQuestion.html#questionsettingsdropdown)
|
||||
- [InteractiveQuestion.QuestionVisualization](./sdk/api/InteractiveQuestion.html#questionvisualization)
|
||||
- [InteractiveQuestion.RefreshButton](./sdk/api/InteractiveQuestion.html#refreshbutton)
|
||||
- [InteractiveQuestion.ResetButton](./sdk/api/InteractiveQuestion.html#resetbutton)
|
||||
- [InteractiveQuestion.SaveButton](./sdk/api/InteractiveQuestion.html#savebutton)
|
||||
- [InteractiveQuestion.SaveQuestionForm](./sdk/api/InteractiveQuestion.html#savequestionform)
|
||||
|
||||
@@ -92,6 +92,7 @@ type IconName =
|
||||
| "connections"
|
||||
| "contract"
|
||||
| "copy"
|
||||
| "corner_down_right"
|
||||
| "corner_up_right"
|
||||
| "curved"
|
||||
| "database"
|
||||
|
||||
@@ -30,7 +30,7 @@ Metabase will keep track of a version each time you save, move, revert, [move to
|
||||
|
||||
### Viewing tracked changes
|
||||
|
||||
1. Go to your transform in [Data studio](../data-modeling/transforms/transforms-overview.md).
|
||||
1. Go to your transform in [Data Studio > Data transformation](../data-modeling/transforms/transforms-overview.md).
|
||||
2. Click the **three dots** icon and select **History**
|
||||
3. The History tab will display the item's history of up to 15 versions.
|
||||
|
||||
@@ -38,12 +38,12 @@ Any change to the transform's query or script will be recorded as "changed sourc
|
||||
|
||||
### Reverting to previous versions
|
||||
|
||||
1. Go to your transform in [Data studio > Transforms](../data-modeling/transforms/transforms-overview.md).
|
||||
1. Go to your transform in [Data Studio > Data transformation](../data-modeling/transforms/transforms-overview.md).
|
||||
2. Click the **three dots** icon and select **History**.
|
||||
3. Click the **back arrow** beside a version to revert your item to that point in time.
|
||||
|
||||
## History for segments and measures
|
||||
|
||||
1. Go to the table source for the segment or measure in [Data Studio > Tables](../data-modeling/metadata/managing-tables.md).
|
||||
1. Go to the table source for the segment or measure in [Data Studio > Connected data](../data-modeling/metadata/managing-tables.md).
|
||||
2. Pick the segment or measure in the right sidebar.
|
||||
3. On the segment or measure's page, go to **Revision history** tab.
|
||||
|
||||
@@ -89,6 +89,10 @@ Options:
|
||||
|
||||
Transfer data from existing H2 database to the newly created MySQL or Postgres DB specified by env vars.
|
||||
|
||||
## `mcp-tools-documentation`
|
||||
|
||||
Generates `docs/ai/mcp-tools.md`, the reference for the tools Metabase's MCP server exposes to AI clients.
|
||||
|
||||
## `migrate direction`
|
||||
|
||||
Run database migrations. Valid options for `direction` are `up`, `force`, `down`, `down-force`, `print`, or `release-locks`.
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 184 KiB |
@@ -10,11 +10,10 @@ description: Version control your dashboards, questions, and transforms. Sync yo
|
||||
Remote Sync lets you develop analytics content in your Metabase and automatically deploy it to a read-only production Metabase through Git. Remote Sync can sync:
|
||||
|
||||
- Top-level collections (dashboards, questions, models, metrics)
|
||||
- Library content (published tables and their segments and measures)
|
||||
- Snippets
|
||||
- Library content (published tables with their segments and measures, plus metrics, snippets, and the glossary)
|
||||
- Transforms
|
||||
|
||||
Metabase doesn't sync any of your data. What it stores in Git are [YAML files](./serialization.md#example-of-a-serialized-question) describing your analytics content. Your actual data stays in your databases and never leaves your Metabase.
|
||||
Metabase doesn't sync any of your data. All Metabase stores in Git are [YAML files](./serialization.md#example-of-a-serialized-question) that describe your analytics content. Your actual data stays in your databases and never leaves your Metabase.
|
||||
|
||||
## How Remote Sync works
|
||||
|
||||
@@ -28,6 +27,12 @@ Here's a basic remote-sync workflow:
|
||||
|
||||
We'll cover [setting up Remote Sync](#setting-up-remote-sync), an [example dev-to-production workflow](#an-example-dev-to-production-workflow), [branch management](#branch-management), and some other odds and ends.
|
||||
|
||||
## Treat write access to your repository like admin access
|
||||
|
||||
When a Metabase pulls from your repository, it loads the YAML files on its branch _without_ checking who wrote them. Anyone who can push to that branch can change that Metabase's synced content. If you turn on [auto-sync](#pulling-changes-automatically), changes merged to the branch show up in your Metabase within minutes, and no one in Metabase approves them first.
|
||||
|
||||
Your repository can't change users, groups, or permissions, because those [don't sync](#what-metabase-syncs). But when a pull creates a new collection, that collection gets the same permissions as its parent. A new top-level collection gets the same permissions as **Our analytics**.
|
||||
|
||||
## Key concepts
|
||||
|
||||
### Remote Sync has two modes for different roles
|
||||
@@ -90,6 +95,8 @@ Before you connect Metabase to your Git repository, create a new repository. Sup
|
||||
|
||||
Initialize the repo with a README.md.
|
||||
|
||||
Anyone who can push to this repository can change content in your synced Metabases, so [limit who has write access](#treat-write-access-to-your-repository-like-admin-access).
|
||||
|
||||
### 2. Create a token for your read-and-write Metabase
|
||||
|
||||
> Skip this step if you're using a [local repo](#local-file-repositories).
|
||||
@@ -138,21 +145,21 @@ In the Metabase instance that you use for development:
|
||||
|
||||
6. (Optional) If you have [multi-tenant user strategy enabled](../embedding/tenants.md#enable-multi-tenant-strategy), you can also choose which [shared collection](../embedding/tenants.md#changing-tenant-strategy) to sync.
|
||||
|
||||
> If you only want to sync your Library and transforms, you can set up remote sync from Data Studio instead: click **Set up remote sync** at the bottom of the Data Studio sidebar.
|
||||
|
||||
### 4. Select what to sync
|
||||
|
||||
Select what you [want to sync](#you-choose-what-to-sync).
|
||||
|
||||
### 5. Push your changes to your repository
|
||||
|
||||

|
||||
|
||||
Once you've added content, you'll see a yellow dot on the synced collections indicating uncommitted changes.
|
||||
|
||||
1. At the top of the screen, choose which branch to push to and click the up arrow (push) icon.
|
||||
1. At the top of the screen, click the branch name and select **Push changes**.
|
||||
|
||||
2. Enter a commit message describing your changes (e.g., "Added dashboard on mammoth populations").
|
||||
2. (Optional) Describe your changes (e.g., "Added dashboard on mammoth populations").
|
||||
|
||||
3. Click "Push changes" to commit and push your changes to your repository.
|
||||
3. Click **Push changes** to commit and push your changes to your repository.
|
||||
|
||||
Check your repository; you should see the collection.
|
||||
|
||||
@@ -199,8 +206,8 @@ In your production Metabase instance:
|
||||
- Click "Save changes". Metabase will verify it can reach your repository. If the connection fails, verify your token has the appropriate permissions and hasn't expired.
|
||||
|
||||
6. Sync your content:
|
||||
- Click "Pull changes" to immediately sync content from your repository.
|
||||
- To keep your production instance automatically updated, toggle on "Auto-sync with Git". Metabase will pull changes from your main branch every five minutes.
|
||||
- Click "Pull changes now" to immediately sync content from your repository.
|
||||
- To keep your production instance automatically updated, toggle on "Auto-sync with git". Metabase will pull changes from your main branch every five minutes.
|
||||
|
||||
In Read-only mode, synced collections appear in the regular collections list with a special icon to indicate that they're versioned and read-only.
|
||||
|
||||
@@ -267,7 +274,7 @@ Let's say your team wants to build a new analytics dashboard. Here's a workflow
|
||||
|
||||
### Step 1: Create a new branch
|
||||
|
||||
In your development Metabase, click the branch dropdown at the top of the screen to [create a new branch](#branch-management) for your work, like `feature/megafauna-dashboard`.
|
||||
In your development Metabase, go to **Admin** > **Settings** > **Remote sync** and [create a new branch](#creating-a-branch) for your work, like `feature/megafauna-dashboard`.
|
||||
|
||||
### Step 2: Create content in your development Metabase
|
||||
|
||||
@@ -275,10 +282,7 @@ Create a dashboard called "Megafauna Analytics" and add some questions. Save the
|
||||
|
||||
### Step 3: Push to your development branch
|
||||
|
||||
1. You should see a yellow dot on your synced collection (indicating local changes).
|
||||
2. Click the up arrow (push) icon at the top of the screen.
|
||||
3. Enter a commit message: "Add Megafauna Analytics dashboard".
|
||||
4. Metabase commits your changes to the branch you're working on and pushes them to your repo.
|
||||
[Push your changes](#committing-and-pushing-your-changes) to `feature/megafauna-dashboard` with a commit message like "Add Megafauna Analytics dashboard".
|
||||
|
||||
### Step 4: Create a pull request
|
||||
|
||||
@@ -304,14 +308,12 @@ On your production Metabase instance:
|
||||
|
||||
### Synced collections in the UI
|
||||
|
||||
You can select any top-level collection under Our Analytics to sync with Git. Synced collections show their current state with visual indicators: a yellow dot indicates unsynced local changes that need to be committed. At the top of the screen, you'll see up/down arrows that provide sync controls for pulling and pushing changes.
|
||||
You can select any top-level collection under Our Analytics to sync with Git. Synced collections show their current state with visual indicators: a yellow dot indicates unsynced local changes that need to be committed. At the top of the screen (in Data Studio, at the top of the sidebar), click the branch name to push or pull changes.
|
||||
|
||||
If you are using [tenants](../embedding/tenants.md), you can also sync shared collections. You'll see the same yellow dot indicator for shared collections.
|
||||
|
||||
In Read-only mode, synced collections appear in the regular collections list (not in a separate "Synced Collections" section) with a special icon to indicate they're versioned and read-only.
|
||||
|
||||
When transforms syncing is enabled, you'll find your transforms in the Transforms section of the Admin settings. Synced transforms are also read-only in Read-only mode.
|
||||
|
||||
### Moving and deleting content in synced collections
|
||||
|
||||
When you remove content from a synced collection in Read-write mode and push that change, the content will also be removed from your production instance when it syncs. This applies to moving content out of a synced collection or deleting it entirely.
|
||||
@@ -361,7 +363,7 @@ Remote Sync uses the same serialization format as the [Metabase CLI serializatio
|
||||
- Documents
|
||||
- Timelines and events
|
||||
- Collection structure and metadata
|
||||
- Library content (published tables, metrics, snippets, segments, measures)
|
||||
- Library content (published tables, metrics, snippets, segments, measures, glossary)
|
||||
- Transforms (including jobs and folders)
|
||||
|
||||
**What doesn't sync:**
|
||||
@@ -378,48 +380,42 @@ Branching is only available in Read-write mode.
|
||||
|
||||
### Creating a branch
|
||||
|
||||
You can create branches in Metabase or directly in your Git repository. Branches created in Git will appear in the Metabase branch dropdown once Metabase syncs with your repository.
|
||||
You can create branches in Metabase or directly in your Git repository. Branches created in Git will appear in Metabase once Metabase syncs with your repository.
|
||||
|
||||
Before creating branches, push an initial commit to your main branch.
|
||||
|
||||
To create a new branch in Metabase:
|
||||
|
||||
1. Click the branch dropdown at the top of the screen.
|
||||
2. Type a name for the new branch in the search box.
|
||||
3. Press Enter to create the branch.
|
||||
1. Go to **Admin** > **Settings** > **Remote sync**.
|
||||
2. Under **Sync branch**, click the current branch.
|
||||
3. Type a name for the new branch and click **Create branch "[name]"**.
|
||||
|
||||
The new branch is created from your current commit (not the latest commit from the remote).
|
||||
The new branch is created from your current commit (not the latest commit from the remote), and Metabase switches to it.
|
||||
|
||||
### Switching branches
|
||||
|
||||
At the top of the screen, you'll see a branch dropdown:
|
||||
|
||||

|
||||
|
||||
1. Click the branch dropdown to see available branches.
|
||||
2. Select a different branch. This will change the branch for everyone in your Metabase (see [limitations](#branching-limitations)).
|
||||
1. Go to **Admin** > **Settings** > **Remote sync**.
|
||||
2. Under **Sync branch**, click the current branch and select a different branch. This will change the branch for everyone in your Metabase (see [limitations](#branching-limitations)).
|
||||
|
||||
If the branch doesn't appear, ensure it exists in your Git repository and that the name matches exactly (branch names are case-sensitive).
|
||||
|
||||
If you have unsynced changes, Metabase will show a dialog asking what you want to do:
|
||||
If you have unsynced changes, Metabase lists them and asks what to do:
|
||||
|
||||
- **Push changes to the current branch:** Commits your changes to the current branch before switching.
|
||||
- **Create a new branch and push changes there:** Saves your work to a new branch, keeping the original branch clean.
|
||||
- **Discard these changes:** Throws away your uncommitted changes (can't be undone).
|
||||
- **Push changes to the current branch, [branch]:** Commits your changes to the current branch. You stay on that branch, so pick the other branch again afterward.
|
||||
- **Create a new branch and push changes there:** Commits your changes to a new branch and switches you to it.
|
||||
- **Delete unsynced changes (can't be undone):** Throws away your uncommitted changes and switches you to the branch you picked.
|
||||
|
||||
The dialog shows you exactly which items have changed, so you can make an informed decision.
|
||||
|
||||
If you switch modes (from Read-write to Read-only or vice versa) with unpushed changes, you'll be prompted to save or discard them. You cannot switch to Read-only mode with uncommitted changes.
|
||||
|
||||
If changes don't appear after switching modes: Hard refresh your browser (Cmd/Ctrl + Shift + R).
|
||||
You can't switch to Read-only mode while you have unpushed changes.
|
||||
|
||||
### Branching limitations
|
||||
|
||||
- **Only admins can manage branches**. Non-admins won't see the branch dropdown at all.
|
||||
- **Only admins can manage branches**. Non-admins don't see the branch controls.
|
||||
- **The current branch is instance-wide**. This means that everyone on the same Metabase is on the same branch at the same time. You can't have different people working on different branches in a single Metabase.
|
||||
|
||||
Limiting a Metabase to a single branch at a time means that the typical Git feature-branch workflow is a bit different here: only admins can switch branches, create new branches, or push and pull changes. If you need multiple branches worked on simultaneously, the workaround is to spin up multiple Metabases in Read-write mode, one Metabase per branch.
|
||||
|
||||
These limits only apply in Metabase. In your Git repository, anyone with write access can create branches and push changes, and Metabase loads those changes when it pulls. See [Treat write access to your repository like admin access](#treat-write-access-to-your-repository-like-admin-access).
|
||||
|
||||
## Pushing changes to Git
|
||||
|
||||
You can only push changes in a Metabase with Remote Sync set to Read-write mode.
|
||||
@@ -428,22 +424,19 @@ You can only push changes in a Metabase with Remote Sync set to Read-write mode.
|
||||
|
||||
When you make changes to items in a synced collection, a yellow dot appears on your synced collection (indicating uncommitted changes). To commit and push your changes:
|
||||
|
||||
1. Click the up arrow (push) icon at the top of the screen (make sure you're pushing to the right branch).
|
||||
2. Enter a descriptive commit message explaining your changes.
|
||||
3. Click "Continue" to push your changes to Git.
|
||||
1. At the top of the screen, click the branch name and select **Push changes**.
|
||||
2. (Optional) Describe your changes.
|
||||
3. Click **Push changes** to push your changes to Git.
|
||||
|
||||
If you see a message that "Remote is ahead of local", that means someone else pushed to the branch from another Metabase in Read-write mode. Pull the latest changes before pushing again.
|
||||
If the branch has new commits since you last pulled, Metabase asks how to handle them before it pushes yours: merge them with yours (when they don't conflict), push your changes to a new branch, or force push over them.
|
||||
|
||||
## Pulling changes from Git
|
||||
|
||||
You can pull changes when in Read-write or Read-only mode.
|
||||
|
||||
In Read-write mode, you can get the latest changes from your Git repository:
|
||||
In Read-write mode, click the branch name at the top of the screen and select **Pull changes**. Metabase imports the latest content from Git.
|
||||
|
||||
1. Click the down arrow (pull) icon at the top of the screen.
|
||||
2. Review any summary of incoming changes if shown.
|
||||
3. Confirm the import.
|
||||
4. Metabase updates your collections with the latest content from Git.
|
||||
In Read-only mode, go to **Admin** > **Settings** > **Remote sync** and click **Pull changes now**.
|
||||
|
||||
If changes don't appear after pulling:
|
||||
|
||||
@@ -451,15 +444,14 @@ If changes don't appear after pulling:
|
||||
- Hard refresh your browser (Cmd/Ctrl + Shift + R).
|
||||
- If you encounter sync errors, review error messages in the sync dialog, manually resolve conflicts in your Git repository, then pull again.
|
||||
|
||||
In Read-only mode, go to **Admin** > **Settings** > **Remote sync** and click **Pull changes**.
|
||||
### Handling unsynced changes when you pull
|
||||
|
||||
### Handling unsynced changes
|
||||
If you have local uncommitted changes when you pull, Metabase lists them and asks what to do:
|
||||
|
||||
If you have local uncommitted changes when trying to pull or switch branches, Metabase will prompt you with options:
|
||||
|
||||
- **Push changes to the current branch:** Commit your changes first, then proceed.
|
||||
- **Create a new branch and push changes there:** Preserve your work on a new branch.
|
||||
- **Discard these changes:** Throw away your uncommitted changes to accept what's in Git.
|
||||
- **Merge the remote changes into your local content:** Only offered when the changes merge cleanly.
|
||||
- **Create a new branch and push changes there:** Saves your work to a new branch.
|
||||
- **Force push to [branch], discarding and overwriting everything (can't be undone):** Overwrites the remote branch with your local content.
|
||||
- **Delete unsynced changes (can't be undone):** Throws away your uncommitted changes to accept what's in Git.
|
||||
|
||||
When in doubt, create a new branch and push changes to that branch. That way you won't lose any work.
|
||||
|
||||
@@ -468,7 +460,7 @@ When in doubt, create a new branch and push changes to that branch. That way you
|
||||
In Read-only mode, you can set Metabase to auto-sync changes from your main branch.
|
||||
|
||||
1. Navigate to **Admin** > **Settings** > **Remote sync**.
|
||||
2. Enable Auto-sync with Git.
|
||||
2. Enable **Auto-sync with git**.
|
||||
|
||||
By default, Metabase will check for and pull changes from the branch you specify every five minutes. You can also manually sync as needed.
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ Type: Boolean.
|
||||
|
||||
Default: Data model is included.
|
||||
|
||||
Excludes [table metadata](../data-modeling/metadata/managing-tables.md) settings, which admins define in **Data Studio > Data structure**. Excluding the data model is useful for subsequent exports where you only want content changes.
|
||||
Excludes [table metadata](../data-modeling/metadata/managing-tables.md) settings, which admins define in **Data Studio > Connected data**. Excluding the data model is useful for subsequent exports where you only want content changes.
|
||||
|
||||
### Include field values
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ To return to the metric's home page, click the metric card in the search bar and
|
||||
|
||||
To open a **measure** in the metrics explorer:
|
||||
|
||||
1. Navigate to the measure in **Data studio > Tables > [Your table] > Measures**.
|
||||
1. Navigate to the measure in **Data Studio > Connected data > [Your table] > Measures**.
|
||||
2. Select your measure.
|
||||
3. On the measure's page, click **three dots** next to the measure's name and select **Explore**.
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ categories:
|
||||
url: "ai/settings"
|
||||
- name: "MCP server"
|
||||
url: "ai/mcp"
|
||||
- name: "MCP tools"
|
||||
url: "ai/mcp-tools"
|
||||
- name: "Agent API"
|
||||
url: "ai/agent-api"
|
||||
- name: "Agent-driven development"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
description: null
|
||||
archived: false
|
||||
slug: stub_database_collection
|
||||
archive_operation_id: null
|
||||
name: Stub Database Collection
|
||||
personal_owner_id: null
|
||||
type: null
|
||||
is_sample: false
|
||||
parent_id: null
|
||||
serdes/meta:
|
||||
- model: Collection
|
||||
id: StubDbCollection12345
|
||||
label: stub_database_collection
|
||||
archived_directly: null
|
||||
entity_id: StubDbCollection12345
|
||||
namespace: null
|
||||
created_at: '2026-10-05T09:00:00.000000-07:00'
|
||||
is_remote_synced: true
|
||||
@@ -0,0 +1,58 @@
|
||||
description: null
|
||||
archived: false
|
||||
collection_position: null
|
||||
source_card_id: null
|
||||
table_id:
|
||||
- Stub Postgres
|
||||
- public
|
||||
- products
|
||||
result_metadata: null
|
||||
card_schema: 22
|
||||
database_id: Stub Postgres
|
||||
enable_embedding: false
|
||||
collection_id: StubDbCollection12345
|
||||
query_type: query
|
||||
name: Stub Database Question
|
||||
document_id: null
|
||||
type: question
|
||||
creator_id: admin@metabase.test
|
||||
made_public_by_id: null
|
||||
embedding_params: null
|
||||
dataset_query:
|
||||
database: Stub Postgres
|
||||
type: query
|
||||
query:
|
||||
source-table:
|
||||
- Stub Postgres
|
||||
- public
|
||||
- products
|
||||
fields:
|
||||
- - field
|
||||
- - Stub Postgres
|
||||
- public
|
||||
- products
|
||||
- title
|
||||
- null
|
||||
order-by:
|
||||
- - asc
|
||||
- - field
|
||||
- - Stub Postgres
|
||||
- public
|
||||
- products
|
||||
- id
|
||||
- null
|
||||
limit: 1
|
||||
parameter_mappings: []
|
||||
serdes/meta:
|
||||
- model: Card
|
||||
id: StubDbQuestion1234567
|
||||
label: stub_database_question
|
||||
display: table
|
||||
archived_directly: false
|
||||
entity_id: StubDbQuestion1234567
|
||||
collection_preview: true
|
||||
visualization_settings: {}
|
||||
parameters: []
|
||||
dashboard_id: null
|
||||
created_at: '2026-10-05T09:00:00.000000-07:00'
|
||||
public_uuid: null
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CollectionPermission,
|
||||
CollectionPermissionsGraph,
|
||||
DataApp,
|
||||
Group,
|
||||
WritebackAction,
|
||||
} from "metabase-types/api";
|
||||
|
||||
@@ -33,7 +34,7 @@ export const fakeDataApp = (overrides: Partial<DataApp> = {}): DataApp => ({
|
||||
bundle_path: "dist/index.js",
|
||||
enabled: true,
|
||||
draft: false,
|
||||
resource_collection_id: null,
|
||||
resource_collection_id: 1,
|
||||
permission_group_id: null,
|
||||
table_ids: [],
|
||||
allowed_hosts: [],
|
||||
@@ -178,23 +179,47 @@ export function dataAppIframe(displayName: string) {
|
||||
return getIframeBody(`iframe[title="${displayName}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets every non-admin group's access to a collection through the permission graph of
|
||||
* the collection's namespace: an app's own collection lives in `data-apps`, whose graph
|
||||
* is separate from the default one. A namespace graph lists only the groups holding a
|
||||
* grant in it, so the groups come from the groups API, not from the graph.
|
||||
*/
|
||||
export function setDataAppCollectionAccess(
|
||||
collectionId: CollectionId,
|
||||
access: CollectionPermission,
|
||||
) {
|
||||
return cy
|
||||
.request<CollectionPermissionsGraph>("GET", "/api/collection/graph")
|
||||
.then(({ body: graph }) => {
|
||||
const groups = Object.fromEntries(
|
||||
Object.entries(graph.groups).map(([groupId, collections]) => [
|
||||
groupId,
|
||||
Number(groupId) === USER_GROUPS.ADMIN_GROUP
|
||||
? collections
|
||||
: { ...collections, [collectionId]: access },
|
||||
]),
|
||||
);
|
||||
.request<Collection>("GET", `/api/collection/${collectionId}`)
|
||||
.then(({ body: collection }) => {
|
||||
const namespace = collection.namespace ?? undefined;
|
||||
const graphUrl =
|
||||
namespace === undefined
|
||||
? "/api/collection/graph"
|
||||
: `/api/collection/graph?namespace=${namespace}`;
|
||||
|
||||
cy.request("PUT", "/api/collection/graph", { ...graph, groups });
|
||||
cy.request<Group[]>("GET", "/api/permissions/group").then(
|
||||
({ body: allGroups }) => {
|
||||
cy.request<CollectionPermissionsGraph>("GET", graphUrl).then(
|
||||
({ body: graph }) => {
|
||||
const groups = Object.fromEntries(
|
||||
allGroups
|
||||
.filter((group) => group.id !== USER_GROUPS.ADMIN_GROUP)
|
||||
.map((group) => [
|
||||
group.id,
|
||||
{ ...graph.groups[group.id], [collectionId]: access },
|
||||
]),
|
||||
);
|
||||
|
||||
cy.request("PUT", "/api/collection/graph", {
|
||||
...graph,
|
||||
...(namespace === undefined ? {} : { namespace }),
|
||||
groups,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,6 @@ export const DataModel = {
|
||||
getReplaceButton: getSourceReplacementReplaceButton,
|
||||
getCancelButton: getSourceReplacementCancelButton,
|
||||
getTargetPickerButton: getSourceReplacementTargetPickerButton,
|
||||
getDependentsTab: getSourceReplacementDependentsTab,
|
||||
getFindAndReplaceButton: getSourceReplacementFindAndReplaceButton,
|
||||
},
|
||||
};
|
||||
@@ -984,12 +983,6 @@ function getSourceReplacementTargetPickerButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function getSourceReplacementDependentsTab(count: number) {
|
||||
return getSourceReplacementModal().findByRole("tab", {
|
||||
name: new RegExp(`${count} items? will be changed`),
|
||||
});
|
||||
}
|
||||
|
||||
function getSourceReplacementFindAndReplaceButton() {
|
||||
return cy.findByRole("menuitem", { name: /Find and replace/ });
|
||||
}
|
||||
|
||||
@@ -119,10 +119,13 @@ export function downloadAndAssert({
|
||||
expect(res.statusCode).to.eq(200);
|
||||
expect(res.headers["content-type"]).to.include(expectedContentType);
|
||||
|
||||
if (assertParameters && req.body?.parameters) {
|
||||
expect(req.body.parameters).to.have.lengthOf(assertParameters.length);
|
||||
if (assertParameters) {
|
||||
const parameters = JSON.parse(
|
||||
new URLSearchParams(req.body).get("parameters") ?? "null",
|
||||
);
|
||||
expect(parameters).to.have.lengthOf(assertParameters.length);
|
||||
assertParameters.forEach((expectedParam, index) => {
|
||||
expect(req.body.parameters[index]).to.deep.include(expectedParam);
|
||||
expect(parameters[index]).to.deep.include(expectedParam);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -52,25 +52,6 @@ export function selectAllMetricsTab(): void {
|
||||
cy.findByRole("dialog").findByRole("tab", { name: "All" }).click();
|
||||
}
|
||||
|
||||
export interface AddMetricsToExplorationOptions {
|
||||
metrics: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick metrics through the "+ Metrics" picker.
|
||||
*/
|
||||
export function addMetricsToExploration({
|
||||
metrics,
|
||||
}: AddMetricsToExplorationOptions): void {
|
||||
cy.findByRole("button", { name: /Metrics/ }).click();
|
||||
cy.wait("@getDimensions");
|
||||
selectAllMetricsTab();
|
||||
for (const name of metrics) {
|
||||
cy.findByRole("checkbox", { name }).check({ force: true });
|
||||
}
|
||||
cy.findByRole("button", { name: "Add" }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the named timelines (one or many) through the "+ Events" modal.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,9 @@ export const SYNCED_TRANSFORMS_COLLECTION_FIXTURE_PATH =
|
||||
"/e2e/support/assets/example_synced_transforms_collection";
|
||||
export const SYNCED_LIBRARY_FIXTURE_PATH =
|
||||
Cypress.config("projectRoot") + "/e2e/support/assets/example_synced_library";
|
||||
export const SYNCED_STUB_DATABASE_FIXTURE_PATH =
|
||||
Cypress.config("projectRoot") +
|
||||
"/e2e/support/assets/example_synced_stub_database";
|
||||
|
||||
// Copy the sample synced collection from the fixture folder to the working directory
|
||||
export const copySyncedCollectionFixture = () => {
|
||||
@@ -44,6 +47,14 @@ export const copySyncedLibraryFixture = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Copy a synced collection whose card references a database the instance doesn't have
|
||||
export const copySyncedStubDatabaseFixture = () => {
|
||||
cy.task("copyDirectory", {
|
||||
source: SYNCED_STUB_DATABASE_FIXTURE_PATH,
|
||||
destination: LOCAL_GIT_PATH,
|
||||
});
|
||||
};
|
||||
|
||||
export const checkoutSyncedCollectionBranch = (branch: string) => {
|
||||
cy.exec("git -C " + LOCAL_GIT_PATH + ` checkout -b '${branch}'`);
|
||||
};
|
||||
|
||||
@@ -43,9 +43,9 @@ export function expectSearchResultContent({
|
||||
}
|
||||
|
||||
if (expectedSearchResult.collection) {
|
||||
cy.findAllByTestId("result-link-wrapper").first(() => {
|
||||
cy.findByText(expectedSearchResult.collection).should("exist");
|
||||
});
|
||||
cy.findAllByTestId("result-link-wrapper")
|
||||
.first()
|
||||
.should("contain.text", expectedSearchResult.collection);
|
||||
}
|
||||
if (expectedSearchResult.timestamp) {
|
||||
cy.findByTestId("revision-history-text").findByText(
|
||||
|
||||
@@ -111,21 +111,38 @@ describe("Embedding SDK: data-app dev diagnostics", () => {
|
||||
});
|
||||
|
||||
it("serves the report to shell agents, with cursor filtering", () => {
|
||||
// Retry the full read-then-cursor pair until no POST sneaks between
|
||||
// the two GETs. Under CPU throttling the reporter's batched timer can
|
||||
// jitter enough to land a POST in that gap, so a single-pass idle
|
||||
// guard before the first GET is not enough.
|
||||
const verifyStableCursor = (attempt = 0): Cypress.Chainable<void> => {
|
||||
return cy.request(DIAGNOSTICS_URL).then(({ body: report }) => {
|
||||
expect(report.manifest?.errors).to.have.length(0);
|
||||
expect(report.clients).to.be.gte(1);
|
||||
|
||||
return cy
|
||||
.request(`${DIAGNOSTICS_URL}?startEventId=${report.nextEventId}`)
|
||||
.then(({ body: filtered }) => {
|
||||
if (filtered.entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (attempt >= 20) {
|
||||
throw new Error("Cursor never stabilized");
|
||||
}
|
||||
return cy
|
||||
.wait(250, { log: false })
|
||||
.then(() => verifyStableCursor(attempt + 1));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
readDiagnosticsUntil(
|
||||
DIAGNOSTICS_URL,
|
||||
"a blocked-network entry and a healthy connection",
|
||||
(report) =>
|
||||
report.entries.some((entry) => entry.kind === "blocked-network") &&
|
||||
report.connection?.reachable === true,
|
||||
).then((report) => {
|
||||
expect(report.manifest?.errors).to.have.length(0);
|
||||
expect(report.clients).to.be.gte(1);
|
||||
|
||||
// A reader that has consumed everything sees an empty page, not a replay.
|
||||
cy.request(`${DIAGNOSTICS_URL}?startEventId=${report.nextEventId}`)
|
||||
.its("body.entries")
|
||||
.should("be.empty");
|
||||
});
|
||||
).then(() => verifyStableCursor());
|
||||
});
|
||||
|
||||
it("keeps the buffer across a page reload, with continuous event ids", () => {
|
||||
|
||||
@@ -300,7 +300,6 @@ function assertSuccessfullUpdateToast() {
|
||||
H.undoToastList()
|
||||
.last()
|
||||
.should("be.visible")
|
||||
.should("have.attr", "color", "feedback-positive")
|
||||
.should("contain.text", "Successfully updated");
|
||||
}
|
||||
|
||||
@@ -310,7 +309,6 @@ function assertSuccessfullDeleteToast() {
|
||||
H.undoToastList()
|
||||
.last()
|
||||
.should("be.visible")
|
||||
.should("have.attr", "color", "feedback-positive")
|
||||
.should("contain.text", "Successfully deleted");
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,9 @@ describe(
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", "/api/card/*").as("getModel");
|
||||
cy.intercept("GET", "/api/card/*/query_metadata").as(
|
||||
"getModelQueryMetadata",
|
||||
);
|
||||
cy.intercept("GET", "/api/action/*").as("getAction");
|
||||
|
||||
cy.intercept("PUT", "/api/action/*").as("updateAction");
|
||||
@@ -318,6 +321,10 @@ describe(
|
||||
cy.wait("@getModel");
|
||||
});
|
||||
|
||||
// The Actions menu renders only once the source table's metadata has
|
||||
// arrived, because it is gated on `supportsImplicitActions()`.
|
||||
cy.wait("@getModelQueryMetadata");
|
||||
|
||||
cy.findByTestId("model-actions-header")
|
||||
.findByLabelText("Actions")
|
||||
.click();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { QA_POSTGRES_PORT, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import {
|
||||
ADMIN_PERSONAL_COLLECTION_ID,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
import type {
|
||||
Collection,
|
||||
Database,
|
||||
RemoteSyncDependencyErrorResponse,
|
||||
} from "metabase-types/api";
|
||||
|
||||
@@ -31,6 +32,8 @@ const MONTHLY_DEPENDENT_NAME = "Monthly Summary";
|
||||
const SNIPPET_NAME = "active_users";
|
||||
const SNIPPET_QUESTION_NAME = "Snippet Dependent Question";
|
||||
const PERSONAL_QUESTION_NAME = "Personal Source Question";
|
||||
const STUB_DATABASE_NAME = "Stub Postgres";
|
||||
const STUB_QUESTION_NAME = "Stub Database Question";
|
||||
|
||||
const setup = (snapshot = "default") => {
|
||||
H.restore(snapshot);
|
||||
@@ -1289,6 +1292,64 @@ describe("Remote Sync", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stub databases", { tags: ["@external"] }, () => {
|
||||
beforeEach(() => {
|
||||
setup();
|
||||
});
|
||||
|
||||
it("imports a card on a missing database as a stub that runs once connected", () => {
|
||||
H.copySyncedStubDatabaseFixture();
|
||||
H.commitToRepo();
|
||||
H.configureGitAndPullChanges("read-write");
|
||||
|
||||
cy.log("the missing database is created as a stub");
|
||||
cy.visit("/admin/databases");
|
||||
cy.findByRole("link", { name: STUB_DATABASE_NAME })
|
||||
.closest("tr")
|
||||
.findByText("Stubbed")
|
||||
.should("be.visible");
|
||||
cy.findByRole("link", { name: STUB_DATABASE_NAME }).click();
|
||||
cy.findByTestId("database-connection-info-section")
|
||||
.findByText(
|
||||
"This database has placeholder connection details. Replace that with actual connection details to make this connection Active.",
|
||||
)
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("connect the stub database");
|
||||
cy.intercept("PUT", "/api/database/*").as("updateDatabase");
|
||||
cy.button("Edit connection details").click();
|
||||
cy.findByTestId("database-form").within(() => {
|
||||
cy.findByLabelText(/Host/).type("localhost");
|
||||
cy.findByLabelText(/Port/).type(String(QA_POSTGRES_PORT));
|
||||
cy.findByLabelText(/Database name/).type("sample");
|
||||
cy.findByLabelText(/Username/).type("metabase");
|
||||
cy.findByLabelText(/Password/).type("metasample123");
|
||||
});
|
||||
cy.button("Save changes").click();
|
||||
cy.wait("@updateDatabase");
|
||||
|
||||
cy.request<{ data: Database[] }>("GET", "/api/database").then(
|
||||
({ body }) => {
|
||||
const database = body.data.find(
|
||||
({ name }) => name === STUB_DATABASE_NAME,
|
||||
);
|
||||
expect(database?.is_stub).to.equal(false);
|
||||
H.waitForSyncToFinish({
|
||||
dbId: database?.id,
|
||||
tableName: "products",
|
||||
tableAlias: "productsTable",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cy.log("the imported card now runs against the connected database");
|
||||
cy.visit("/collection/root");
|
||||
H.goToSyncedCollection("Stub Database Collection");
|
||||
H.collectionTable().findByText(STUB_QUESTION_NAME).click();
|
||||
H.tableInteractive().findByText("Rustic Paper Wallet").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const ensureSyncedCollectionIsVisible = () => {
|
||||
|
||||
@@ -174,9 +174,11 @@ describe("scenarios > dashboard > chained filter", () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("scenarios > dashboard > chained filter > UUID primary key", () => {
|
||||
it(
|
||||
"should work for all field types (metabase#15170)",
|
||||
"should offer a UUID primary key column for an ID filter in 'Update a dashboard filter' click behavior (metabase#15170)",
|
||||
{ tags: "@external" },
|
||||
|
||||
() => {
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
const { H } = cy;
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
function filterDashboard(suggests = true) {
|
||||
function filterDashboard() {
|
||||
H.visitDashboard(ORDERS_DASHBOARD_ID);
|
||||
cy.contains("Orders");
|
||||
cy.contains("Text").click();
|
||||
H.filterWidget().click();
|
||||
|
||||
// We should get a suggested response and be able to click it if we're an admin
|
||||
if (suggests) {
|
||||
cy.findByPlaceholderText("Search the list").type("Main Street");
|
||||
cy.contains("100 Main Street").click();
|
||||
} else {
|
||||
cy.findByPlaceholderText("Search the list").type("100 Main Street").blur();
|
||||
cy.wait("@search").should(({ response }) => {
|
||||
expect(response.statusCode).to.equal(403);
|
||||
});
|
||||
}
|
||||
cy.contains("Add filter").click({ force: true });
|
||||
cy.contains("100 Main Street");
|
||||
cy.findByPlaceholderText("Search the list").type("Main Street");
|
||||
cy.contains("100 Main Street").click();
|
||||
|
||||
H.dashboardParametersPopover().button("Add filter").click();
|
||||
H.filterWidget().should("contain", "100 Main Street");
|
||||
cy.location("search").should((search) =>
|
||||
expect(new URLSearchParams(search).get("text")).to.eq("100 Main Street"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("support > permissions (metabase#8472)", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(
|
||||
"GET",
|
||||
`/api/dashboard/${ORDERS_DASHBOARD_ID}/params/*/search/*").as("search`,
|
||||
);
|
||||
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
@@ -35,6 +25,11 @@ describe("support > permissions (metabase#8472)", () => {
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
H.getDashboardCard(0)
|
||||
.realHover()
|
||||
.findByLabelText("Add a filter")
|
||||
.should("be.visible");
|
||||
|
||||
H.setFilter("Text or Category", "Is");
|
||||
|
||||
// Filter the first card by User Address
|
||||
@@ -47,16 +42,23 @@ describe("support > permissions (metabase#8472)", () => {
|
||||
cy.contains("Done").click();
|
||||
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Save").click();
|
||||
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Orders in a dashboard").click();
|
||||
});
|
||||
|
||||
it("should allow an admin user to select the filter", () => {
|
||||
it("should let admin and nodata users use the filter, and hide filter mapping from nodata users in edit mode", () => {
|
||||
filterDashboard();
|
||||
});
|
||||
|
||||
it("should allow a nodata user to select the filter", () => {
|
||||
cy.signIn("nodata");
|
||||
filterDashboard();
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
H.getDashboardCard(0)
|
||||
.realHover()
|
||||
.findByTestId("dashboardcard-actions-panel")
|
||||
.should("be.visible");
|
||||
H.getDashboardCard(0).findByLabelText("Add a filter").should("not.exist");
|
||||
|
||||
H.filterWidget({ isEditing: true }).click();
|
||||
H.getDashboardCard(0).icon("key").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("scenarios > dashboard > filters > reset", () => {
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should reset a filters value when editing the default", () => {
|
||||
it("should reset a filters value when editing the default, and leave other filters alone", () => {
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails: QUESTION,
|
||||
dashboardDetails: DASHBOARD,
|
||||
@@ -82,9 +82,7 @@ describe("scenarios > dashboard > filters > reset", () => {
|
||||
|
||||
cy.location("search").should("eq", "?filter_one=&filter_two=");
|
||||
|
||||
cy.log(
|
||||
"Finally, when we remove dashboard filter's default value, the url should reflect that by removing the placeholder",
|
||||
);
|
||||
cy.log("Replacing Filter Two's default value updates its value in the url");
|
||||
H.editDashboard();
|
||||
|
||||
H.filterWidget({ name: "Filter Two", isEditing: true }).click();
|
||||
@@ -104,47 +102,9 @@ describe("scenarios > dashboard > filters > reset", () => {
|
||||
|
||||
H.filterWidget().contains("Filter One").should("be.visible");
|
||||
H.filterWidget().contains("Foo").should("be.visible");
|
||||
});
|
||||
|
||||
it("should reset a filters value when editing the default, and leave other filters alone", () => {
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails: QUESTION,
|
||||
dashboardDetails: DASHBOARD,
|
||||
}).then(({ body: dashboardCard }) => {
|
||||
const { card_id, dashboard_id } = dashboardCard;
|
||||
|
||||
H.editDashboardCard(dashboardCard, {
|
||||
parameter_mappings: [
|
||||
{
|
||||
parameter_id: FILTER_ONE.id,
|
||||
card_id,
|
||||
target: ["dimension", ["field", PRODUCTS.CATEGORY, null]],
|
||||
},
|
||||
{
|
||||
parameter_id: FILTER_TWO.id,
|
||||
card_id,
|
||||
target: ["dimension", ["field", PRODUCTS.TITLE, null]],
|
||||
},
|
||||
],
|
||||
}).then(() => {
|
||||
H.visitDashboard(dashboard_id, {
|
||||
params: {
|
||||
filter_one: "",
|
||||
filter_two: "Bar",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cy.log("Default dashboard filter");
|
||||
|
||||
H.filterWidget().contains("Filter One").should("be.visible");
|
||||
H.filterWidget().contains("Bar").should("be.visible");
|
||||
|
||||
cy.location("search").should("eq", "?filter_one=&filter_two=Bar");
|
||||
|
||||
cy.log(
|
||||
"Finally, when we remove dashboard filter's default value, the url should reflect that by removing the placeholder",
|
||||
"Setting Filter One's default value leaves Filter Two's value alone",
|
||||
);
|
||||
H.editDashboard();
|
||||
|
||||
@@ -154,15 +114,15 @@ describe("scenarios > dashboard > filters > reset", () => {
|
||||
cy.findByLabelText("Input box").click();
|
||||
});
|
||||
|
||||
setDefaultFilterValue("Foo");
|
||||
setDefaultFilterValue("Baz");
|
||||
|
||||
cy.location("search").should("eq", "?filter_one=Foo&filter_two=Bar");
|
||||
cy.location("search").should("eq", "?filter_one=Baz&filter_two=Foo");
|
||||
|
||||
H.saveDashboard();
|
||||
|
||||
cy.location("search").should("eq", "?filter_one=Foo&filter_two=Bar");
|
||||
cy.location("search").should("eq", "?filter_one=Baz&filter_two=Foo");
|
||||
|
||||
H.filterWidget().contains("Filter One").should("be.visible");
|
||||
H.filterWidget().contains("Baz").should("be.visible");
|
||||
H.filterWidget().contains("Foo").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ describe("scenarios > dashboard > filters > query stages", () => {
|
||||
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
cy.intercept("GET", "/api/dashboard/**").as("getDashboard");
|
||||
cy.intercept("PUT", "/api/dashboard/**").as("updateDashboard");
|
||||
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
|
||||
"dashboardData",
|
||||
);
|
||||
@@ -37,7 +36,7 @@ describe("scenarios > dashboard > filters > query stages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows to map to all relevant columns", () => {
|
||||
it("allows to map to all relevant columns and applies a 1st stage implicit join (data source) filter", () => {
|
||||
H.editDashboard();
|
||||
|
||||
cy.log("## date columns");
|
||||
@@ -52,6 +51,25 @@ describe("scenarios > dashboard > filters > query stages", () => {
|
||||
QSHelpers.getFilter("Number").click();
|
||||
verifyNumberMappingOptions();
|
||||
|
||||
H.dashboardCancelButton().click();
|
||||
|
||||
cy.log("## 1st stage implicit join (data source)");
|
||||
QSHelpers.setup1stStageImplicitJoinFromSourceFilter();
|
||||
|
||||
QSHelpers.verifyDashcardRowsCount({
|
||||
dashcardIndex: 0,
|
||||
dashboardCount: 1044,
|
||||
queryBuilderCount: "Showing 1,044 rows",
|
||||
});
|
||||
|
||||
QSHelpers.goBackToDashboard();
|
||||
|
||||
QSHelpers.verifyDashcardRowsCount({
|
||||
dashcardIndex: 1,
|
||||
dashboardCount: 1044,
|
||||
queryBuilderCount: "Showing 1,044 rows",
|
||||
});
|
||||
|
||||
function verifyDateMappingOptions() {
|
||||
QSHelpers.verifyDashcardMappingOptions(
|
||||
QSHelpers.QUESTION_BASED_QUESTION_INDEX,
|
||||
@@ -311,24 +329,6 @@ describe("scenarios > dashboard > filters > query stages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("1st stage implicit join (data source)", () => {
|
||||
QSHelpers.setup1stStageImplicitJoinFromSourceFilter();
|
||||
|
||||
QSHelpers.verifyDashcardRowsCount({
|
||||
dashcardIndex: 0,
|
||||
dashboardCount: 1044,
|
||||
queryBuilderCount: "Showing 1,044 rows",
|
||||
});
|
||||
|
||||
QSHelpers.goBackToDashboard();
|
||||
|
||||
QSHelpers.verifyDashcardRowsCount({
|
||||
dashcardIndex: 1,
|
||||
dashboardCount: 1044,
|
||||
queryBuilderCount: "Showing 1,044 rows",
|
||||
});
|
||||
});
|
||||
|
||||
it("1st stage implicit join (joined data source)", () => {
|
||||
QSHelpers.setup1stStageImplicitJoinFromJoinFilter();
|
||||
|
||||
@@ -369,7 +369,7 @@ describe("scenarios > dashboard > filters > query stages", () => {
|
||||
QSHelpers.setup1stStageAggregationFilter();
|
||||
|
||||
QSHelpers.verifyDashcardRowsCount({
|
||||
dashcardIndex: 1,
|
||||
dashcardIndex: 0,
|
||||
dashboardCount: 3,
|
||||
queryBuilderCount: "Showing 3 rows",
|
||||
});
|
||||
@@ -31,8 +31,6 @@ function createDashboardDetails({ parameters }) {
|
||||
};
|
||||
}
|
||||
|
||||
const TOAST_TIMEOUT = 16000;
|
||||
|
||||
const filterToggleLabel = "Auto-apply filters";
|
||||
|
||||
describe(
|
||||
@@ -117,7 +115,7 @@ describe(
|
||||
cy.get("@cardQuery.all").should("have.length", 4);
|
||||
|
||||
cy.log(
|
||||
"last applied parameter values should be used when disabling auto applying filters, even if previously there were draft parameter values",
|
||||
"parameter values applied while auto-applying filters should be preserved when disabling auto applying filters",
|
||||
);
|
||||
H.filterWidget().findByText("Widget").click();
|
||||
H.popover().within(() => {
|
||||
@@ -134,13 +132,14 @@ describe(
|
||||
H.closeDashboardSettingsSidebar();
|
||||
|
||||
H.filterWidget().findByText("2 selections").should("be.visible");
|
||||
H.assertTableRowsCount(107);
|
||||
cy.get("@cardQuery.all").should("have.length", 5);
|
||||
|
||||
cy.get("@updateDashboardSpy").should("have.callCount", 3);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not save filter state for dashboard parameter w/o auto-apply enabled", () => {
|
||||
it("should not save unapplied filter state and allow resetting it", () => {
|
||||
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
|
||||
openDashboard();
|
||||
|
||||
@@ -156,16 +155,16 @@ describe(
|
||||
cy.log("verify filter value is not saved");
|
||||
|
||||
H.visitDashboard("@dashboardId");
|
||||
H.filterWidget().should("not.contain", "Gadget");
|
||||
});
|
||||
H.filterWidget()
|
||||
.should("contain", FILTER.name)
|
||||
.and("not.contain", "Gadget");
|
||||
|
||||
it("should allow resetting unapplied filter state", () => {
|
||||
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
|
||||
openDashboard();
|
||||
cy.log("verify unapplied filter value can be reset");
|
||||
|
||||
H.filterWidget().findByText(FILTER.name).click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Gadget").click();
|
||||
cy.findByLabelText("Gadget").click();
|
||||
cy.findByLabelText("Gadget").should("be.checked");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -177,12 +176,12 @@ describe(
|
||||
|
||||
H.filterWidget().findByText(FILTER.name).click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Gadget").should("not.be.checked");
|
||||
cy.findByLabelText("Gadget").should("not.be.checked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modifying dashboard and dashboard cards", () => {
|
||||
it("should not preserve draft parameter values when editing the dashboard", () => {
|
||||
it("should preserve draft parameter values when editing is cancelled but not when the dashboard is saved", () => {
|
||||
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
|
||||
openDashboard();
|
||||
|
||||
@@ -193,6 +192,13 @@ describe(
|
||||
});
|
||||
H.applyFilterButton().should("be.visible");
|
||||
|
||||
cy.log("cancel editing");
|
||||
H.editDashboard();
|
||||
cy.findByTestId("edit-bar").button("Cancel").click();
|
||||
H.filterWidget().findByText("Gadget").should("be.visible");
|
||||
H.applyFilterButton().should("be.visible");
|
||||
|
||||
cy.log("edit and save the dashboard");
|
||||
H.editDashboard();
|
||||
|
||||
H.setFilter("Text or Category", "Is");
|
||||
@@ -213,25 +219,6 @@ describe(
|
||||
});
|
||||
});
|
||||
|
||||
describe("modify nothing", () => {
|
||||
it("should preserve draft parameter values when editing of the dashboard was cancelled", () => {
|
||||
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
|
||||
openDashboard();
|
||||
|
||||
H.filterWidget().findByText(FILTER.name).click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Gadget").click();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
H.applyFilterButton().should("be.visible");
|
||||
|
||||
H.editDashboard();
|
||||
cy.findByTestId("edit-bar").button("Cancel").click();
|
||||
H.filterWidget().findByText("Gadget").should("be.visible");
|
||||
H.applyFilterButton().should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parameter with default values", () => {
|
||||
beforeEach(() => {
|
||||
createDashboard({ parameter: FILTER_WITH_DEFAULT_VALUE });
|
||||
@@ -293,12 +280,12 @@ describe(
|
||||
cy.signIn("readonly");
|
||||
});
|
||||
|
||||
it("should not be able to toggle auto-apply filters toggle", () => {
|
||||
it("should not show dashboard settings with the auto-apply filters toggle", () => {
|
||||
openDashboard();
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
// shouldn't even show settings as an option for this user
|
||||
H.dashboardHeader().icon("ellipsis").click();
|
||||
H.popover().findByText("Enter fullscreen").should("be.visible");
|
||||
H.popover().findByText("Edit settings").should("not.exist");
|
||||
});
|
||||
});
|
||||
@@ -329,17 +316,6 @@ describe(
|
||||
H.assertTableRowsCount(54);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show toast", () => {
|
||||
createDashboard();
|
||||
cy.clock();
|
||||
openSlowPublicDashboard({ [FILTER.slug]: "Gadget" });
|
||||
H.filterWidget().findByText("Gadget").should("be.visible");
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("signed embeds", () => {
|
||||
@@ -375,25 +351,6 @@ describe(
|
||||
H.assertTableRowsCount(54);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show toast", () => {
|
||||
createDashboard({
|
||||
dashboardDetails: {
|
||||
enable_embedding: true,
|
||||
embedding_params: {
|
||||
[FILTER.slug]: "enabled",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cy.clock();
|
||||
openSlowEmbeddingDashboard({ [FILTER.slug]: "Gadget" });
|
||||
H.filterWidget().findByText("Gadget").should("be.visible");
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("full-app embeddings", () => {
|
||||
@@ -452,7 +409,7 @@ describe("scenarios > dashboards > filters > auto apply", () => {
|
||||
H.expectNoBadSnowplowEvents();
|
||||
});
|
||||
|
||||
it("should send snowplow events when disabling auto-apply filters", () => {
|
||||
it("should send snowplow events only when disabling auto-apply filters", () => {
|
||||
createDashboard();
|
||||
openDashboard();
|
||||
cy.wait("@cardQuery");
|
||||
@@ -465,20 +422,12 @@ describe("scenarios > dashboards > filters > auto apply", () => {
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "auto_apply_filters_disabled",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not send snowplow events when enabling auto-apply filters", () => {
|
||||
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
|
||||
openDashboard();
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
H.openDashboardSettingsSidebar();
|
||||
H.sidesheet().within(() => {
|
||||
cy.log("enabling auto-apply filters should not send an event");
|
||||
cy.findByLabelText(filterToggleLabel).click();
|
||||
cy.wait("@updateDashboard");
|
||||
cy.findByLabelText(filterToggleLabel).should("be.checked");
|
||||
H.assertNoUnstructuredSnowplowEvent({
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "auto_apply_filters_disabled",
|
||||
});
|
||||
});
|
||||
@@ -520,36 +469,6 @@ const openDashboard = (params = {}) => {
|
||||
H.visitDashboard("@dashboardId", { params });
|
||||
};
|
||||
|
||||
const openSlowPublicDashboard = (params = {}) => {
|
||||
cy.intercept("GET", "/api/public/dashboard/*/dashcard/*/card/*").as(
|
||||
"cardQuery",
|
||||
);
|
||||
|
||||
cy.get("@dashboardId").then((dashboardId) => {
|
||||
H.visitPublicDashboard(dashboardId, { params });
|
||||
});
|
||||
|
||||
H.getDashboardCard().should("be.visible");
|
||||
};
|
||||
|
||||
const openSlowEmbeddingDashboard = (params = {}) => {
|
||||
cy.intercept("GET", "/api/embed/dashboard/*/dashcard/*/card/*").as(
|
||||
"cardQuery",
|
||||
);
|
||||
|
||||
cy.get("@dashboardId").then((dashboardId) => {
|
||||
const embeddingPayload = {
|
||||
resource: { dashboard: dashboardId },
|
||||
params: {},
|
||||
};
|
||||
H.visitEmbeddedPage(embeddingPayload, {
|
||||
setFilters: params,
|
||||
});
|
||||
});
|
||||
|
||||
H.getDashboardCard().should("be.visible");
|
||||
};
|
||||
|
||||
const visitFullAppEmbeddingUrl = ({ url, qs }) => {
|
||||
cy.visit({
|
||||
url,
|
||||
|
||||
@@ -29,7 +29,6 @@ describe("dashboard filters auto-wiring", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
|
||||
});
|
||||
|
||||
describe("parameter mapping", () => {
|
||||
@@ -48,6 +47,7 @@ describe("dashboard filters auto-wiring", () => {
|
||||
cy.findByText("User.Name").should("exist");
|
||||
});
|
||||
|
||||
H.getDashboardCard(1).findByText("Select…").should("exist");
|
||||
H.getDashboardCard(1).findByText("User.Name").should("not.exist");
|
||||
|
||||
H.undoToast()
|
||||
@@ -77,9 +77,35 @@ describe("dashboard filters auto-wiring", () => {
|
||||
cy.findByText("Auto-connected").should("not.exist");
|
||||
cy.icon("sparkles").should("not.exist");
|
||||
});
|
||||
|
||||
cy.log("verify auto-wire suggestion when a card is replaced");
|
||||
|
||||
goToFilterMapping();
|
||||
|
||||
H.getDashboardCard(1)
|
||||
.realHover({ scrollBehavior: "bottom" })
|
||||
.findByLabelText("Replace")
|
||||
.click();
|
||||
|
||||
H.modal().findByText("Orders, Count").click();
|
||||
|
||||
H.undoToastList()
|
||||
.contains("Auto-connect “Orders, Count” to “Text”?")
|
||||
.closest("[data-testid='toast-undo']")
|
||||
.button("Auto-connect")
|
||||
.click();
|
||||
|
||||
H.undoToastList().should(
|
||||
"contain",
|
||||
"“Orders, Count” was auto-connected to “Text”.",
|
||||
);
|
||||
|
||||
goToFilterMapping();
|
||||
|
||||
H.getDashboardCard(1).findByText("User.Name").should("exist");
|
||||
});
|
||||
|
||||
it("should not wire parameters to cards that already have a parameter, despite matching fields", () => {
|
||||
it("should undo auto-wiring and not wire parameters to cards that already have a parameter, despite matching fields", () => {
|
||||
createDashboardWithCards({ cards }).then((dashboardId) => {
|
||||
H.visitDashboard(dashboardId);
|
||||
});
|
||||
@@ -102,9 +128,14 @@ describe("dashboard filters auto-wiring", () => {
|
||||
.findByRole("button", { name: "Auto-connect" })
|
||||
.click();
|
||||
|
||||
H.getDashboardCard(1).within(() => {
|
||||
cy.findByLabelText("close icon").click();
|
||||
});
|
||||
H.getDashboardCard(0).findByText("User.Name").should("exist");
|
||||
H.getDashboardCard(1).findByText("User.Name").should("exist");
|
||||
|
||||
cy.log("verify undo functionality");
|
||||
H.undoToast().findByRole("button", { name: "Undo" }).click();
|
||||
|
||||
H.getDashboardCard(0).findByText("User.Name").should("exist");
|
||||
H.getDashboardCard(1).findByText("Select…").should("exist");
|
||||
|
||||
H.selectDashboardFilter(H.getDashboardCard(1), "Address");
|
||||
|
||||
@@ -116,7 +147,7 @@ describe("dashboard filters auto-wiring", () => {
|
||||
cy.findByText("User.Address").should("exist");
|
||||
});
|
||||
|
||||
H.undoToast().should("contain", "Undo");
|
||||
H.undoToast().should("not.exist");
|
||||
});
|
||||
|
||||
it("should not suggest to wire parameters to cards that don't have a matching field", () => {
|
||||
@@ -152,41 +183,12 @@ describe("dashboard filters auto-wiring", () => {
|
||||
|
||||
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
|
||||
|
||||
H.getDashboardCard(0).findByText("User.Name").should("exist");
|
||||
H.undoToast().should("not.exist");
|
||||
});
|
||||
|
||||
it("should undo parameter wiring when 'Undo' is clicked", () => {
|
||||
createDashboardWithCards({ cards }).then((dashboardId) => {
|
||||
H.visitDashboard(dashboardId);
|
||||
});
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
H.setFilter("Text or Category", "Is");
|
||||
addCardToDashboard();
|
||||
goToFilterMapping();
|
||||
|
||||
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
|
||||
|
||||
H.undoToast().findByRole("button", { name: "Auto-connect" }).click();
|
||||
|
||||
H.getDashboardCard(0).findByText("User.Name").should("exist");
|
||||
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
H.getDashboardCard(i).findByText("User.Name").should("exist");
|
||||
}
|
||||
|
||||
H.undoToast().findByRole("button", { name: "Undo" }).click();
|
||||
|
||||
H.getDashboardCard(0).findByText("User.Name").should("exist");
|
||||
for (let i = 1; i < cards.length; i++) {
|
||||
H.getDashboardCard(i).findByText("Select…").should("exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("in case of two auto-wiring undo toast, the second one should last the default timeout of 12s", () => {
|
||||
// The auto-wiring undo toasts use the same id, a bug in the undo logic caused the second toast to be dismissed by the
|
||||
// timeout set by the first. See https://github.com/metabase/metabase/pull/35461#pullrequestreview-1731776862
|
||||
it("should dismiss suggestion toasts after 12s, restarting for a second suggestion, and the undo toast after 8s", () => {
|
||||
// A second auto-wiring suggestion toast must not be dismissed by the timeout set for the first one.
|
||||
const cardTemplate = {
|
||||
card_id: ORDERS_BY_YEAR_QUESTION_ID,
|
||||
row: 0,
|
||||
@@ -234,6 +236,21 @@ describe("dashboard filters auto-wiring", () => {
|
||||
|
||||
cy.tick(2000);
|
||||
H.undoToast().should("not.exist");
|
||||
|
||||
cy.log("verify the undo toast after auto-connect lasts 8s");
|
||||
|
||||
removeFilterFromDashCard(0);
|
||||
|
||||
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
|
||||
cy.tick(1000);
|
||||
|
||||
H.undoToast().findByRole("button", { name: "Auto-connect" }).click();
|
||||
|
||||
cy.tick(7000);
|
||||
H.undoToast().should("exist");
|
||||
|
||||
cy.tick(2000);
|
||||
H.undoToast().should("not.exist");
|
||||
});
|
||||
|
||||
describe("multiple tabs", () => {
|
||||
@@ -274,7 +291,7 @@ describe("dashboard filters auto-wiring", () => {
|
||||
});
|
||||
|
||||
describe("add a card", () => {
|
||||
it("should wire parameters to cards that are added to the dashboard", () => {
|
||||
it("should wire parameters to cards that are added to the dashboard and undo it", () => {
|
||||
createDashboardWithCards({ cards }).then((dashboardId) => {
|
||||
H.visitDashboard(dashboardId);
|
||||
});
|
||||
@@ -291,8 +308,7 @@ describe("dashboard filters auto-wiring", () => {
|
||||
}
|
||||
|
||||
addCardToDashboard();
|
||||
|
||||
cy.log("verify toast text and enable auto-connect");
|
||||
goToFilterMapping();
|
||||
|
||||
H.undoToastList()
|
||||
.eq(1)
|
||||
@@ -300,49 +316,10 @@ describe("dashboard filters auto-wiring", () => {
|
||||
.findByRole("button", { name: "Auto-connect" })
|
||||
.click();
|
||||
|
||||
cy.log("verify toast text after auto-connect");
|
||||
|
||||
H.undoToastList()
|
||||
.eq(1)
|
||||
.should("contain", "“Orders Model” was auto-connected to “Text”.");
|
||||
|
||||
goToFilterMapping();
|
||||
|
||||
for (let i = 0; i < cards.length + 1; i++) {
|
||||
H.getDashboardCard(i).findByText("User.Name").should("exist");
|
||||
}
|
||||
|
||||
H.undoToastList()
|
||||
.eq(1)
|
||||
.findByText("“Orders Model” was auto-connected to “Text”.")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should undo parameter wiring when 'Undo' is clicked", () => {
|
||||
createDashboardWithCards({ cards }).then((dashboardId) => {
|
||||
H.visitDashboard(dashboardId);
|
||||
});
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
H.setFilter("Text or Category", "Is");
|
||||
|
||||
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
|
||||
H.undoToast().findByRole("button", { name: "Auto-connect" }).click();
|
||||
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
H.getDashboardCard(i).findByText("User.Name").should("exist");
|
||||
}
|
||||
|
||||
addCardToDashboard();
|
||||
goToFilterMapping();
|
||||
|
||||
H.undoToastList()
|
||||
.eq(1)
|
||||
.should("contain", "Auto-connect “Orders Model” to “Text”?")
|
||||
.findByRole("button", { name: "Auto-connect" })
|
||||
.click();
|
||||
|
||||
for (let i = 0; i < cards.length + 1; i++) {
|
||||
H.getDashboardCard(i).findByText("User.Name").should("exist");
|
||||
}
|
||||
@@ -414,49 +391,8 @@ describe("dashboard filters auto-wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace a card", () => {
|
||||
it("should show auto-wire suggestion toast when a card is replaced", () => {
|
||||
createDashboardWithCards({ cards }).then((dashboardId) => {
|
||||
H.visitDashboard(dashboardId);
|
||||
});
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
H.setFilter("Text or Category", "Is");
|
||||
|
||||
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
|
||||
|
||||
H.undoToast().findByText("Auto-connect").click();
|
||||
|
||||
goToFilterMapping();
|
||||
|
||||
H.getDashboardCard(1)
|
||||
.realHover({ scrollBehavior: "bottom" })
|
||||
.findByLabelText("Replace")
|
||||
.click();
|
||||
|
||||
H.modal().findByText("Orders, Count").click();
|
||||
|
||||
H.undoToastList()
|
||||
.eq(2)
|
||||
.should("contain", "Auto-connect “Orders, Count” to “Text”?")
|
||||
.button("Auto-connect")
|
||||
.click();
|
||||
|
||||
H.undoToastList()
|
||||
.eq(2)
|
||||
.should("contain", "“Orders, Count” was auto-connected to “Text”.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding cards with foreign keys to the dashboard (metabase#36275)", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(
|
||||
"POST",
|
||||
"/api/dashboard/*/dashcard/*/card/*/query",
|
||||
cy.spy().as("cardQueryRequest"),
|
||||
).as("cardQuery");
|
||||
|
||||
H.createQuestion({
|
||||
name: "Products Question",
|
||||
query: { "source-table": PRODUCTS_ID, limit: 1 },
|
||||
@@ -497,8 +433,6 @@ describe("dashboard filters auto-wiring", () => {
|
||||
|
||||
addCardToDashboard(["Orders Question", "Reviews Question"]);
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
goToFilterMapping("ID");
|
||||
|
||||
// The two auto-wire suggestion toasts stack and briefly overlap while the
|
||||
@@ -526,23 +460,27 @@ describe("dashboard filters auto-wiring", () => {
|
||||
|
||||
H.dashboardParametersContainer().findByText("ID").click();
|
||||
|
||||
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
|
||||
"filteredCardQuery",
|
||||
);
|
||||
|
||||
H.dashboardParametersPopover().within(() => {
|
||||
H.fieldValuesCombobox().type("1,");
|
||||
H.fieldValuesCombobox().type("3,");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
cy.wait("@filteredCardQuery");
|
||||
|
||||
H.getDashboardCard(0).within(() => {
|
||||
getTableCell("ID", 0).should("contain", "1");
|
||||
getTableCell("ID", 0).should("have.text", "3");
|
||||
});
|
||||
|
||||
H.getDashboardCard(1).within(() => {
|
||||
getTableCell("Product ID", 0).should("contain", "1");
|
||||
getTableCell("Product ID", 0).should("have.text", "3");
|
||||
});
|
||||
|
||||
H.getDashboardCard(2).within(() => {
|
||||
getTableCell("Product ID", 0).should("contain", "1");
|
||||
getTableCell("Product ID", 0).should("have.text", "3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -564,8 +502,6 @@ describe("dashboard filters auto-wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
goToFilterMapping("ID");
|
||||
|
||||
H.getDashboardCard(0).findByText("Products.ID").should("exist");
|
||||
@@ -576,23 +512,27 @@ describe("dashboard filters auto-wiring", () => {
|
||||
|
||||
H.dashboardParametersContainer().findByText("ID").click();
|
||||
|
||||
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
|
||||
"filteredCardQuery",
|
||||
);
|
||||
|
||||
H.dashboardParametersPopover().within(() => {
|
||||
H.fieldValuesCombobox().type("1,");
|
||||
H.fieldValuesCombobox().type("3,");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
cy.wait("@filteredCardQuery");
|
||||
|
||||
H.getDashboardCard(0).within(() => {
|
||||
getTableCell("ID", 0).should("contain", "1");
|
||||
getTableCell("ID", 0).should("have.text", "3");
|
||||
});
|
||||
|
||||
H.getDashboardCard(1).within(() => {
|
||||
getTableCell("Product ID", 0).should("contain", "1");
|
||||
getTableCell("Product ID", 0).should("have.text", "3");
|
||||
});
|
||||
|
||||
H.getDashboardCard(2).within(() => {
|
||||
getTableCell("Product ID", 0).should("contain", "1");
|
||||
getTableCell("Product ID", 0).should("have.text", "3");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -645,42 +585,6 @@ describe("dashboard filters auto-wiring", () => {
|
||||
.should("have.length", 1)
|
||||
.should("contain", "Removed card");
|
||||
});
|
||||
|
||||
it("should dismiss toasts on timeout", () => {
|
||||
createDashboardWithCards({ cards }).then((dashboardId) => {
|
||||
H.visitDashboard(dashboardId);
|
||||
});
|
||||
|
||||
H.editDashboard();
|
||||
H.setFilter("Text or Category", "Is");
|
||||
|
||||
cy.clock();
|
||||
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
|
||||
|
||||
cy.tick(1000);
|
||||
|
||||
H.undoToast().should("be.visible");
|
||||
|
||||
// AUTO_WIRE_TOAST_TIMEOUT
|
||||
cy.tick(12000);
|
||||
|
||||
H.undoToast().should("not.exist");
|
||||
|
||||
removeFilterFromDashCard(0);
|
||||
|
||||
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
|
||||
cy.tick(1000);
|
||||
|
||||
// cy.clock();
|
||||
H.undoToast().findByRole("button", { name: "Auto-connect" }).click();
|
||||
|
||||
cy.tick(1000);
|
||||
H.undoToast().should("be.visible");
|
||||
|
||||
// AUTO_WIRE_UNDO_TOAST_TIMEOUT
|
||||
cy.tick(8000);
|
||||
H.undoToast().should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should auto-wire a new card to correct parameter targets (metabase#44720)", () => {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
const { H } = cy;
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
|
||||
const { ORDERS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
describe("dashboard filters values source config clearing and restoring", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
});
|
||||
|
||||
it("should clear and restore parameter static-list values when the type changes", () => {
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails: {
|
||||
display: "scalar",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
},
|
||||
}).then(({ body: { dashboard_id } }) => {
|
||||
H.visitDashboard(dashboard_id);
|
||||
|
||||
H.editDashboard();
|
||||
H.setFilter("Number", "Equal to", "Foo");
|
||||
mapFilterToQuestion();
|
||||
H.setFilterListSource({
|
||||
values: [["10", "Ten"], ["20", "Twenty"], "30"],
|
||||
});
|
||||
H.saveDashboard();
|
||||
|
||||
H.editDashboard();
|
||||
editFilter("Foo");
|
||||
|
||||
editFilterType("Text or Category", "Is");
|
||||
H.checkFilterListSourceHasValue({ values: [] });
|
||||
|
||||
mapFilterToQuestion("Email");
|
||||
setFilterSourceFromConnectedFields();
|
||||
|
||||
editFilterType("Number", "Equal to");
|
||||
H.checkFilterListSourceHasValue({
|
||||
values: [["10", "Ten"], ["20", "Twenty"], "30"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setFilterSourceFromConnectedFields() {
|
||||
H.sidebar().findByText("Edit").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("From connected fields").click();
|
||||
cy.button("Done").click();
|
||||
});
|
||||
}
|
||||
|
||||
const mapFilterToQuestion = (column = "Quantity") => {
|
||||
cy.findByText("Select…").click();
|
||||
H.popover().within(() => cy.findByText(column).click());
|
||||
};
|
||||
|
||||
function editFilter(name: string) {
|
||||
cy.findByTestId("edit-dashboard-parameters-widget-container")
|
||||
.findByText(name)
|
||||
.click();
|
||||
}
|
||||
|
||||
function editFilterType(type: string, subType: string) {
|
||||
H.sidebar().findByText("Filter or parameter type").next().click();
|
||||
H.selectDropdown().findByText(type).click();
|
||||
|
||||
H.sidebar().findByText("Filter operator").next().click();
|
||||
H.selectDropdown().findByText(subType).click();
|
||||
}
|
||||
@@ -110,13 +110,16 @@ describe("scenarios > dashboard > filters > management", () => {
|
||||
});
|
||||
|
||||
describe("change parameter type", () => {
|
||||
it("should reset existing filter mappings and default value", () => {
|
||||
it("should reset existing filter mappings and default value, and preselect the default operator for every type", () => {
|
||||
createDashboardWithFilterAndQuestionMapped();
|
||||
|
||||
selectFilter("Text");
|
||||
|
||||
H.getDashboardCard().should("contain", "People.Name");
|
||||
|
||||
cy.log("verify Text default value: Is");
|
||||
H.sidebar().findByDisplayValue("Is").should("exist");
|
||||
|
||||
cy.log("change filter type");
|
||||
|
||||
H.sidebar().within(() => {
|
||||
@@ -131,26 +134,12 @@ describe("scenarios > dashboard > filters > management", () => {
|
||||
// verifies no default value
|
||||
cy.findByText("No default").should("exist");
|
||||
});
|
||||
H.getDashboardCard().should("not.contain", "People.Name");
|
||||
|
||||
H.saveDashboard();
|
||||
|
||||
H.filterWidget().should("not.exist");
|
||||
});
|
||||
|
||||
it("should preselect default value for every type of filter", () => {
|
||||
createDashboardWithFilterAndQuestionMapped();
|
||||
|
||||
selectFilter("Text");
|
||||
|
||||
cy.log("verify Text default value: Is");
|
||||
H.sidebar().findByDisplayValue("Is").should("exist");
|
||||
|
||||
changeFilterType("Number");
|
||||
|
||||
cy.log("verify Number default value: Between");
|
||||
cy.log("verify Number default value: Equal to");
|
||||
verifyOperatorValue("Equal to");
|
||||
|
||||
H.getDashboardCard().should("not.contain", "People.Name");
|
||||
|
||||
changeFilterType("ID");
|
||||
|
||||
cy.log("verify ID default value: Numeric ID");
|
||||
@@ -163,72 +152,15 @@ describe("scenarios > dashboard > filters > management", () => {
|
||||
|
||||
changeFilterType("Location");
|
||||
|
||||
cy.log("verify Date default value: Is");
|
||||
cy.log("verify Location default value: Is");
|
||||
verifyOperatorValue("Is");
|
||||
|
||||
H.saveDashboard();
|
||||
|
||||
H.filterWidget().should("not.exist");
|
||||
});
|
||||
|
||||
it("should use saved parameter value when user switches back to the saved filter type", () => {
|
||||
const textFilter = createMockParameter({
|
||||
name: "Text Text",
|
||||
slug: "string",
|
||||
id: "5aefc726",
|
||||
type: "string/does-not-contain",
|
||||
sectionId: "string",
|
||||
});
|
||||
|
||||
const peopleQuestionDetails = {
|
||||
query: { "source-table": PEOPLE_ID, limit: 5 },
|
||||
};
|
||||
|
||||
H.createDashboardWithQuestions({
|
||||
dashboardDetails: {
|
||||
parameters: [textFilter],
|
||||
},
|
||||
questions: [peopleQuestionDetails],
|
||||
}).then(({ dashboard, questions: cards }) => {
|
||||
const [peopleCard] = cards;
|
||||
|
||||
H.updateDashboardCards({
|
||||
dashboard_id: dashboard.id,
|
||||
cards: [
|
||||
{
|
||||
card_id: peopleCard.id,
|
||||
parameter_mappings: [
|
||||
{
|
||||
parameter_id: textFilter.id,
|
||||
card_id: peopleCard.id,
|
||||
target: ["dimension", ["field", PEOPLE.NAME, null]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
H.visitDashboard(dashboard.id);
|
||||
});
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
selectFilter("Text Text");
|
||||
|
||||
H.sidebar().findByDisplayValue("Does not contain").should("exist");
|
||||
|
||||
changeFilterType("Number");
|
||||
|
||||
// default value for a number type
|
||||
H.sidebar().findByDisplayValue("Equal to").should("exist");
|
||||
|
||||
changeFilterType("Text or Category");
|
||||
|
||||
cy.log("verify the saved parameter value is restored");
|
||||
H.sidebar().within(() => {
|
||||
cy.findByDisplayValue("Does not contain").should("exist");
|
||||
cy.findByDisplayValue("Text or Category").should("exist");
|
||||
cy.findByDisplayValue("Text Text").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should restore parameter mappings when user switches back to the saved parameter type", () => {
|
||||
it("should restore the saved parameter value and mappings when user switches back to the saved filter type", () => {
|
||||
const textFilter = createMockParameter({
|
||||
name: "Text Text",
|
||||
slug: "string",
|
||||
@@ -278,11 +210,21 @@ describe("scenarios > dashboard > filters > management", () => {
|
||||
|
||||
changeFilterType("Number");
|
||||
|
||||
// default value for a number type
|
||||
H.sidebar().findByDisplayValue("Equal to").should("exist");
|
||||
|
||||
cy.log("verify that mapping is cleared");
|
||||
H.getDashboardCard().should("not.contain", "People.Name");
|
||||
|
||||
changeFilterType("Text or Category");
|
||||
|
||||
cy.log("verify the saved parameter value is restored");
|
||||
H.sidebar().within(() => {
|
||||
cy.findByDisplayValue("Does not contain").should("exist");
|
||||
cy.findByDisplayValue("Text or Category").should("exist");
|
||||
cy.findByDisplayValue("Text Text").should("exist");
|
||||
});
|
||||
|
||||
cy.log("verify that mapping is restored");
|
||||
H.getDashboardCard().should("contain", "People.Name");
|
||||
});
|
||||
@@ -301,11 +243,11 @@ describe("scenarios > dashboard > filters > management", () => {
|
||||
|
||||
changeOperator("Contains");
|
||||
|
||||
H.getDashboardCard().should("contain", "People.Name");
|
||||
|
||||
// verifies default value does not exist
|
||||
H.sidebar().findByText("No default").should("exist");
|
||||
|
||||
H.getDashboardCard().should("contain", "People.Name");
|
||||
|
||||
H.saveDashboard();
|
||||
|
||||
H.filterWidget().should("contain", "Text");
|
||||
@@ -354,6 +296,8 @@ function createDashboardWithFilterAndQuestionMapped() {
|
||||
H.visitDashboard(dashboard.id);
|
||||
});
|
||||
|
||||
H.filterWidget().should("contain", "Text");
|
||||
|
||||
H.editDashboard();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,8 @@ describe("pivot tables", () => {
|
||||
cy.signInAsAdmin();
|
||||
QSHelpers.createBaseQuestions();
|
||||
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
cy.intercept("POST", "/api/dataset/pivot").as("datasetPivot");
|
||||
cy.intercept("GET", "/api/dashboard/**").as("getDashboard");
|
||||
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
|
||||
|
||||
cy.then(function () {
|
||||
H.createQuestion({
|
||||
@@ -108,7 +106,10 @@ describe("pivot tables", () => {
|
||||
cy.findByTestId("qb-header")
|
||||
.button(/Filter/)
|
||||
.click();
|
||||
H.popover().findByText("Summaries").should("not.exist");
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Base Orders Question").should("be.visible");
|
||||
cy.findByText("Summaries").should("not.exist");
|
||||
});
|
||||
|
||||
function verifyDateMappingOptions() {
|
||||
QSHelpers.verifyDashcardMappingOptions(QUESTION_PIVOT_INDEX, [
|
||||
@@ -18,7 +18,7 @@ describe("scenarios > dashboard > filters", { tags: "@slow" }, () => {
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should be able to use a static list source with a dropdown or a search box", () => {
|
||||
it("should be able to use a static list source with a dropdown or a search box, and restore it when the type changes back", () => {
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails: targetQuestion,
|
||||
}).then(({ body: { dashboard_id } }) => {
|
||||
@@ -52,6 +52,21 @@ describe("scenarios > dashboard > filters", { tags: "@slow" }, () => {
|
||||
|
||||
H.filterWidget().eq(1).findByText("Twenty").should("be.visible");
|
||||
H.getDashboardCard().findByText("4").should("be.visible");
|
||||
|
||||
cy.log("static list values are cleared and restored when the type changes");
|
||||
H.editDashboard();
|
||||
editFilter("Number");
|
||||
|
||||
editFilterType("Text or Category", "Is");
|
||||
H.checkFilterListSourceHasValue({ values: [] });
|
||||
|
||||
mapFilterToQuestion("Email");
|
||||
setFilterSourceFromConnectedFields();
|
||||
|
||||
editFilterType("Number", "Equal to");
|
||||
H.checkFilterListSourceHasValue({
|
||||
values: [["10", "Ten"], ["20", "Twenty"], "30"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to use a card source with numeric columns and single or multiple values", () => {
|
||||
@@ -126,6 +141,28 @@ const mapFilterToQuestion = (column = "Quantity") => {
|
||||
H.popover().within(() => cy.findByText(column).click());
|
||||
};
|
||||
|
||||
function setFilterSourceFromConnectedFields() {
|
||||
H.sidebar().findByText("Edit").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("From connected fields").click();
|
||||
cy.button("Done").click();
|
||||
});
|
||||
}
|
||||
|
||||
function editFilter(name) {
|
||||
cy.findByTestId("edit-dashboard-parameters-widget-container")
|
||||
.findByText(name)
|
||||
.click();
|
||||
}
|
||||
|
||||
function editFilterType(type, subType) {
|
||||
H.sidebar().findByText("Filter or parameter type").next().click();
|
||||
H.selectDropdown().findByText(type).click();
|
||||
|
||||
H.sidebar().findByText("Filter operator").next().click();
|
||||
H.selectDropdown().findByText(subType).click();
|
||||
}
|
||||
|
||||
const filterDashboard = ({ index, isLabeled = false, isDropdown = false }) => {
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
H.filterWidget().eq(index).click();
|
||||
|
||||