diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index ba1e32b32fcc1..7178e64ed7f3a 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -441,7 +441,7 @@ metabase.api.search-test/do-test-users clojure.core/let metabase.async.api-response-test/with-response clojure.core/let metabase.dashboard-subscription-test/with-dashboard-sub-for-card clojure.core/let - metabase.db.custom-migrations/defmigration clj-kondo.lint-as/def-catch-all + metabase.db.custom-migrations/define-migration clj-kondo.lint-as/def-catch-all metabase.db.custom-migrations/define-reversible-migration clj-kondo.lint-as/def-catch-all metabase.db.data-migrations/defmigration clojure.core/def metabase.db.liquibase/with-liquibase clojure.core/let diff --git a/.dir-locals.el b/.dir-locals.el index 6af1a7bfbe2d0..9c22ebb315632 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -92,4 +92,4 @@ ("bin" (clojure-mode - (cider-clojure-cli-aliases . "dev")))) + (cider-clojure-cli-aliases . "dev:drivers:build:build-dev")))) diff --git a/.eslintrc b/.eslintrc index 55429c080c31f..2496a22483904 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,18 +3,11 @@ "strict": [2, "never"], "no-undef": 2, "no-var": 1, - "no-unused-vars": [ - "error", - { - "vars": "all", - "args": "none", - "varsIgnorePattern": "^_.+", - "ignoreRestSiblings": true - } - ], + "no-unused-vars": "off", "no-empty": [1, { "allowEmptyCatch": true }], "curly": [1, "all"], "eqeqeq": [1, "smart"], + "import/no-default-export": 2, "import/no-commonjs": 1, "import/order": [ "error", @@ -56,7 +49,7 @@ ] } ], - "no-console": 0, + "no-console": [2, {"allow": ["warn", "error"]}], "react/no-is-mounted": 2, "react/prefer-es6-class": 2, "react/display-name": 1, @@ -76,7 +69,10 @@ ], "prefer-const": [1, { "destructuring": "all" }], "no-useless-escape": 0, - "no-only-tests/no-only-tests": "error", + "no-only-tests/no-only-tests": [ + "error", + {"block": ["describe", "it", "context", "test", "tape", "fixture", "serial", "Feature", "Scenario", "Given", "And", "When", "Then", "describeWithSnowplow", "describeEE"]} + ], "complexity": ["error", { "max": 54 }] }, "globals": { @@ -96,6 +92,7 @@ "extends": [ "eslint:recommended", "plugin:react/recommended", + "plugin:react/jsx-runtime", "plugin:react-hooks/recommended", "plugin:import/errors", "plugin:import/warnings" @@ -126,16 +123,7 @@ "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-this-alias": "off", "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - "vars": "all", - "args": "none", - "varsIgnorePattern": "^_.+", - "ignoreRestSiblings": true, - "destructuredArrayIgnorePattern": "^_" - } - ] + "@typescript-eslint/no-unused-vars": "off" } }, { diff --git a/.github/actions/test-driver/action.yml b/.github/actions/test-driver/action.yml index 020bfe6401e96..694737aac3309 100644 --- a/.github/actions/test-driver/action.yml +++ b/.github/actions/test-driver/action.yml @@ -30,3 +30,4 @@ runs: reporter: java-junit list-suites: failed list-tests: failed + fail-on-error: false diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000000..e3ce32fa8578d --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,7 @@ +name: "Metabase CodeQL config" + +paths-ignore: + - "**/node_modules" + - "frontend/test/**" + - "frontend/**/*.unit.*" + - "e2e/**" diff --git a/.github/file-paths.yaml b/.github/file-paths.yaml index 7b2a7e4958648..f7b0bded495ac 100644 --- a/.github/file-paths.yaml +++ b/.github/file-paths.yaml @@ -6,10 +6,10 @@ ci: &ci - ".github/**/!(*.md)" shared_sources: &shared_sources - - "shared/src" + - "shared/src/**" shared_specs: &shared_specs - - "shared/test" + - "shared/test/**" frontend_sources: &frontend_sources - *shared_sources @@ -25,7 +25,7 @@ frontend_sources: &frontend_sources frontend_specs: &frontend_specs - *shared_specs - - "frontend/test/!(__support__|__runner__)/**" + - "frontend/test/**" - "frontend/**/*.unit.*" - "jest.unit.conf.json" - "jest.tz.unit.conf.json" @@ -65,13 +65,15 @@ backend_all: &backend_all - *frontend_sources # keep it here until we detect static viz changes sources: &sources + - *shared_sources - *frontend_sources - *backend_sources e2e_specs: &e2e_specs - "**/*.cy.*.js" - - "frontend/test/__support__/e2e/**" - - "frontend/test/__runner__/*cypress*" + - "e2e/runner/**" + - "e2e/support/**" + - "e2e/snapshot*/**" e2e_all: - *default @@ -90,3 +92,12 @@ documentation: yaml: - "**/*.yml" - "**/*.yaml" + +codeql: + - "frontend/src/**" + - "enterprise/frontend/src/**" + +i18n: + - *default + - *ci + - *sources diff --git a/.github/workflows/auto-backport.yml b/.github/workflows/auto-backport.yml index 720b6a2ca6837..5cb6ae6b71f55 100644 --- a/.github/workflows/auto-backport.yml +++ b/.github/workflows/auto-backport.yml @@ -54,7 +54,7 @@ jobs: git checkout master git push -u origin ${BACKPORT_BRANCH} - BACKPORT_PR_URL=$(hub pull-request -b "${TARGET_BRANCH}" -h "${BACKPORT_BRANCH}" -l "auto-backported" -a "${GITHUB_ACTOR}" -F- <<<"🤖 backported \"${ORIGINAL_TITLE}\" + BACKPORT_PR_URL=$(hub pull-request -b "${TARGET_BRANCH}" -h "${BACKPORT_BRANCH}" -l "was-backported" -a "${GITHUB_ACTOR}" -F- <<<"🤖 backported \"${ORIGINAL_TITLE}\" #${ORIGINAL_PULL_REQUEST_NUMBER}") diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 5b98eeefd5a2f..93bfae22f5b82 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -44,7 +44,7 @@ jobs: - name: Collect the test coverage run: clojure -X:dev:ee:ee-dev:test:cloverage - name: Upload coverage to codecov.io - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: files: ./target/coverage/codecov.json flags: back-end @@ -120,7 +120,7 @@ jobs: if: github.event.pull_request.draft == true && needs.files-changed.outputs.backend_all == 'true' runs-on: ubuntu-22.04 name: be-tests-java-11-ee-pre-check - timeout-minutes: 25 + timeout-minutes: 35 steps: - uses: actions/checkout@v3 - name: Prepare front-end environment @@ -146,13 +146,14 @@ jobs: reporter: java-junit list-suites: failed list-tests: failed + fail-on-error: false be-tests: needs: files-changed if: github.event.pull_request.draft == false && needs.files-changed.outputs.backend_all == 'true' runs-on: ubuntu-22.04 name: be-tests-java-${{ matrix.java-version }}-${{ matrix.edition }} - timeout-minutes: 25 + timeout-minutes: 35 strategy: fail-fast: false matrix: @@ -183,6 +184,7 @@ jobs: reporter: java-junit list-suites: failed list-tests: failed + fail-on-error: false be-tests-stub: needs: files-changed diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 131566c2492df..080d1069196c3 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -111,7 +111,7 @@ jobs: if [[ $(hub pr list -b "${TARGET_BRANCH}" -h "${BACKPORT_BRANCH}" -s "open") ]]; then echo "PR already exists" else - BACKPORT_PR_URL=$(hub pull-request -b "${TARGET_BRANCH}" -h "${BACKPORT_BRANCH}" -l "auto-backported" -a "${GITHUB_ACTOR}" -F- <<<"🤖 backported \"${ORIGINAL_TITLE}\" + BACKPORT_PR_URL=$(hub pull-request -b "${TARGET_BRANCH}" -h "${BACKPORT_BRANCH}" -l "was-backported" -a "${GITHUB_ACTOR}" -F- <<<"🤖 backported \"${ORIGINAL_TITLE}\" #${ORIGINAL_PULL_REQUEST_NUMBER}") diff --git a/.github/workflows/build-scripts.yml b/.github/workflows/build-scripts.yml index 87fe47c1c0396..29aad040d1df3 100644 --- a/.github/workflows/build-scripts.yml +++ b/.github/workflows/build-scripts.yml @@ -19,25 +19,8 @@ jobs: with: m2-cache-key: 'build-scripts' - - name: Run metabuild-common build script tests - run: clojure -M:test - working-directory: bin/common - timeout-minutes: 15 - - name: Run build-drivers build script tests - run: clojure -M:test - working-directory: bin/build-drivers - timeout-minutes: 15 - - name: Run i18n script tests - run: clojure -M:test - working-directory: bin/i18n - timeout-minutes: 15 - - name: Run build-mb build script tests - run: clojure -M:test - working-directory: bin/build-mb - timeout-minutes: 15 - - name: Run release script tests - run: clojure -M:test - working-directory: bin/release + - name: Run build and release script tests + run: clojure -X:dev:drivers:build:build-dev:build-test:ci timeout-minutes: 15 - name: Run Liquibase migrations linter tests run: clojure -M:test diff --git a/.github/workflows/clean-cache.yml b/.github/workflows/clean-cache.yml new file mode 100644 index 0000000000000..e4102f2b033af --- /dev/null +++ b/.github/workflows/clean-cache.yml @@ -0,0 +1,33 @@ +name: Cleanup caches by a branch +on: + pull_request: + types: + - closed + +jobs: + cleanup: + runs-on: ubuntu-22.04 + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Cleanup + run: | + gh extension install actions/gh-actions-cache + + REPO=${{ github.repository }} + BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR + do + gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b65f50d4190f5..f0421f5166503 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,7 +8,22 @@ on: pull_request: jobs: + files-changed: + name: Check which files changed + runs-on: ubuntu-22.04 + timeout-minutes: 3 + outputs: + codeql: ${{ steps.changes.outputs.codeql }} + steps: + - uses: actions/checkout@v3 + - name: Test which files changed + uses: dorny/paths-filter@v2.11.1 + id: changes + with: + token: ${{ github.token }} + filters: .github/file-paths.yaml analyze: + needs: files-changed runs-on: ubuntu-22.04 steps: - name: Checkout repository @@ -17,5 +32,6 @@ jobs: uses: github/codeql-action/init@v2 with: languages: javascript + config-file: ./.github/codeql/codeql-config.yml - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/drivers.yml b/.github/workflows/drivers.yml index d72254668377d..ea0271c24ff6a 100644 --- a/.github/workflows/drivers.yml +++ b/.github/workflows/drivers.yml @@ -73,7 +73,7 @@ jobs: be-tests-druid-ee: needs: files-changed if: github.event.pull_request.draft == false && needs.files-changed.outputs.backend_all == 'true' - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-22.04 timeout-minutes: 60 env: CI: 'true' @@ -609,7 +609,7 @@ jobs: be-tests-sparksql-ee: needs: files-changed if: github.event.pull_request.draft == false && needs.files-changed.outputs.backend_all == 'true' - runs-on: buildjet-2vcpu-ubuntu-2204 + runs-on: ubuntu-22.04 timeout-minutes: 60 env: CI: 'true' diff --git a/.github/workflows/e2e-cross-version.yml b/.github/workflows/e2e-cross-version.yml index 3511338260dbd..1807d61a3f62a 100644 --- a/.github/workflows/e2e-cross-version.yml +++ b/.github/workflows/e2e-cross-version.yml @@ -58,8 +58,8 @@ jobs: run: | yarn cypress run \ --browser chrome \ - --config-file frontend/test/metabase/scenarios/cross-version/source/shared/cross-version-source.config.js \ - --spec frontend/test/metabase/scenarios/cross-version/source/**/*.cy.spec.js + --config-file e2e/test/scenarios/cross-version/source/shared/cross-version-source.config.js \ + --spec e2e/test/scenarios/cross-version/source/**/*.cy.spec.js - name: Stop Metabase ${{ matrix.version.source }} run: docker stop metabase-${{ matrix.version.source }} @@ -85,8 +85,8 @@ jobs: run: | yarn cypress run \ --browser chrome \ - --config-file frontend/test/metabase/scenarios/cross-version/target/shared/cross-version-target.config.js \ - --spec frontend/test/metabase/scenarios/cross-version/target/**/*.cy.spec.js + --config-file e2e/test/scenarios/cross-version/target/shared/cross-version-target.config.js \ + --spec e2e/test/scenarios/cross-version/target/**/*.cy.spec.js - name: Upload Cypress Artifacts upon failure uses: actions/upload-artifact@v3 if: failure() diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 27d24898df1ac..c7940ba4d1682 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -48,8 +48,8 @@ jobs: with: m2-cache-key: e2e-tests - - name: Build uberjar with ./bin/build - run: ./bin/build version translations frontend licenses drivers uberjar + - name: Build uberjar with ./bin/build.sh + run: ./bin/build.sh - name: Prepare uberjar artifact uses: ./.github/actions/prepare-uberjar-artifact @@ -71,6 +71,7 @@ jobs: MB_SNOWPLOW_AVAILABLE: true MB_SNOWPLOW_URL: "http://localhost:9090" # Snowplow micro ELECTRON_EXTRA_LAUNCH_ARGS: "--remote-debugging-port=40500" # deploysentinel + TZ: US/Pacific # to make node match the instance tz strategy: fail-fast: false matrix: @@ -169,7 +170,7 @@ jobs: run: | yarn run test-cypress-run \ --env grepTags=@OSS \ - --spec './frontend/test/metabase/scenarios/**/*.cy.spec.js' + --spec './e2e/test/scenarios/**/*.cy.spec.js' env: TERM: xterm @@ -242,10 +243,6 @@ jobs: env: MB_EDITION: ${{ matrix.edition }} MB_PREMIUM_EMBEDDING_TOKEN: ${{ secrets.ENTERPRISE_TOKEN }} - strategy: - matrix: - java-version: [11] - edition: [ee] services: maildev: image: maildev/maildev:2.0.5 @@ -263,18 +260,18 @@ jobs: echo "PERCY_TOKEN=${{ secrets.PERCY_TOKEN }}" >> $GITHUB_ENV - name: Prepare front-end environment uses: ./.github/actions/prepare-frontend - - name: Prepare JDK ${{ matrix.java-version }} + - name: Prepare JDK 11 uses: actions/setup-java@v3 with: - java-version: ${{ matrix.java-version }} + java-version: 11 distribution: 'temurin' - name: Prepare Cypress environment uses: ./.github/actions/prepare-cypress - uses: actions/download-artifact@v3 - name: Retrieve uberjar artifact for ${{ matrix.edition }} + name: Retrieve uberjar artifact for ee with: - name: metabase-${{ matrix.edition }}-uberjar + name: metabase-ee-uberjar - name: Get the version info run: | jar xf target/uberjar/metabase.jar version.properties diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 8bbfbe762bbc4..a845fed8cbc30 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -90,7 +90,7 @@ jobs: - run: yarn run test-unit --coverage --silent name: Run frontend unit tests - name: Upload coverage to codecov.io - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: files: ./coverage/lcov.info flags: front-end diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index f70657466ca93..9d0c95956bfc3 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -26,7 +26,7 @@ jobs: m2-cache-key: 'i18n' - run: sudo apt install gettext - - run: ./bin/i18n/update-translation-template - name: Check i18n tags/make sure template can be built - - run: ./bin/i18n/build-translation-resources - name: Verify i18n translations (.po files) + - name: Check i18n tags/make sure template can be built + run: ./bin/i18n/update-translation-template + - name: Verify i18n translations (.po files) + run: ./bin/i18n/build-translation-resources diff --git a/.github/workflows/percy-issue-comment.yml b/.github/workflows/percy-issue-comment.yml index ed8a890d86c0e..63d59544a225d 100644 --- a/.github/workflows/percy-issue-comment.yml +++ b/.github/workflows/percy-issue-comment.yml @@ -69,7 +69,7 @@ jobs: - name: Prepare cypress environment uses: ./.github/actions/prepare-cypress - - run: ./bin/build + - run: ./bin/build.sh - name: Get the version info run: | jar xf target/uberjar/metabase.jar version.properties diff --git a/.github/workflows/percy-visual-label.yml b/.github/workflows/percy-visual-label.yml index 72bc56895b18d..0028646ff69c6 100644 --- a/.github/workflows/percy-visual-label.yml +++ b/.github/workflows/percy-visual-label.yml @@ -25,7 +25,7 @@ jobs: m2-cache-key: percy-visual-label - name: Prepare cypress environment uses: ./.github/actions/prepare-cypress - - run: ./bin/build + - run: ./bin/build.sh - name: Get the version info run: | jar xf target/uberjar/metabase.jar version.properties diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index bde3eb74c4062..de76a096a7d5f 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -38,7 +38,7 @@ jobs: with: m2-cache-key: pre-release-build - name: Build - run: ./bin/build + run: ./bin/build.sh - name: Prepare uberjar artifact uses: ./.github/actions/prepare-uberjar-artifact diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fac80c1f898dc..cd7f0433db8e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -266,3 +266,22 @@ jobs: - name: Wait for Metabase to start run: while ! curl -s 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done timeout-minutes: 3 + + publish-elastic-beanstalk-artifacts: + runs-on: ubuntu-22.04 + needs: containerize + timeout-minutes: 15 + env: + NO_SLACK: 1 + steps: + - uses: actions/checkout@v3 + - name: Prepare back-end environment + uses: ./.github/actions/prepare-backend + - name: Configure AWS credentials for S3 + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_S3_RELEASE_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_S3_RELEASE_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + - name: Publish Elastic Beanstalk artifacts + run: ./bin/ebs.sh :version ${{ github.ref_name }} diff --git a/.github/workflows/uberjar.yml b/.github/workflows/uberjar.yml index 8eb0f76690c69..9fa4fefc940e8 100644 --- a/.github/workflows/uberjar.yml +++ b/.github/workflows/uberjar.yml @@ -42,7 +42,7 @@ jobs: with: m2-cache-key: uberjar - name: Build - run: ./bin/build + run: ./bin/build.sh - name: Prepare uberjar artifact uses: ./.github/actions/prepare-uberjar-artifact @@ -70,7 +70,7 @@ jobs: - name: Launch uberjar run: java -jar ./target/uberjar/metabase.jar & - name: Wait for Metabase to start - run: while ! curl -s 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done + run: while ! curl 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done containerize_test_and_push_container: runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index cae8bca8c10eb..b49de5ba5b0a5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ /deploy/artifacts/* /docs/uberdoc.html /frontend/test/snapshots/* +/e2e/snapshots/* /lein-plugins/*/target /local /locales/metabase-*.pot @@ -76,6 +77,8 @@ dev/src/dev/nocommit/ /frontend/src/cljs /frontend/src/cljs_release .shadow-cljs +.github/ +.swc/ # lsp: ignore all but the config file .lsp/* diff --git a/Dockerfile b/Dockerfile index 716786004cd16..5fdf8609c8efc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN apt-get update && apt-get upgrade -y && apt-get install openjdk-11-jdk curl && ./linux-install-1.11.1.1208.sh COPY . . -RUN INTERACTIVE=false CI=true MB_EDITION=$MB_EDITION bin/build +RUN INTERACTIVE=false CI=true MB_EDITION=$MB_EDITION bin/build.sh # ################### # # STAGE 2: runner diff --git a/bin/build-driver.sh b/bin/build-driver.sh index e0e125ac87039..ec93edfdce1bd 100755 --- a/bin/build-driver.sh +++ b/bin/build-driver.sh @@ -5,7 +5,7 @@ set -eo pipefail driver="$1" if [ ! "$driver" ]; then - echo "Usage: ./bin/build-driver.sh [edition]" + echo "Usage: ./bin/build-driver.sh [:edition edition]" exit -1 fi @@ -19,5 +19,4 @@ check_clojure_cli source "./bin/clear-outdated-cpcaches.sh" clear_outdated_cpcaches -cd bin/build-drivers -clojure -M -m build-driver $@ +clojure -X:build:drivers:build/driver :driver $@ diff --git a/bin/build-drivers/README.md b/bin/build-drivers.md similarity index 67% rename from bin/build-drivers/README.md rename to bin/build-drivers.md index 8862c9502df33..e7fcc72fd217a 100644 --- a/bin/build-drivers/README.md +++ b/bin/build-drivers.md @@ -10,8 +10,11 @@ There are three main entrypoints. Shell script wrappers are provided for conveni Builds *all* drivers as needed. ``` -cd bin/build-drivers -clojure -M -m build-drivers +clojure -X:build:drivers:build/drivers + +# or + +clojure -X:build:drivers:build/drivers :edition :ee # or @@ -22,9 +25,12 @@ clojure -M -m build-drivers Build a single driver as needed. Builds parent drivers if needed first. -``` -cd bin/build-driver redshift -clojure -M -m build-driver redshift +```sh +clojure -X:build:drivers:build/driver :driver :sqlserver + +# or + +clojure -X:build:drivers:build/driver :driver :sqlserver :edition :oss # or @@ -36,10 +42,5 @@ clojure -M -m build-driver redshift Verify that a built driver looks correctly built. ``` -cd bin/verify-driver redshift -clojure -M -m verify-driver redshift - -# or - -./bin/verify-driver redshift +clojure -X:build:build/verify-driver :driver :mongo ``` diff --git a/bin/build-drivers.sh b/bin/build-drivers.sh index e7eda1463753d..2e1a1a57976a7 100755 --- a/bin/build-drivers.sh +++ b/bin/build-drivers.sh @@ -12,5 +12,4 @@ check_clojure_cli source "./bin/clear-outdated-cpcaches.sh" clear_outdated_cpcaches -cd bin/build-drivers -clojure -M -m build-drivers $@ +clojure -X:build:drivers:build/drivers $@ diff --git a/bin/build-drivers/deps.edn b/bin/build-drivers/deps.edn deleted file mode 100644 index 555cf8470527f..0000000000000 --- a/bin/build-drivers/deps.edn +++ /dev/null @@ -1,40 +0,0 @@ -{:paths ["src"] - - :deps - {org.clojure/clojure {:mvn/version "1.11.1"} ; explicit otherwise version is picked up from cli - common/common {:local/root "../common"} - com.github.seancorfield/depstar {:mvn/version "2.1.278"} - cheshire/cheshire {:mvn/version "5.8.1"} - commons-codec/commons-codec {:mvn/version "1.14"} - expound/expound {:mvn/version "0.7.0"} ; better output of spec validation errors - hiccup/hiccup {:mvn/version "1.0.5"} - io.forward/yaml {:mvn/version "1.0.9"} ; Don't upgrade yet, new version doesn't support Java 8 (see https://github.com/owainlewis/yaml/issues/37) - io.github.clojure/tools.build {:git/tag "v0.7.4" :git/sha "ac442da"} - org.clojure/tools.deps.alpha {:mvn/version "0.12.985"} - com.bhauman/spell-spec {:mvn/version "0.1.1"} ; used to find misspellings in YAML files - stencil/stencil {:mvn/version "0.5.0"} - ;; local source - metabase/metabase-core {:local/root "../.."} - metabase/driver-modules {:local/root "../../modules/drivers"}} - - ;; These are needed for the Athena and Redshift drivers in order to build them. Maven repos from subprojects do not - ;; get copied over -- see - ;; https://ask.clojure.org/index.php/10726/deps-manifest-dependencies-respect-repos-dependent-project - :mvn/repos - {"athena" {:url "https://s3.amazonaws.com/maven-athena"} - "redshift" {:url "https://s3.amazonaws.com/redshift-maven-repository/release"} - ;; for metabase/saml20-clj - "opensaml" {:url "https://build.shibboleth.net/nexus/content/repositories/releases/"}} - - :jvm-opts - ["-XX:-OmitStackTraceInFastThrow"] - - :aliases - {:dev - {:extra-paths ["test"]} - - :test - {:extra-paths ["test"] - :extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" - :sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}} - :main-opts ["-m" "cognitect.test-runner"]}}} diff --git a/bin/build-for-test b/bin/build-for-test index b650dbe659031..3d5f6a0713933 100755 --- a/bin/build-for-test +++ b/bin/build-for-test @@ -29,9 +29,9 @@ check-uberjar-hash() { } build-uberjar-for-test() { - ./bin/build version + ./bin/build.sh :steps [:version] echo -e "\n$VERSION_PROPERTY_NAME=$(source-hash)" >> resources/version.properties - ./bin/build uberjar + ./bin/build.sh :steps [:uberjar] } if [ ! -f "target/uberjar/metabase.jar" ] || ! check-uberjar-hash; then diff --git a/bin/build-mb/README.md b/bin/build-mb.md similarity index 63% rename from bin/build-mb/README.md rename to bin/build-mb.md index 4d9aafe3fb962..0ba818b935736 100644 --- a/bin/build-mb/README.md +++ b/bin/build-mb.md @@ -1,28 +1,31 @@ ## Build Metabase Tooling -This project is to build the Metabase jar. It can be called standalone and is also called from the release project when creating releases. +This project is to build the Metabase jar. It can be called standalone and is also called from the release project +when creating releases. ## License Information We create license information for all of our dependencies, both frontend and backend, and package them in our jar. -Tests will run in CI that we have license information for all dependencies. If you see these failing, an easy way to get a report of dependencies without license information can be obtained by running +Tests will run in CI that we have license information for all dependencies. If you see these failing, an easy way to +get a report of dependencies without license information can be obtained by running ```shell -build-mb % clojure -X build/list-without-license -$ "lein" "with-profile" "-dev,+ee,+include-all-drivers" "classpath" +clojure -X:build:build/list-without-license +... All dependencies have licenses ``` If there are dependencies with missing license information you will see output like ```shell -build-mb % clojure -X build/list-without-license -$ "lein" "with-profile" "-dev,+ee,+include-all-drivers" "classpath" +clojure -X:build:build/list-without-license +$ "clojure" "-A:ee" "-Spath" Missing License: /Users/dan/.m2/repository/org/eclipse/jetty/jetty-webapp/9.3.19.v20170502/jetty-webapp-9.3.19.v20170502.jar Missing License: /Users/dan/.m2/repository/org/fusesource/leveldbjni/leveldbjni-all/1.8/leveldbjni-all-1.8.jar Missing License: /Users/dan/.m2/repository/org/opensaml/opensaml-security-impl/3.4.5/opensaml-security-impl-3.4.5.jar Missing License: /Users/dan/.m2/repository/colorize/colorize/0.1.1/colorize-0.1.1.jar ``` -You can check the overrides file (resources/overrides.edn) and add the license information there, or perhaps improve the license discovery mechanism in the code. +You can check the overrides file (resources/overrides.edn) and add the license information there, or perhaps improve +the license discovery mechanism in the code. diff --git a/bin/build-mb/deps.edn b/bin/build-mb/deps.edn deleted file mode 100644 index b019ca5da4c33..0000000000000 --- a/bin/build-mb/deps.edn +++ /dev/null @@ -1,29 +0,0 @@ -{:paths ["src" "resources"] - - :deps - {common/common {:local/root "../common"} - build-drivers/build-drivers {:local/root "../build-drivers"} - i18n/i18n {:local/root "../i18n"} - org.flatland/ordered {:mvn/version "1.15.10"} - io.github.clojure/tools.build {:git/tag "v0.8.5" :git/sha "9c738da"} - ;; value currently used in tools.build but top level since we directly depend on it - org.apache.maven/maven-model {:mvn/version "3.8.6"}} - - ;; These are needed for the Athena and Redshift drivers in order to build them. Maven repos from subprojects do not - ;; get copied over -- see - ;; https://ask.clojure.org/index.php/10726/deps-manifest-dependencies-respect-repos-dependent-project - :mvn/repos - {"athena" {:url "https://s3.amazonaws.com/maven-athena"} - "redshift" {:url "https://s3.amazonaws.com/redshift-maven-repository/release"} - ;; for metabase/saml20-clj - "opensaml" {:url "https://build.shibboleth.net/nexus/content/repositories/releases/"}} - - :aliases - {:test {:extra-paths ["test"] - :extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" - :sha "cc75980b43011773162b485f46f939dc5fba91e4"} - - ;; the following deps are to have jars on the classpath for testing - org.apache.commons/commons-math3 {:mvn/version "3.6.1"} - net.redhogs.cronparser/cron-parser-core {:mvn/version "3.5"}} - :main-opts ["-m" "cognitect.test-runner"]}}} diff --git a/bin/build b/bin/build.sh similarity index 88% rename from bin/build rename to bin/build.sh index 06d252cc04375..2a2eec276f67d 100755 --- a/bin/build +++ b/bin/build.sh @@ -12,5 +12,4 @@ check_clojure_cli source "./bin/clear-outdated-cpcaches.sh" clear_outdated_cpcaches -cd bin/build-mb -clojure -M -m build $@ +clojure -X:drivers:build:build/all $@ diff --git a/bin/build-mb/resources/BSD.txt b/bin/build/resources/BSD.txt similarity index 100% rename from bin/build-mb/resources/BSD.txt rename to bin/build/resources/BSD.txt diff --git a/bin/build-mb/resources/CC_2_5.txt b/bin/build/resources/CC_2_5.txt similarity index 100% rename from bin/build-mb/resources/CC_2_5.txt rename to bin/build/resources/CC_2_5.txt diff --git a/bin/build-mb/resources/EDL.txt b/bin/build/resources/EDL.txt similarity index 100% rename from bin/build-mb/resources/EDL.txt rename to bin/build/resources/EDL.txt diff --git a/bin/build-mb/resources/EPL.txt b/bin/build/resources/EPL.txt similarity index 100% rename from bin/build-mb/resources/EPL.txt rename to bin/build/resources/EPL.txt diff --git a/bin/build-mb/resources/EPL_2.txt b/bin/build/resources/EPL_2.txt similarity index 100% rename from bin/build-mb/resources/EPL_2.txt rename to bin/build/resources/EPL_2.txt diff --git a/bin/build-mb/resources/GPL1_1.txt b/bin/build/resources/GPL1_1.txt similarity index 100% rename from bin/build-mb/resources/GPL1_1.txt rename to bin/build/resources/GPL1_1.txt diff --git a/bin/build-mb/resources/GPL2_0.txt b/bin/build/resources/GPL2_0.txt similarity index 100% rename from bin/build-mb/resources/GPL2_0.txt rename to bin/build/resources/GPL2_0.txt diff --git a/bin/build-mb/resources/MIT.txt b/bin/build/resources/MIT.txt similarity index 100% rename from bin/build-mb/resources/MIT.txt rename to bin/build/resources/MIT.txt diff --git a/bin/build-mb/resources/apache2_0.txt b/bin/build/resources/apache2_0.txt similarity index 100% rename from bin/build-mb/resources/apache2_0.txt rename to bin/build/resources/apache2_0.txt diff --git a/bin/build-mb/resources/overrides.edn b/bin/build/resources/overrides.edn similarity index 100% rename from bin/build-mb/resources/overrides.edn rename to bin/build/resources/overrides.edn diff --git a/bin/build-mb/src/build.clj b/bin/build/src/build.clj similarity index 85% rename from bin/build-mb/src/build.clj rename to bin/build/src/build.clj index 54525c8f88672..f8bb3a595c085 100644 --- a/bin/build-mb/src/build.clj +++ b/bin/build/src/build.clj @@ -2,6 +2,7 @@ (:require [build-drivers :as build-drivers] [build.licenses :as license] + [build.uberjar :as uberjar] [build.version-info :as version-info] [clojure.edn :as edn] [clojure.java.io :as io] @@ -79,9 +80,7 @@ {:pre [(#{:oss :ee} edition)]} (u/delete-file-if-exists! uberjar-filename) (u/step (format "Build uberjar with profile %s" edition) - ;; TODO -- we (probably) don't need to shell out in order to do this anymore, we should be able to do all this - ;; stuff directly in Clojure land by including this other `build` namespace directly (once we dedupe the names) - (u/sh {:dir u/project-root-directory} "clojure" "-T:build" "uberjar" :edition edition) + (uberjar/uberjar {:edition edition}) (u/assert-file-exists uberjar-filename) (u/announce "Uberjar built successfully."))) @@ -101,11 +100,12 @@ (build-uberjar! edition)))) (defn build! + "Programmatic entrypoint." ([] (build! nil)) ([{:keys [version edition steps] - :or {edition :oss + :or {edition (edition-from-env-var) steps (keys all-steps)}}] (let [version (or version (version-info/current-snapshot-version edition))] @@ -123,21 +123,25 @@ (step-fn {:version version, :edition edition})) (u/announce "All build steps finished."))))) -(defn -main [& steps] +(defn build-cli + "CLI entrypoint. This is just a slim wrapper around [[build!]] that exists with a nonzero status if an exception is + thrown." + [options] (u/exit-when-finished-nonzero-on-exception - (build! (merge {:edition (edition-from-env-var)} - (when-let [steps (not-empty steps)] - {:steps steps}))))) + (build! options))) -;; useful to call from command line `cd bin/build-mb && clojure -X build/list-without-license` -(defn list-without-license [{:keys []}] - (let [[classpath] (u/sh {:dir u/project-root-directory - :quiet? true} - "clojure" "-A:ee" "-Spath") - classpath-entries (license/jar-entries classpath) +(defn list-without-license + "From the command line: + + clojure -X:build:build/list-without-license" + [_options] + (let [[classpath] (u/sh {:dir u/project-root-directory + :quiet? true} + "clojure" "-A:ee" "-Spath") + classpath-entries (license/jar-entries classpath) {:keys [without-license]} (license/process* {:classpath-entries classpath-entries - :backfill (edn/read-string + :backfill (edn/read-string (slurp (io/resource "overrides.edn")))})] (if (seq without-license) (run! (comp (partial u/error "Missing License: %s") first) diff --git a/bin/build-mb/src/build/licenses.clj b/bin/build/src/build/licenses.clj similarity index 100% rename from bin/build-mb/src/build/licenses.clj rename to bin/build/src/build/licenses.clj diff --git a/build.clj b/bin/build/src/build/uberjar.clj similarity index 84% rename from build.clj rename to bin/build/src/build/uberjar.clj index 4997042b7691e..b8174bd97652d 100644 --- a/build.clj +++ b/bin/build/src/build/uberjar.clj @@ -1,18 +1,19 @@ -(ns build - (:require [clojure.java.io :as io] - [clojure.string :as str] - [clojure.tools.build.api :as b] - [clojure.tools.build.util.zip :as build.zip] - [clojure.tools.namespace.dependency :as ns.deps] - [clojure.tools.namespace.find :as ns.find] - [clojure.tools.namespace.parse :as ns.parse] - [hf.depstar.api :as depstar] - [metabuild-common.core :as u]) - (:import java.io.OutputStream - java.net.URI - [java.nio.file Files FileSystems OpenOption StandardOpenOption] - java.util.Collections - java.util.jar.Manifest)) +(ns build.uberjar + (:require + [clojure.java.io :as io] + [clojure.tools.build.api :as b] + [clojure.tools.build.util.zip :as build.zip] + [clojure.tools.namespace.dependency :as ns.deps] + [clojure.tools.namespace.find :as ns.find] + [clojure.tools.namespace.parse :as ns.parse] + [hf.depstar.api :as depstar] + [metabuild-common.core :as u]) + (:import + (java.io OutputStream) + (java.net URI) + (java.nio.file Files FileSystems OpenOption StandardOpenOption) + (java.util Collections) + (java.util.jar Manifest))) (def class-dir "target/classes") (def uberjar-filename "target/uberjar/metabase.jar") @@ -127,8 +128,13 @@ StandardOpenOption/TRUNCATE_EXISTING]))] (write-manifest! os)))))) -;; clojure -T:build uberjar :edition -(defn uberjar [{:keys [edition], :or {edition :oss}}] + +(defn uberjar + "Build just the uberjar (no i18n, FE, or anything else). You can run this from the CLI like: + + clojure -X:build:build/uberjar + clojure -X:build:build/uberjar :edition :ee" + [{:keys [edition], :or {edition :oss}}] (u/step (format "Build %s uberjar" edition) (with-duration-ms [duration-ms] (clean!) @@ -139,6 +145,3 @@ (update-manifest!)) (u/announce "Built target/uberjar/metabase.jar in %.1f seconds." (/ duration-ms 1000.0))))) - -;; TODO -- add `jar` and `install` commands to install Metabase to the local Maven repo (?) could make it easier to -;; build 3rd-party drivers the old way diff --git a/bin/build-mb/src/build/version_info.clj b/bin/build/src/build/version_info.clj similarity index 100% rename from bin/build-mb/src/build/version_info.clj rename to bin/build/src/build/version_info.clj diff --git a/bin/build-drivers/src/build_driver.clj b/bin/build/src/build_driver.clj similarity index 62% rename from bin/build-drivers/src/build_driver.clj rename to bin/build/src/build_driver.clj index 135949a87bed3..3995a7698381e 100644 --- a/bin/build-drivers/src/build_driver.clj +++ b/bin/build/src/build_driver.clj @@ -4,8 +4,9 @@ [build-drivers.build-driver :as build-driver] [metabuild-common.core :as u])) -(defn -main [& [driver edition]] +(defn build-driver [{:keys [driver edition]}] (u/exit-when-finished-nonzero-on-exception - (when-not (seq driver) - (throw (ex-info "Usage: clojure -m build-driver [edition]" {}))) + (when-not driver + (throw (ex-info "Usage: clojure -X:build:drivers:build/driver :driver [:edition ]" + {}))) (build-driver/build-driver! (u/parse-as-keyword driver) (or (u/parse-as-keyword edition) :oss)))) diff --git a/bin/build-drivers/src/build_drivers.clj b/bin/build/src/build_drivers.clj similarity index 95% rename from bin/build-drivers/src/build_drivers.clj rename to bin/build/src/build_drivers.clj index beaf5c0b2fdd6..401da16d7f772 100644 --- a/bin/build-drivers/src/build_drivers.clj +++ b/bin/build/src/build_drivers.clj @@ -27,6 +27,8 @@ (build-driver/build-driver! driver edition)) (u/announce "Successfully built all drivers.")))) -(defn -main [& [edition]] +(defn build-drivers + "CLI entrypoint." + [{:keys [edition]}] (u/exit-when-finished-nonzero-on-exception (build-drivers! (u/parse-as-keyword edition)))) diff --git a/bin/build-drivers/src/build_drivers/build_driver.clj b/bin/build/src/build_drivers/build_driver.clj similarity index 100% rename from bin/build-drivers/src/build_drivers/build_driver.clj rename to bin/build/src/build_drivers/build_driver.clj diff --git a/bin/build-drivers/src/build_drivers/common.clj b/bin/build/src/build_drivers/common.clj similarity index 100% rename from bin/build-drivers/src/build_drivers/common.clj rename to bin/build/src/build_drivers/common.clj diff --git a/bin/build-drivers/src/build_drivers/compile_source_files.clj b/bin/build/src/build_drivers/compile_source_files.clj similarity index 100% rename from bin/build-drivers/src/build_drivers/compile_source_files.clj rename to bin/build/src/build_drivers/compile_source_files.clj diff --git a/bin/build-drivers/src/build_drivers/copy_source_files.clj b/bin/build/src/build_drivers/copy_source_files.clj similarity index 100% rename from bin/build-drivers/src/build_drivers/copy_source_files.clj rename to bin/build/src/build_drivers/copy_source_files.clj diff --git a/bin/build-drivers/src/build_drivers/create_uberjar.clj b/bin/build/src/build_drivers/create_uberjar.clj similarity index 100% rename from bin/build-drivers/src/build_drivers/create_uberjar.clj rename to bin/build/src/build_drivers/create_uberjar.clj diff --git a/bin/build-drivers/src/build_drivers/lint_manifest_file.clj b/bin/build/src/build_drivers/lint_manifest_file.clj similarity index 100% rename from bin/build-drivers/src/build_drivers/lint_manifest_file.clj rename to bin/build/src/build_drivers/lint_manifest_file.clj diff --git a/bin/build-drivers/src/build_drivers/verify.clj b/bin/build/src/build_drivers/verify.clj similarity index 100% rename from bin/build-drivers/src/build_drivers/verify.clj rename to bin/build/src/build_drivers/verify.clj diff --git a/bin/i18n/src/i18n/common.clj b/bin/build/src/i18n/common.clj similarity index 100% rename from bin/i18n/src/i18n/common.clj rename to bin/build/src/i18n/common.clj diff --git a/bin/i18n/src/i18n/create_artifacts.clj b/bin/build/src/i18n/create_artifacts.clj similarity index 82% rename from bin/i18n/src/i18n/create_artifacts.clj rename to bin/build/src/i18n/create_artifacts.clj index 4a0d1bd14a918..d74f911b4b877 100644 --- a/bin/i18n/src/i18n/create_artifacts.clj +++ b/bin/build/src/i18n/create_artifacts.clj @@ -31,11 +31,12 @@ (doseq [locale (i18n/locales)] (create-artifacts-for-locale! locale))) -(defn create-all-artifacts! [] - (u/step "Create i18n artifacts" - (generate-locales-dot-edn!) - (create-artifacts-for-all-locales!) - (u/announce "Translation resources built successfully."))) +(defn create-all-artifacts! + ([] + (create-all-artifacts! nil)) -(defn -main [] - (create-all-artifacts!)) + ([_options] + (u/step "Create i18n artifacts" + (generate-locales-dot-edn!) + (create-artifacts-for-all-locales!) + (u/announce "Translation resources built successfully.")))) diff --git a/bin/i18n/src/i18n/create_artifacts/backend.clj b/bin/build/src/i18n/create_artifacts/backend.clj similarity index 100% rename from bin/i18n/src/i18n/create_artifacts/backend.clj rename to bin/build/src/i18n/create_artifacts/backend.clj diff --git a/bin/i18n/src/i18n/create_artifacts/frontend.clj b/bin/build/src/i18n/create_artifacts/frontend.clj similarity index 100% rename from bin/i18n/src/i18n/create_artifacts/frontend.clj rename to bin/build/src/i18n/create_artifacts/frontend.clj diff --git a/bin/i18n/src/i18n/enumerate.clj b/bin/build/src/i18n/enumerate.clj similarity index 97% rename from bin/i18n/src/i18n/enumerate.clj rename to bin/build/src/i18n/enumerate.clj index 06abeb99c07d0..bd368b5732be4 100644 --- a/bin/i18n/src/i18n/enumerate.clj +++ b/bin/build/src/i18n/enumerate.clj @@ -178,14 +178,14 @@ :entry-count (count grouped) :bad-forms bad-forms})) -(defn -main +(defn enumerate "Entrypoint for creating a backend pot file. Exits with 0 if all forms were processed correctly, exits with 1 if one or more forms were found that it could not process." - [& [filename]] + [{:keys [filename]}] (when (str/blank? filename) (println "Please provide a filename argument. Eg: ") - (println " clj -M -m i18n.enumerate \"$POT_BACKEND_NAME\"") - (println " clj -M -m i18n.enumerate metabase.pot") + (println " clj -X:build i18n.enumerate/enumerate :filename \"\\\"$POT_BACKEND_NAME\\\"\"") + (println " clj -X:build i18n.enumerate/enumerate :filename '\"metabase.pot\"'") (System/exit 1)) (let [{:keys [valid-usages entry-count bad-forms]} (create-pot-file! roots filename)] (println (format "Found %d forms for translations" valid-usages)) diff --git a/bin/common/src/metabuild_common/aws.clj b/bin/build/src/metabuild_common/aws.clj similarity index 90% rename from bin/common/src/metabuild_common/aws.clj rename to bin/build/src/metabuild_common/aws.clj index dd5c79a28ebc8..8d7a874c45964 100644 --- a/bin/common/src/metabuild_common/aws.clj +++ b/bin/build/src/metabuild_common/aws.clj @@ -1,12 +1,14 @@ (ns metabuild-common.aws (:require + [environ.core] [metabuild-common.env :as env] [metabuild-common.output :as out] [metabuild-common.shell :as sh] [metabuild-common.steps :as steps])) (defn aws-profile [] - (env/env-or-throw :aws-default-profile)) + (when (not (contains? environ.core/env :ci)) + (env/env-or-throw :aws-default-profile))) (defn s3-copy! ([source dest] diff --git a/bin/common/src/metabuild_common/core.clj b/bin/build/src/metabuild_common/core.clj similarity index 100% rename from bin/common/src/metabuild_common/core.clj rename to bin/build/src/metabuild_common/core.clj diff --git a/bin/common/src/metabuild_common/entrypoint.clj b/bin/build/src/metabuild_common/entrypoint.clj similarity index 100% rename from bin/common/src/metabuild_common/entrypoint.clj rename to bin/build/src/metabuild_common/entrypoint.clj diff --git a/bin/common/src/metabuild_common/env.clj b/bin/build/src/metabuild_common/env.clj similarity index 100% rename from bin/common/src/metabuild_common/env.clj rename to bin/build/src/metabuild_common/env.clj diff --git a/bin/common/src/metabuild_common/files.clj b/bin/build/src/metabuild_common/files.clj similarity index 95% rename from bin/common/src/metabuild_common/files.clj rename to bin/build/src/metabuild_common/files.clj index 92f5f818d21f5..205d005c6cc83 100644 --- a/bin/common/src/metabuild_common/files.clj +++ b/bin/build/src/metabuild_common/files.clj @@ -103,10 +103,10 @@ source file." (.. (Paths/get (.toURI (io/resource "metabuild_common/files.clj"))) toFile - getParentFile ; /home/cam/metabase/bin/common/src/metabuild_common - getParentFile ; /home/cam/metabase/bin/common/src/ - getParentFile ; /home/cam/metabase/bin/common/ - getParentFile ; /home/cam/metabase/bin/ + getParentFile ; /home/cam/metabase/bin/build/src/metabuild_common/ + getParentFile ; /home/cam/metabase/bin/build/src/ + getParentFile ; /home/cam/metabase/bin/build/ + getParentFile ; /home/cam/metabase/ getParentFile ; /home/cam/metabase/ getCanonicalPath)) diff --git a/bin/common/src/metabuild_common/input.clj b/bin/build/src/metabuild_common/input.clj similarity index 100% rename from bin/common/src/metabuild_common/input.clj rename to bin/build/src/metabuild_common/input.clj diff --git a/bin/common/src/metabuild_common/misc.clj b/bin/build/src/metabuild_common/misc.clj similarity index 95% rename from bin/common/src/metabuild_common/misc.clj rename to bin/build/src/metabuild_common/misc.clj index 49f8c5757c1aa..f42e8d5db9472 100644 --- a/bin/common/src/metabuild_common/misc.clj +++ b/bin/build/src/metabuild_common/misc.clj @@ -19,6 +19,7 @@ (which is super confusing, because it's an _unnamespaced_ keyword whose the _name_ is `:driver`)" [s] (cond + (symbol? s) (parse-as-keyword (name s)) (keyword? s) s (not (str/blank? s)) (keyword (cond-> s (str/starts-with? s ":") (.substring 1))))) diff --git a/bin/common/src/metabuild_common/output.clj b/bin/build/src/metabuild_common/output.clj similarity index 100% rename from bin/common/src/metabuild_common/output.clj rename to bin/build/src/metabuild_common/output.clj diff --git a/bin/common/src/metabuild_common/shell.clj b/bin/build/src/metabuild_common/shell.clj similarity index 100% rename from bin/common/src/metabuild_common/shell.clj rename to bin/build/src/metabuild_common/shell.clj diff --git a/bin/common/src/metabuild_common/steps.clj b/bin/build/src/metabuild_common/steps.clj similarity index 100% rename from bin/common/src/metabuild_common/steps.clj rename to bin/build/src/metabuild_common/steps.clj diff --git a/bin/release/src/release.clj b/bin/build/src/release.clj similarity index 80% rename from bin/release/src/release.clj rename to bin/build/src/release.clj index d13e3492c8837..3b23241d58fa8 100644 --- a/bin/release/src/release.clj +++ b/bin/build/src/release.clj @@ -40,10 +40,19 @@ (slack/post-message! "Finished `%s` :partyparrot:" step-name)) (u/announce "Success.")) -(defn -main [& steps] +(defn release [{:keys [steps]}] (u/exit-when-finished-nonzero-on-exception (check-prereqs/check-prereqs) (set-build-options/prompt-and-set-build-options!) (let [steps (or (seq (map u/parse-as-keyword steps)) (keys steps*))] (do-steps! steps)))) + +(defn publish-ebs [args] + (u/exit-when-finished-nonzero-on-exception + (let [version (:version args)] + (c/set-version! version) + (c/set-edition! (if (str/starts-with? (c/version) "0") :oss :ee)) + (c/set-branch! "release-x.y.z") ;; FIXME: branch is irrelevant for CD run + (u/announce (format "Preparing Elastic Beanstalk artifacts for version %s" (c/version))) + (do-steps! [:publish-elastic-beanstalk-artifacts])))) diff --git a/bin/release/src/release/check_prereqs.clj b/bin/build/src/release/check_prereqs.clj similarity index 100% rename from bin/release/src/release/check_prereqs.clj rename to bin/build/src/release/check_prereqs.clj diff --git a/bin/release/src/release/common.clj b/bin/build/src/release/common.clj similarity index 89% rename from bin/release/src/release/common.clj rename to bin/build/src/release/common.clj index 6a9498f2f73e3..b69141b19eb96 100644 --- a/bin/release/src/release/common.clj +++ b/bin/build/src/release/common.clj @@ -1,28 +1,23 @@ (ns release.common (:require [clojure.string :as str] - [environ.core :as env] - [metabuild-common.core :as u]) - (:import - (java.io File))) + [metabuild-common.core :as u])) -(assert (str/ends-with? (env/env :user-dir) "/bin/release") - "Please run release.clj from the `release` directory e.g. `cd bin/release; clojure -m release`") - -(def cloudfront-distribution-id "E35CJLWZIZVG7K") +(def downloads-cloudfront-distribution-id "E35CJLWZIZVG7K") +(def static-cloudfront-distribution-id "E1HU16PWP1JPMC") (def ^String root-directory "e.g. /Users/cam/metabase" - (.. (File. ^String (env/env :user-dir)) getParentFile getParent)) + u/project-root-directory) (def ^String uberjar-path (u/filename root-directory "target" "uberjar" "metabase.jar")) -(defonce ^:private build-options +(defonce ^:private ^:dynamic *build-options* (atom nil)) (defn- build-option-or-throw [k] - (or (get @build-options k) + (or (get @*build-options* k) (let [msg (format "%s is not set. Run release.set-build-options/prompt-and-set-build-options! to set it." (name k)) e (ex-info msg {})] @@ -42,7 +37,7 @@ (defn set-version! [new-version] ;; strip off initial `v` if present - (swap! build-options assoc :version (str/replace new-version #"^v" ""))) + (swap! *build-options* assoc :version (str/replace new-version #"^v" ""))) (defn github-milestone "Name of GitHub milestone to query for fixed issue descriptions. Same as version, except for enterprise edition, in @@ -51,7 +46,7 @@ (build-option-or-throw :github-milestone)) (defn set-github-milestone! [new-github-milestone] - (swap! build-options assoc :github-milestone new-github-milestone)) + (swap! *build-options* assoc :github-milestone new-github-milestone)) (defn branch "Branch we are building from, e.g. `release-0.36.x`" @@ -59,7 +54,7 @@ (build-option-or-throw :branch)) (defn set-branch! [new-branch] - (swap! build-options assoc :branch new-branch)) + (swap! *build-options* assoc :branch new-branch)) (defn edition "Either `:oss` (Community Edition) or `:ee` (Enterprise Edition)." @@ -69,7 +64,7 @@ (defn set-edition! [new-edition] (assert (#{:oss :ee} new-edition)) - (swap! build-options assoc :edition new-edition)) + (swap! *build-options* assoc :edition new-edition)) (defn pre-release-version? "Whether this version should be considered a prerelease. True if the version doesn't follow the usual diff --git a/bin/release/src/release/common/git.clj b/bin/build/src/release/common/git.clj similarity index 100% rename from bin/release/src/release/common/git.clj rename to bin/build/src/release/common/git.clj diff --git a/bin/release/src/release/common/github.clj b/bin/build/src/release/common/github.clj similarity index 100% rename from bin/release/src/release/common/github.clj rename to bin/build/src/release/common/github.clj diff --git a/bin/release/src/release/common/hash.clj b/bin/build/src/release/common/hash.clj similarity index 100% rename from bin/release/src/release/common/hash.clj rename to bin/build/src/release/common/hash.clj diff --git a/bin/release/src/release/common/http.clj b/bin/build/src/release/common/http.clj similarity index 100% rename from bin/release/src/release/common/http.clj rename to bin/build/src/release/common/http.clj diff --git a/bin/release/src/release/common/slack.clj b/bin/build/src/release/common/slack.clj similarity index 100% rename from bin/release/src/release/common/slack.clj rename to bin/build/src/release/common/slack.clj diff --git a/bin/release/src/release/common/upload.clj b/bin/build/src/release/common/upload.clj similarity index 80% rename from bin/release/src/release/common/upload.clj rename to bin/build/src/release/common/upload.clj index f57d278158efe..474e57fe9cef9 100644 --- a/bin/release/src/release/common/upload.clj +++ b/bin/build/src/release/common/upload.clj @@ -11,4 +11,4 @@ ([source-file version filename] (u/step (format "Upload %s to %s" source-file (c/artifact-download-url version filename)) (u/s3-copy! (u/assert-file-exists source-file) (c/s3-artifact-url version filename)) - (u/create-cloudfront-invalidation! c/cloudfront-distribution-id (c/s3-artifact-path version filename))))) + (u/create-cloudfront-invalidation! c/downloads-cloudfront-distribution-id (c/s3-artifact-path version filename))))) diff --git a/bin/release/src/release/draft_release.clj b/bin/build/src/release/draft_release.clj similarity index 100% rename from bin/release/src/release/draft_release.clj rename to bin/build/src/release/draft_release.clj diff --git a/bin/release/src/release/draft_release/release-template.md b/bin/build/src/release/draft_release/release-template.md similarity index 100% rename from bin/release/src/release/draft_release/release-template.md rename to bin/build/src/release/draft_release/release-template.md diff --git a/bin/release/src/release/elastic_beanstalk.clj b/bin/build/src/release/elastic_beanstalk.clj similarity index 87% rename from bin/release/src/release/elastic_beanstalk.clj rename to bin/build/src/release/elastic_beanstalk.clj index aaca6e36b4c49..529151b369773 100644 --- a/bin/release/src/release/elastic_beanstalk.clj +++ b/bin/build/src/release/elastic_beanstalk.clj @@ -3,7 +3,6 @@ (:require [cheshire.core :as json] [clojure.core.cache :as cache] - [clojure.java.io :as io] [metabuild-common.core :as u] [release.common :as c] [release.common.http :as common.http] @@ -11,6 +10,8 @@ [stencil.core :as stencil] [stencil.loader])) +(set! *warn-on-reflection* true) + ;; Disable caching of our template files for easier REPL debugging, we're only rendering them once anyways (stencil.loader/set-cache (cache/ttl-cache-factory {} :ttl 0)) @@ -24,11 +25,11 @@ (def ^:private eb-extensions-source "Source location of the .ebextensions directory" - (u/assert-file-exists (u/filename c/root-directory "bin" "release" "src" "release" "elastic_beanstalk" ".ebextensions"))) + (u/assert-file-exists (u/filename c/root-directory "bin" "build" "src" "release" "elastic_beanstalk" ".ebextensions"))) (def ^:private eb-platform-source "Source location of the .ebextensions directory" - (u/assert-file-exists (u/filename c/root-directory "bin" "release" "src" "release" "elastic_beanstalk" ".platform"))) + (u/assert-file-exists (u/filename c/root-directory "bin" "build" "src" "release" "elastic_beanstalk" ".platform"))) (def ^:private archive-temp-dir "Path where we'll put the contents of the ZIP file before we create it." @@ -87,17 +88,15 @@ (u/assert-file-exists archive-path)))) (def ^:private launch-template-filename - "release/elastic_beanstalk/launch-aws-eb.html.template") - -(u/assert-file-exists (.getPath (io/resource launch-template-filename))) + (u/assert-file-exists (u/filename c/root-directory "bin" "build" "src" "release" "elastic_beanstalk" "launch-aws-eb.html.template"))) (defn- create-html-file! [] (u/step (format "Create launch-aws-eb.html for Docker image %s" (c/docker-tag)) (u/delete-file-if-exists! html-file-path) (spit html-file-path - (stencil/render-file launch-template-filename - {:url (java.net.URLEncoder/encode (c/artifact-download-url "metabase-aws-eb.zip") - "UTF-8")})) + (stencil/render (slurp launch-template-filename) + {:url (java.net.URLEncoder/encode (c/artifact-download-url "metabase-aws-eb.zip") + "UTF-8")})) (u/assert-file-exists html-file-path))) (defn- upload-artifacts! [] diff --git a/bin/release/src/release/elastic_beanstalk/.ebextensions/01_metabase.config b/bin/build/src/release/elastic_beanstalk/.ebextensions/01_metabase.config similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.ebextensions/01_metabase.config rename to bin/build/src/release/elastic_beanstalk/.ebextensions/01_metabase.config diff --git a/bin/release/src/release/elastic_beanstalk/.ebextensions/metabase_config/cloudwatch/config.json b/bin/build/src/release/elastic_beanstalk/.ebextensions/metabase_config/cloudwatch/config.json similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.ebextensions/metabase_config/cloudwatch/config.json rename to bin/build/src/release/elastic_beanstalk/.ebextensions/metabase_config/cloudwatch/config.json diff --git a/bin/release/src/release/elastic_beanstalk/.ebextensions/metabase_config/metabase-setup.sh b/bin/build/src/release/elastic_beanstalk/.ebextensions/metabase_config/metabase-setup.sh similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.ebextensions/metabase_config/metabase-setup.sh rename to bin/build/src/release/elastic_beanstalk/.ebextensions/metabase_config/metabase-setup.sh diff --git a/bin/release/src/release/elastic_beanstalk/.ebextensions/metabase_config/papertrail/log_files.yml b/bin/build/src/release/elastic_beanstalk/.ebextensions/metabase_config/papertrail/log_files.yml similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.ebextensions/metabase_config/papertrail/log_files.yml rename to bin/build/src/release/elastic_beanstalk/.ebextensions/metabase_config/papertrail/log_files.yml diff --git a/bin/release/src/release/elastic_beanstalk/.ebextensions/metabase_config/papertrail/remote_syslog b/bin/build/src/release/elastic_beanstalk/.ebextensions/metabase_config/papertrail/remote_syslog similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.ebextensions/metabase_config/papertrail/remote_syslog rename to bin/build/src/release/elastic_beanstalk/.ebextensions/metabase_config/papertrail/remote_syslog diff --git a/bin/release/src/release/elastic_beanstalk/.platform/confighooks/postdeploy/config_nginx.sh b/bin/build/src/release/elastic_beanstalk/.platform/confighooks/postdeploy/config_nginx.sh similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.platform/confighooks/postdeploy/config_nginx.sh rename to bin/build/src/release/elastic_beanstalk/.platform/confighooks/postdeploy/config_nginx.sh diff --git a/bin/release/src/release/elastic_beanstalk/.platform/hooks/postdeploy/config_nginx.sh b/bin/build/src/release/elastic_beanstalk/.platform/hooks/postdeploy/config_nginx.sh similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.platform/hooks/postdeploy/config_nginx.sh rename to bin/build/src/release/elastic_beanstalk/.platform/hooks/postdeploy/config_nginx.sh diff --git a/bin/release/src/release/elastic_beanstalk/.platform/nginx/nginx-ssl.conf b/bin/build/src/release/elastic_beanstalk/.platform/nginx/nginx-ssl.conf similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.platform/nginx/nginx-ssl.conf rename to bin/build/src/release/elastic_beanstalk/.platform/nginx/nginx-ssl.conf diff --git a/bin/release/src/release/elastic_beanstalk/.platform/nginx/nginx.conf b/bin/build/src/release/elastic_beanstalk/.platform/nginx/nginx.conf similarity index 100% rename from bin/release/src/release/elastic_beanstalk/.platform/nginx/nginx.conf rename to bin/build/src/release/elastic_beanstalk/.platform/nginx/nginx.conf diff --git a/bin/release/src/release/elastic_beanstalk/launch-aws-eb.html.template b/bin/build/src/release/elastic_beanstalk/launch-aws-eb.html.template similarity index 100% rename from bin/release/src/release/elastic_beanstalk/launch-aws-eb.html.template rename to bin/build/src/release/elastic_beanstalk/launch-aws-eb.html.template diff --git a/bin/release/src/release/git_tags.clj b/bin/build/src/release/git_tags.clj similarity index 100% rename from bin/release/src/release/git_tags.clj rename to bin/build/src/release/git_tags.clj diff --git a/bin/release/src/release/set_build_options.clj b/bin/build/src/release/set_build_options.clj similarity index 100% rename from bin/release/src/release/set_build_options.clj rename to bin/build/src/release/set_build_options.clj diff --git a/bin/release/src/release/uberjar.clj b/bin/build/src/release/uberjar.clj similarity index 98% rename from bin/release/src/release/uberjar.clj rename to bin/build/src/release/uberjar.clj index b50da72688c78..e35f76720b40e 100644 --- a/bin/release/src/release/uberjar.clj +++ b/bin/build/src/release/uberjar.clj @@ -9,7 +9,7 @@ [release.common.upload :as upload])) (defn build-uberjar! [] - (u/step "Run bin/build to build uberjar" + (u/step "Build uberjar" (u/delete-file-if-exists! (str c/root-directory "/target")) (build/build! {:version (str \v (c/version)) :edition (c/edition)}) diff --git a/bin/release/src/release/version_info.clj b/bin/build/src/release/version_info.clj similarity index 95% rename from bin/release/src/release/version_info.clj rename to bin/build/src/release/version_info.clj index abdb4ee2bf400..d2de6f4fd3b64 100644 --- a/bin/release/src/release/version_info.clj +++ b/bin/build/src/release/version_info.clj @@ -62,7 +62,8 @@ (defn- upload-version-info! [] (u/step "Upload version info" (u/s3-copy! (format "s3://%s" (version-info-url)) (format "s3://%s.previous" (version-info-url))) - (u/s3-copy! (u/assert-file-exists (tmp-version-info-filename)) (format "s3://%s" (version-info-url))))) + (u/s3-copy! (u/assert-file-exists (tmp-version-info-filename)) (format "s3://%s" (version-info-url))) + (u/create-cloudfront-invalidation! c/static-cloudfront-distribution-id (format "/%s" (version-info-filename))))) (defn- validate-version-info [] (u/step (format "Validate version info at %s" (version-info-url)) diff --git a/bin/build-drivers/src/verify_driver.clj b/bin/build/src/verify_driver.clj similarity index 50% rename from bin/build-drivers/src/verify_driver.clj rename to bin/build/src/verify_driver.clj index b607a40ec61f2..c32a7244f05bd 100644 --- a/bin/build-drivers/src/verify_driver.clj +++ b/bin/build/src/verify_driver.clj @@ -1,11 +1,11 @@ (ns verify-driver - "Entrypoint for `bin/verify-driver`. Verify that a driver JAR looks correct." + "Verify that a driver JAR looks correct." (:require [build-drivers.verify :as verify] [metabuild-common.core :as u])) -(defn -main [& [driver]] +(defn verify-driver [{:keys [driver]}] (u/exit-when-finished-nonzero-on-exception - (when-not (seq driver) - (throw (ex-info "Usage: clojure -m verify-driver " {}))) + (when-not driver + (throw (ex-info "Usage: clojure -X:build:build/verify-driver :driver " {}))) (verify/verify-driver (u/parse-as-keyword driver)))) diff --git a/bin/build-mb/test/build/licenses_test.clj b/bin/build/test/build/licenses_test.clj similarity index 100% rename from bin/build-mb/test/build/licenses_test.clj rename to bin/build/test/build/licenses_test.clj diff --git a/bin/build-mb/test/build/version_info_test.clj b/bin/build/test/build/version_info_test.clj similarity index 100% rename from bin/build-mb/test/build/version_info_test.clj rename to bin/build/test/build/version_info_test.clj diff --git a/bin/build-drivers/test/build_drivers/build_driver_test.clj b/bin/build/test/build_drivers/build_driver_test.clj similarity index 100% rename from bin/build-drivers/test/build_drivers/build_driver_test.clj rename to bin/build/test/build_drivers/build_driver_test.clj diff --git a/bin/i18n/test/i18n/create_artifacts/backend_test.clj b/bin/build/test/i18n/create_artifacts/backend_test.clj similarity index 100% rename from bin/i18n/test/i18n/create_artifacts/backend_test.clj rename to bin/build/test/i18n/create_artifacts/backend_test.clj diff --git a/bin/i18n/test/i18n/create_artifacts/frontend_test.clj b/bin/build/test/i18n/create_artifacts/frontend_test.clj similarity index 100% rename from bin/i18n/test/i18n/create_artifacts/frontend_test.clj rename to bin/build/test/i18n/create_artifacts/frontend_test.clj diff --git a/bin/i18n/test/i18n/create_artifacts/test_common.clj b/bin/build/test/i18n/create_artifacts/test_common.clj similarity index 100% rename from bin/i18n/test/i18n/create_artifacts/test_common.clj rename to bin/build/test/i18n/create_artifacts/test_common.clj diff --git a/bin/common/test/metabuild_common/misc_test.clj b/bin/build/test/metabuild_common/misc_test.clj similarity index 100% rename from bin/common/test/metabuild_common/misc_test.clj rename to bin/build/test/metabuild_common/misc_test.clj diff --git a/bin/release/test/release/common_test.clj b/bin/build/test/release/common_test.clj similarity index 100% rename from bin/release/test/release/common_test.clj rename to bin/build/test/release/common_test.clj diff --git a/bin/build/test/release/elastic_beanstalk_test.clj b/bin/build/test/release/elastic_beanstalk_test.clj new file mode 100644 index 0000000000000..7c9a1584b5bd0 --- /dev/null +++ b/bin/build/test/release/elastic_beanstalk_test.clj @@ -0,0 +1,11 @@ +(ns release.elastic-beanstalk-test + (:require + [clojure.test :refer :all] + [release.common :as c] + [release.elastic-beanstalk :as release.eb])) + +(deftest create-html-file-test + (testing "Just make sure this doesn't barf." + (binding [c/*build-options* (atom {:version "0.46.0-test" + :edition :oss})] + (is (string? (#'release.eb/create-html-file!)))))) diff --git a/bin/release/test/release/version_info_test.clj b/bin/build/test/release/version_info_test.clj similarity index 97% rename from bin/release/test/release/version_info_test.clj rename to bin/build/test/release/version_info_test.clj index cd9ce71f7f42d..e441110d01896 100644 --- a/bin/release/test/release/version_info_test.clj +++ b/bin/build/test/release/version_info_test.clj @@ -43,6 +43,6 @@ (#'v-info/generate-version-info!) (let [actual (-> (#'v-info/tmp-version-info-filename) (slurp) - (json/read-json true)) + (json/read-str :key-fn keyword)) expected (make-version-info edition test-versions)] (is (= expected actual))))))) diff --git a/bin/common/deps.edn b/bin/common/deps.edn deleted file mode 100644 index 4e50548fb866a..0000000000000 --- a/bin/common/deps.edn +++ /dev/null @@ -1,13 +0,0 @@ -{:paths ["src"] - - :deps - {commons-io/commons-io {:mvn/version "2.11.0"} - colorize/colorize {:mvn/version "0.1.1"} - environ/environ {:mvn/version "1.2.0"} - potemkin/potemkin {:mvn/version "0.4.6"}} - - :aliases - {:test {:extra-paths ["test"] - :extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" - :sha "cc75980b43011773162b485f46f939dc5fba91e4"}} - :main-opts ["-m" "cognitect.test-runner"]}}} diff --git a/bin/ebs.sh b/bin/ebs.sh new file mode 100755 index 0000000000000..7b62d68abd95a --- /dev/null +++ b/bin/ebs.sh @@ -0,0 +1,15 @@ +#! /usr/bin/env bash + +set -euo pipefail + +# switch to project root directory if we're not already there +script_directory=`dirname "${BASH_SOURCE[0]}"` +cd "$script_directory/.." + +source "./bin/check-clojure-cli.sh" +check_clojure_cli + +source "./bin/clear-outdated-cpcaches.sh" +clear_outdated_cpcaches + +clojure -X:build:build/publish-ebs $@ diff --git a/bin/i18n/README.md b/bin/i18n/README.md index 361982f63ea9e..1b6e1132a666d 100644 --- a/bin/i18n/README.md +++ b/bin/i18n/README.md @@ -2,10 +2,10 @@ #### Building the backend pot file -Building the backend pot file can be done from the command line: +Building the backend pot file can be done from the command line (from the project root directory): ```shell -❯ clojure -M -m i18n.enumerate cli.pot +❯ clojure -X:build:build/i18n Created pot file at cli.pot Found 1393 forms for translations Grouped into 1313 distinct pot entries diff --git a/bin/i18n/build-translation-resources b/bin/i18n/build-translation-resources index bcac1a5d1733c..664f19903c610 100755 --- a/bin/i18n/build-translation-resources +++ b/bin/i18n/build-translation-resources @@ -2,8 +2,11 @@ set -euo pipefail +# switch to project root directory if we're not already there +script_directory=`dirname "${BASH_SOURCE[0]}"` +cd "$script_directory/../.." + source "./bin/check-clojure-cli.sh" check_clojure_cli -cd bin/i18n -clojure -M -m i18n.create-artifacts $@ +clojure -X:build:build/i18n diff --git a/bin/i18n/deps.edn b/bin/i18n/deps.edn deleted file mode 100644 index 2aa3884551406..0000000000000 --- a/bin/i18n/deps.edn +++ /dev/null @@ -1,14 +0,0 @@ -{:paths ["src"] - - :deps - {common/common {:local/root "../common"} - cheshire/cheshire {:mvn/version "5.8.1"} - clj-http/clj-http {:mvn/version "3.9.1"} - io.github.borkdude/grasp {:mvn/version "0.0.3"} - org.fedorahosted.tennera/jgettext {:mvn/version "0.15.1"}} - - :aliases - {:test {:extra-paths ["test"] - :extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" - :sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}} - :main-opts ["-m" "cognitect.test-runner"]}}} diff --git a/bin/i18n/update-translation-template b/bin/i18n/update-translation-template index d0943ffc17043..0ab2ef43c25e1 100755 --- a/bin/i18n/update-translation-template +++ b/bin/i18n/update-translation-template @@ -45,10 +45,7 @@ rm "$POT_FRONTEND_NAME.bak" # update backend pot # ###################### -pushd bin/i18n -clojure -M -m i18n.enumerate "../../$POT_BACKEND_NAME" -# switch back to project root -popd +clojure -X:build i18n.enumerate/enumerate :filename "\"$POT_BACKEND_NAME\"" ######################## # update auto dash pot # diff --git a/bin/release/README.md b/bin/release.md similarity index 93% rename from bin/release/README.md rename to bin/release.md index 1c4f9f625a5d3..ebe917c172132 100644 --- a/bin/release/README.md +++ b/bin/release.md @@ -49,14 +49,12 @@ *or* ```bash -# Run from the same directory as this README file -cd /path/to/metabase/bin/release -clojure -M -m release +clojure -X:drivers:build:build/release ``` 1. Debugging -If you're running into issues running the release script, it's helpful to first check that you can run `./bin/build` +If you're running into issues running the release script, it's helpful to first check that you can run `./bin/build.sh` -- this is the real meat and potatoes of the release process and more likely to be the cause of your issues. If you can run that but still need help, talk to Cam. diff --git a/bin/release.sh b/bin/release.sh index 58ff2c453cebb..ca00894ea8be4 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -12,5 +12,4 @@ check_clojure_cli source "./bin/clear-outdated-cpcaches.sh" clear_outdated_cpcaches -cd bin/release -clojure -M -m release $@ +clojure -X:drivers:build:build/release $@ diff --git a/bin/release/deps.edn b/bin/release/deps.edn deleted file mode 100644 index 1b756d8acbdee..0000000000000 --- a/bin/release/deps.edn +++ /dev/null @@ -1,26 +0,0 @@ -{ - :deps - {common/common {:local/root "../common"} - build/build {:local/root "../build-mb"} - cheshire/cheshire {:mvn/version "5.8.1"} - clj-http/clj-http {:mvn/version "3.9.1"} - enlive/enlive {:mvn/version "1.1.6"} - hiccup/hiccup {:mvn/version "1.0.5"} - org.flatland/ordered {:mvn/version "1.5.7"} - stencil/stencil {:mvn/version "0.5.0"}} - - ;; These are needed for the Athena and Redshift drivers in order to build them. Maven repos from subprojects do not - ;; get copied over -- see - ;; https://ask.clojure.org/index.php/10726/deps-manifest-dependencies-respect-repos-dependent-project - :mvn/repos - {"athena" {:url "https://s3.amazonaws.com/maven-athena"} - "redshift" {:url "https://s3.amazonaws.com/redshift-maven-repository/release"} - ;; for metabase/saml20-clj - "opensaml" {:url "https://build.shibboleth.net/nexus/content/repositories/releases/"}} - - :aliases - {:test {:extra-paths ["test"] - :extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git" - :sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"} - org.clojure/data.json {:mvn/version "2.0.2"}} - :main-opts ["-m" "cognitect.test-runner"]}}} diff --git a/deps.edn b/deps.edn index e7f11e99e2fbe..c44a08919702d 100644 --- a/deps.edn +++ b/deps.edn @@ -32,6 +32,8 @@ org.slf4j/slf4j-api]} com.draines/postal {:mvn/version "2.0.5"} ; SMTP library com.github.seancorfield/honeysql {:mvn/version "2.4.962"} ; Honey SQL 2. SQL generation from Clojure data maps + com.github.seancorfield/next.jdbc {:git/url "https://github.com/seancorfield/next-jdbc.git" + :sha "8f372917a4b8b55c893ef6586f9ffe8dce4cbc7e"} ; for https://github.com/seancorfield/next-jdbc/issues/245; when this makes it to a release we can switch to that. com.github.vertical-blank/sql-formatter {:mvn/version "2.0.3"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter com.google.guava/guava {:mvn/version "31.1-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697 com.fasterxml.jackson.core/jackson-databind @@ -46,6 +48,7 @@ com.vladsch.flexmark/flexmark {:mvn/version "0.64.0"} ; Markdown parsing com.vladsch.flexmark/flexmark-ext-autolink {:mvn/version "0.64.0"} ; Flexmark extension for auto-linking bare URLs + commons-fileupload/commons-fileupload {:mvn/version "1.5"} ; ring/ring-core 1.9.6 uses v1.4, but we want 1.5 due to a CVE. When we upgrade to the forthcoming ring/ring-core 1.10.0 we can remove this. commons-codec/commons-codec {:mvn/version "1.15"} ; Apache Commons -- useful codec util fns commons-io/commons-io {:mvn/version "2.11.0"} ; Apache Commons -- useful IO util fns commons-net/commons-net {:mvn/version "3.9.0"} ; Apache Commons -- useful network utils. Transitive dep of Snowplow, pinned due to CVE-2021-37533 @@ -63,7 +66,7 @@ hiccup/hiccup {:mvn/version "1.0.5"} ; HTML templating honeysql/honeysql {:mvn/version "1.0.461" ; Transform Clojure data structures to SQL :exclusions [org.clojure/clojurescript]} - info.sunng/ring-jetty9-adapter {:mvn/version "0.18.3"} ; Drop-in replacement for official Ring Jetty adapter. Supports Jetty 11 webserver. + info.sunng/ring-jetty9-adapter {:mvn/version "0.18.5"} ; Drop-in replacement for official Ring Jetty adapter. Supports Jetty 11 webserver. instaparse/instaparse {:mvn/version "1.4.12"} ; Make your own parser io.forward/yaml {:mvn/version "1.0.11" ; Clojure wrapper for YAML library SnakeYAML. Don't upgrade yet, new version doesn't support Java 8 (see https://github.com/owainlewis/yaml/issues/37) :exclusions [org.clojure/clojure @@ -85,6 +88,7 @@ nano-id/nano-id {:mvn/version "1.0.0"} ; NanoID generator for generating entity_ids net.cgrand/macrovich {:mvn/version "0.2.1"} ; utils for writing macros for both Clojure & ClojureScript net.i2p.crypto/eddsa {:mvn/version "0.3.0"} ; ED25519 key support (optional dependency for org.apache.sshd/sshd-core) + net.clojars.wkok/openai-clojure {:mvn/version "0.5.0"} ; OpenAI net.redhogs.cronparser/cron-parser-core {:mvn/version "3.5" ; describe Cron schedule in human-readable language :exclusions [joda-time/joda-time ; exclude joda time 2.3 which has outdated timezone information org.slf4j/slf4j-api]} @@ -99,6 +103,8 @@ org.apache.logging.log4j/log4j-jcl {:mvn/version "2.19.0"} ; allows the commons-logging API to work with log4j 2 org.apache.logging.log4j/log4j-jul {:mvn/version "2.19.0"} ; java.util.logging (JUL) -> Log4j2 adapter org.apache.logging.log4j/log4j-slf4j-impl {:mvn/version "2.19.0"} ; allows the slf4j API to work with log4j 2 + org.apache.logging.log4j/log4j-layout-template-json + {:mvn/version "2.20.0"} ; allows the custom json logging format org.apache.poi/poi {:mvn/version "5.2.3"} ; Work with Office documents (e.g. Excel spreadsheets) -- newer version than one specified by Docjure org.apache.poi/poi-ooxml {:mvn/version "5.2.3" :exclusions [org.bouncycastle/bcpkix-jdk15on @@ -231,7 +237,11 @@ ;; locally -- prefer debuggability over performance for local dev work. "-XX:-OmitStackTraceInFastThrow" ;; prevent Java icon from randomly popping up in macOS dock - "-Djava.awt.headless=true"]} + "-Djava.awt.headless=true" + ;; ignore options that aren't present in older versions of Java, like the one below: + "-XX:+IgnoreUnrecognizedVMOptions" + ;; include more details for debugging NPEs (Java 14+) + "-XX:+ShowCodeDetailsInExceptionMessages"]} ;; includes test code as source paths. Run tests with clojure -X:dev:test :test @@ -259,7 +269,8 @@ ;; alias for CI-specific options. :ci {:jvm-opts ["-Xmx2g" - ;; normally CircleCI sets `CI` as an env var, so this is mostly to replicate that locally. + ;; normally CircleCI sets `CI` as an env var, so this is mostly to replicate that locally. Hawk will not + ;; print the progress bar in output when this is set. Progress bars aren't very CI friendly "-Dci=TRUE"]} ;; include EE source code. @@ -270,7 +281,7 @@ ;; for ee dev: :dev:ee:ee-dev ;; for ee tests: clojure -X:dev:ee:ee-dev:test :ee-dev - {:extra-paths ["enterprise/backend/test"]} + {:extra-paths ["enterprise/backend/test" "modules/drivers/starburst/test"]} ;; these aliases exist for symmetry with the ee aliases. Empty for now. :oss @@ -320,7 +331,7 @@ "modules/drivers/sparksql/test" "modules/drivers/sqlite/test" "modules/drivers/sqlserver/test" - "modules/drivers/vertica/test"]} + "modules/drivers/vertica/test" "modules/drivers/starburst/test"]} ;;; Linters @@ -454,16 +465,119 @@ ;; different port from `:test` so you can run it at the same time as `:test`. :jvm-opts ["-Dmb.jetty.port=3002"]} -;;; building Uberjar +;;; building Uberjar, build and release scripts - ;; clojure -T:build uberjar - ;; clojure -T:build uberjar :edition :ee :build - {:deps {io.github.clojure/tools.build {:git/tag "v0.7.5" :git/sha "2526f58"} - com.github.seancorfield/depstar {:mvn/version "2.1.303"} - metabase/build.common {:local/root "bin/common"} - metabase/buid-mb {:local/root "bin/build-mb"}} - :ns-default build} + {:extra-paths + ["bin/build/resources" + "bin/build/src"] + + :extra-deps + {com.bhauman/spell-spec {:mvn/version "0.1.1"} ; used to find misspellings in YAML files + com.github.seancorfield/depstar {:mvn/version "2.1.303"} + expound/expound {:mvn/version "0.7.0"} ; better output of spec validation errors + io.github.borkdude/grasp {:mvn/version "0.0.3"} + io.github.clojure/tools.build {:git/tag "v0.9.3" :git/sha "e537cd1"} + org.clojure/data.xml {:mvn/version "0.2.0-alpha8"} + org.clojure/tools.deps.alpha {:mvn/version "0.12.985"} + org.fedorahosted.tennera/jgettext {:mvn/version "0.15.1"}} + + :jvm-opts + ["-Dclojure.main.report=stderr" + "-XX:-OmitStackTraceInFastThrow" + "-XX:+IgnoreUnrecognizedVMOptions" + "-XX:+ShowCodeDetailsInExceptionMessages"]} + + ;; Build everything: + ;; + ;; clojure -X:drivers:build:build/all + ;; clojure -X:drivers:build:build/all :edition :ee + ;; + ;; Run just a specific build step: + ;; + ;; clojure -X:drivers:build:build/all :steps '[:version]' + ;; + ;; the various steps available are: + ;; + ;; :version :translations :frontend :licenses :drivers :uberjar + :build/all + {:exec-fn build/build-cli} + + ;; build just the uberjar (without i18n, drivers, etc.) + ;; + ;; clojure -X:build:build/uberjar + ;; clojure -X:build:build/uberjar :edition :ee + :build/uberjar + {:exec-fn build.uberjar/uberjar} + + ;; List dependencies without a license. Not really 100% sure why this needs `:dev` + ;; + ;; clojure -X:build:build/list-without-license + :build/list-without-license + {:exec-fn build/list-without-license} + + ;; Build a single driver. + ;; + ;; clojure -X:build:drivers:build/driver :driver :sqlserver :edition :oss + :build/driver + {:exec-fn build-driver/build-driver} + + ;; Build all of the drivers. + ;; + ;; clojure -X:build:drivers:build/drivers + ;; clojure -X:build:drivers:build/drivers :edition :ee + :build/drivers + {:exec-fn build-drivers/build-drivers} + + ;; Verify that a driver JAR looks correct. + ;; + ;; clojure -X:build:build/verify-driver :driver :mongo + :build/verify-driver + {:exec-fn verify-driver/verify-driver} + + ;; Build i18n artifacts. + ;; + ;; clojure -X:build:build/i18n + :build/i18n + {:exec-fn i18n.create-artifacts/create-all-artifacts!} + + ;; Build everything, and run the release steps. + ;; + ;; clojure -X:drivers:build:build/release + ;; clojure -X:drivers:build:build/release :steps '[:build-uberjar :upload-uberjar]' + ;; + ;; Available steps: + ;; + ;; :build-uberjar + ;; :upload-uberjar + ;; :push-git-tags + ;; :publish-draft-release + ;; :publish-elastic-beanstalk-artifacts + ;; :update-version-info + :build/release + {:exec-fn release/release} + + ;; Only in the CI for now: publish EBS artifact + ;; FIXME: remove after full release automation on CI + ;; + ;; clojure -X:build:build/publish-ebs + :build/publish-ebs + {:exec-fn release/publish-ebs} + + ;; extra paths and deps for working on build scripts, or running the tests. + :build-dev + {:extra-paths ["bin/build/test"] + + :extra-deps + {org.clojure/data.json {:mvn/version "2.0.2"}}} + + ;;; Run tests for the build scripts: + ;;; + ;;; clj -X:dev:drivers:build:build-dev:build-test + :build-test + {:exec-fn hawk.core/find-and-run-tests-cli + :exec-args {:only ["bin/build/test" "modules/drivers/starburst/test"]}} + ;;; Other misc convenience aliases diff --git a/deps.edn.bak b/deps.edn.bak new file mode 100644 index 0000000000000..36afaebb8b329 --- /dev/null +++ b/deps.edn.bak @@ -0,0 +1,622 @@ +;; -*- comment-column: 80; -*- +{:deps + ;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + ;; !! PLEASE KEEP THESE ORGANIZED ALPHABETICALLY !! + ;; !! AND ADD A COMMENT EXPLAINING THEIR PURPOSE !! + ;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + {amalloy/ring-buffer {:mvn/version "1.3.1" ; fixed length queue implementation, used in log buffering + :exclusions [org.clojure/clojure + org.clojure/clojurescript]} + amalloy/ring-gzip-middleware {:mvn/version "0.1.4"} ; Ring middleware to GZIP responses if client can handle it + bigml/histogram {:mvn/version "4.1.4"} ; Histogram data structure + buddy/buddy-core {:mvn/version "1.10.413" ; various cryptographic functions + :exclusions [commons-codec/commons-codec + org.bouncycastle/bcpkix-jdk15on + org.bouncycastle/bcprov-jdk15on]} + buddy/buddy-sign {:mvn/version "3.4.333"} ; JSON Web Tokens; High-Level message signing library + camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"} ; util functions for converting between camel, snake, and kebob case + cheshire/cheshire {:mvn/version "5.11.0"} ; fast JSON encoding (used by Ring JSON middleware) + clj-commons/iapetos {:mvn/version "0.1.13"} ; prometheus metrics + clj-http/clj-http {:mvn/version "3.12.3" ; HTTP client + :exclusions [commons-codec/commons-codec + commons-io/commons-io + slingshot/slingshot]} + clojure.java-time/clojure.java-time {:mvn/version "1.1.0"} ; java.time utilities + clojurewerkz/quartzite {:mvn/version "2.1.0" ; scheduling library + :exclusions [c3p0/c3p0 + org.quartz-scheduler/quartz]} + colorize/colorize {:mvn/version "0.1.1" ; string output with ANSI color codes (for logging) + :exclusions [org.clojure/clojure]} + com.clearspring.analytics/stream {:mvn/version "2.9.8" ; Various sketching algorithms + :exclusions [it.unimi.dsi/fastutil + org.slf4j/slf4j-api]} + com.draines/postal {:mvn/version "2.0.5"} ; SMTP library + com.github.seancorfield/honeysql {:mvn/version "2.4.962"} ; Honey SQL 2. SQL generation from Clojure data maps + com.github.seancorfield/next.jdbc {:git/url "https://github.com/seancorfield/next-jdbc.git" + :sha "8f372917a4b8b55c893ef6586f9ffe8dce4cbc7e"} ; for https://github.com/seancorfield/next-jdbc/issues/245; when this makes it to a release we can switch to that. + com.github.vertical-blank/sql-formatter {:mvn/version "2.0.3"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter + com.google.guava/guava {:mvn/version "31.1-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697 + com.fasterxml.jackson.core/jackson-databind + {:mvn/version "2.14.1"} ; JSON processor used by snowplow-java-tracker + com.fasterxml.woodstox/woodstox-core {:mvn/version "6.4.0"} ; trans dep of commons-codec (pinned version due to CVE-2022-40151) + com.h2database/h2 {:mvn/version "2.1.212"} ; embedded SQL database + com.gfredericks/test.chuck {:mvn/version "0.2.13"} ; generating strings from regex + com.snowplowanalytics/snowplow-java-tracker + {:mvn/version "0.12.0" ; Snowplow analytics + :exclusions [com.fasterxml.jackson.core/jackson-databind]} + com.taoensso/nippy {:mvn/version "3.2.0"} ; Fast serialization (i.e., GZIP) library for Clojure + com.vladsch.flexmark/flexmark {:mvn/version "0.64.0"} ; Markdown parsing + com.vladsch.flexmark/flexmark-ext-autolink + {:mvn/version "0.64.0"} ; Flexmark extension for auto-linking bare URLs + commons-fileupload/commons-fileupload {:mvn/version "1.5"} ; ring/ring-core 1.9.6 uses v1.4, but we want 1.5 due to a CVE. When we upgrade to the forthcoming ring/ring-core 1.10.0 we can remove this. + commons-codec/commons-codec {:mvn/version "1.15"} ; Apache Commons -- useful codec util fns + commons-io/commons-io {:mvn/version "2.11.0"} ; Apache Commons -- useful IO util fns + commons-net/commons-net {:mvn/version "3.9.0"} ; Apache Commons -- useful network utils. Transitive dep of Snowplow, pinned due to CVE-2021-37533 + commons-validator/commons-validator {:mvn/version "1.7" ; Apache Commons -- useful validation util fns + :exclusions [commons-beanutils/commons-beanutils + commons-digester/commons-digester + commons-logging/commons-logging]} + compojure/compojure {:mvn/version "1.7.0" ; HTTP Routing library built on Ring + :exclusions [ring/ring-codec]} + crypto-random/crypto-random {:mvn/version "1.2.1"} ; library for generating cryptographically secure random bytes and strings + dk.ative/docjure {:mvn/version "1.19.0" ; excel export + :exclusions [org.apache.poi/poi + org.apache.poi/poi-ooxml]} + environ/environ {:mvn/version "1.2.0"} ; env vars/Java properties abstraction + hiccup/hiccup {:mvn/version "1.0.5"} ; HTML templating + honeysql/honeysql {:mvn/version "1.0.461" ; Transform Clojure data structures to SQL + :exclusions [org.clojure/clojurescript]} + info.sunng/ring-jetty9-adapter {:mvn/version "0.18.5"} ; Drop-in replacement for official Ring Jetty adapter. Supports Jetty 11 webserver. + instaparse/instaparse {:mvn/version "1.4.12"} ; Make your own parser + io.forward/yaml {:mvn/version "1.0.11" ; Clojure wrapper for YAML library SnakeYAML. Don't upgrade yet, new version doesn't support Java 8 (see https://github.com/owainlewis/yaml/issues/37) + :exclusions [org.clojure/clojure + org.flatland/ordered + org.yaml/snakeyaml]} + io.github.camsaul/toucan2 {:mvn/version "1.0.519"} + io.github.camsaul/toucan2-toucan1 {:mvn/version "1.0.519"} + io.github.resilience4j/resilience4j-retry {:mvn/version "1.7.1"} ; Support for retrying operations + io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector + io.prometheus/simpleclient_jetty {:mvn/version "0.16.0"} ; prometheus jetty collector + joda-time/joda-time {:mvn/version "2.12.2"} + kixi/stats {:mvn/version "0.4.4" ; Various statistic measures implemented as transducers + :exclusions [org.clojure/data.avl]} + medley/medley {:mvn/version "1.4.0"} ; lightweight lib of useful functions + metabase/connection-pool {:mvn/version "1.2.0"} ; simple wrapper around C3P0. JDBC connection pools + metabase/saml20-clj {:mvn/version "2.1.0"} ; EE SAML integration + metabase/throttle {:mvn/version "1.0.2"} ; Tools for throttling access to API endpoints and other code pathways + metosin/malli {:mvn/version "0.10.0"} ; Data-driven Schemas for Clojure/Script and babashka + nano-id/nano-id {:mvn/version "1.0.0"} ; NanoID generator for generating entity_ids + net.cgrand/macrovich {:mvn/version "0.2.1"} ; utils for writing macros for both Clojure & ClojureScript + net.i2p.crypto/eddsa {:mvn/version "0.3.0"} ; ED25519 key support (optional dependency for org.apache.sshd/sshd-core) + net.redhogs.cronparser/cron-parser-core {:mvn/version "3.5" ; describe Cron schedule in human-readable language + :exclusions [joda-time/joda-time ; exclude joda time 2.3 which has outdated timezone information + org.slf4j/slf4j-api]} + net.sf.cssbox/cssbox {:mvn/version "5.0.0" ; HTML / CSS rendering + :exclusions [org.slf4j/slf4j-api]} + net.thisptr/jackson-jq {:mvn/version "1.0.0-preview.20220705"} ; Java implementation of the JQ json query language + org.apache.commons/commons-compress {:mvn/version "1.22"} ; compression utils + org.apache.commons/commons-lang3 {:mvn/version "3.12.0"} ; helper methods for working with java.lang stuff + org.apache.logging.log4j/log4j-1.2-api {:mvn/version "2.19.0"} ; apache logging framework + org.apache.logging.log4j/log4j-api {:mvn/version "2.19.0"} ; add compatibility with log4j 1.2 + org.apache.logging.log4j/log4j-core {:mvn/version "2.19.0"} ; apache logging framework + org.apache.logging.log4j/log4j-jcl {:mvn/version "2.19.0"} ; allows the commons-logging API to work with log4j 2 + org.apache.logging.log4j/log4j-jul {:mvn/version "2.19.0"} ; java.util.logging (JUL) -> Log4j2 adapter + org.apache.logging.log4j/log4j-slf4j-impl {:mvn/version "2.19.0"} ; allows the slf4j API to work with log4j 2 + org.apache.logging.log4j/log4j-layout-template-json + {:mvn/version "2.20.0"} ; allows the custom json logging format + org.apache.poi/poi {:mvn/version "5.2.3"} ; Work with Office documents (e.g. Excel spreadsheets) -- newer version than one specified by Docjure + org.apache.poi/poi-ooxml {:mvn/version "5.2.3" + :exclusions [org.bouncycastle/bcpkix-jdk15on + org.bouncycastle/bcprov-jdk15on]} + org.apache.sshd/sshd-core {:mvn/version "2.9.2"} ; ssh tunneling and test server + org.apache.xmlgraphics/batik-all {:mvn/version "1.16"} ; SVG -> image + org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} ; LDAP client + org.bouncycastle/bcpkix-jdk15on {:mvn/version "1.70"} ; Bouncy Castle crypto library -- explicit version of BC specified to resolve illegal reflective access errors + org.bouncycastle/bcprov-jdk15on {:mvn/version "1.70"} + org.clojure/clojure {:mvn/version "1.11.1"} + org.clojure/core.async {:mvn/version "1.6.673" + :exclusions [org.clojure/tools.reader]} + org.clojure/core.logic {:mvn/version "1.0.1"} ; optimized pattern matching library for Clojure + org.clojure/core.match {:mvn/version "1.0.0"} + org.clojure/core.memoize {:mvn/version "1.0.257"} ; useful FIFO, LRU, etc. caching mechanisms + org.clojure/data.csv {:mvn/version "1.0.1"} ; CSV parsing / generation + org.clojure/data.xml {:mvn/version "0.0.8"} ; XML parsing / generation + org.clojure/java.classpath {:mvn/version "1.0.0"} ; examine the Java classpath from Clojure programs + org.clojure/java.jdbc {:mvn/version "0.7.12"} ; basic JDBC access from Clojure + org.clojure/java.jmx {:mvn/version "1.0.0"} ; JMX bean library, for exporting diagnostic info + org.clojure/math.combinatorics {:mvn/version "0.1.6"} ; combinatorics functions + org.clojure/math.numeric-tower {:mvn/version "0.0.5"} ; math functions like `ceil` + org.clojure/tools.logging {:mvn/version "1.2.4"} ; logging framework + org.clojure/tools.macro {:mvn/version "0.1.5"} ; local macros + org.clojure/tools.namespace {:mvn/version "1.3.0"} + org.clojure/tools.reader {:mvn/version "1.3.6"} + org.clojure/tools.trace {:mvn/version "0.7.11"} ; function tracing + org.eclipse.jetty/jetty-server {:mvn/version "11.0.13"} ; web server + org.flatland/ordered {:mvn/version "1.15.10"} ; ordered maps & sets + org.graalvm.js/js {:mvn/version "22.3.0"} ; JavaScript engine + org.liquibase/liquibase-core {:mvn/version "4.11.0" ; migration management (Java lib) + :exclusions [ch.qos.logback/logback-classic]} + org.mariadb.jdbc/mariadb-java-client {:mvn/version "2.7.6"} ; MySQL/MariaDB driver + org.mindrot/jbcrypt {:mvn/version "0.4"} ; Crypto library + org.postgresql/postgresql {:mvn/version "42.5.1"} ; Postgres driver + org.quartz-scheduler/quartz {:mvn/version "2.3.2"} ; Quartz job scheduler, provided by quartzite but this is a newer version. + org.slf4j/slf4j-api {:mvn/version "1.7.36"} ; abstraction for logging frameworks -- allows end user to plug in desired logging framework at deployment time + org.tcrawley/dynapath {:mvn/version "1.1.0"} ; Dynamically add Jars (e.g. Oracle or Vertica) to classpath + org.threeten/threeten-extra {:mvn/version "1.7.2"} ; extra Java 8 java.time classes like DayOfMonth and Quarter + org.yaml/snakeyaml {:mvn/version "1.33"} ; YAML parser + potemkin/potemkin {:mvn/version "0.4.6" ; utility macros & fns + :exclusions [riddley/riddley]} + pretty/pretty {:mvn/version "1.0.5"} ; protocol for defining how custom types should be pretty printed + prismatic/schema {:mvn/version "1.4.1"} ; Data schema declaration and validation library + redux/redux {:mvn/version "0.1.4"} ; Utility functions for building and composing transducers + riddley/riddley {:mvn/version "0.2.0"} ; code walking lib -- used interally by Potemkin, manifold, etc. + ring/ring-core {:mvn/version "1.9.6"} ; web server (Jetty wrapper) + ring/ring-json {:mvn/version "0.5.1"} ; Ring middleware for reading/writing JSON automatically + slingshot/slingshot {:mvn/version "0.12.2"} ; enhanced throw/catch, used by other deps + stencil/stencil {:mvn/version "0.5.0"} ; Mustache templates for Clojure + user-agent/user-agent {:mvn/version "0.1.0"} ; User-Agent string parser, for Login History page & elsewhere + weavejester/dependency {:mvn/version "0.2.1"}} ; Dependency graphs and topological sorting + + ;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + ;; !! PLEASE KEEP NEW DEPENDENCIES ABOVE ALPHABETICALLY ORGANIZED AND ADD COMMENTS EXPLAINING THEM. !! + ;; !! *PLEASE DO NOT* ADD NEW ONES TO THE BOTTOM OF THE LIST. !! + ;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + + :paths + ["src" "shared/src" "resources"] + + ;; These are needed for the Athena and Redshift drivers if you are developing against them locally. If those drivers' + ;; dependencies are not included (i.e., if we don't have the `:drivers` profile), these repos are effectively + ;; ignored. + ;; + ;; 1. Maven repos from subprojects do not get copied over -- see + ;; https://ask.clojure.org/index.php/10726/deps-manifest-dependencies-respect-repos-dependent-project + ;; + ;; 2. You cannot include `:mvn/repos` inside of an alias -- see + ;; https://ask.clojure.org/index.php/12367/support-mvn-repos-inside-an-alias -- if we could, this could go in the + ;; `:drivers` alias instead. + :mvn/repos + {"athena" {:url "https://s3.amazonaws.com/maven-athena"} + "redshift" {:url "https://s3.amazonaws.com/redshift-maven-repository/release"} + ;; for metabase/saml20-clj + "opensaml" {:url "https://build.shibboleth.net/nexus/content/repositories/releases/"}} + + :aliases + { +;;; Local Dev & test profiles + + ;; for local development: start a REPL with + ;; + ;; clojure -A:dev (basic dev REPL that includes test namespaces) + ;; clojure -A:dev:drivers:drivers-dev (dev REPL w/ drivers + tests) + ;; clojure -A:dev:ee:ee-dev (dev REPL w/ EE code including tests) + ;; + ;; You can start a web server from this REPL with + ;; + ;; (require 'dev) + ;; (dev/start!) + :dev + {:extra-deps + {clj-http-fake/clj-http-fake {:mvn/version "1.0.3" + :exclusions [slingshot/slingshot]} + clj-kondo/clj-kondo {:mvn/version "2023.01.16"} ; this is not for RUNNING kondo, but so we can hack on custom hooks code from the REPL. + cloverage/cloverage {:mvn/version "1.2.4"} + com.gfredericks/test.chuck {:mvn/version "0.2.13"} ; generating strings from regexes (useful with malli) + djblue/portal {:mvn/version "0.35.0"} ; ui for inspecting values + io.github.camsaul/humane-are {:mvn/version "1.0.2"} + io.github.metabase/hawk {:sha "45ed36008014f9ac1ea66beb56fb1c4c39f8342b"} + jonase/eastwood {:mvn/version "1.3.0"} ; inspects namespaces and reports possible problems using tools.analyzer + lambdaisland/deep-diff2 {:mvn/version "2.7.169"} ; way better diffs + methodical/methodical {:mvn/version "0.15.1"} ; drop-in replacements for Clojure multimethods and adds several advanced features + org.clojure/algo.generic {:mvn/version "0.1.3"} + pjstadig/humane-test-output {:mvn/version "0.11.0"} + reifyhealth/specmonstah {:mvn/version "2.1.0" + :exclusions [org.clojure/clojure + org.clojure/clojurescript]} ; lets you write test fixtures that are clear, concise, and easy to maintain (clojure.spec) + ring/ring-mock {:mvn/version "0.4.0"} ; creating Ring request maps for testing purposes + talltale/talltale {:mvn/version "0.5.13"}} ; generates fake data, useful for prototyping or load testing + + :extra-paths ["dev/src" "local/src" "test" "shared/test" "test_resources"] + :jvm-opts ["-Dmb.run.mode=dev" + "-Dmb.field.filter.operators.enabled=true" + "-Dmb.test.env.setting=ABCDEFG" + "-Duser.timezone=UTC" + "-Dfile.encoding=UTF-8" + "-Duser.language=en" + "-Duser.country=US" + ;; set the logging properties set in metabase.bootstrap. calling (dev) will load it but putting here to be sure + "-Dlog4j2.contextSelector=org.apache.logging.log4j.core.selector.BasicContextSelector" + "-Dclojure.tools.logging.factory=clojure.tools.logging.impl/log4j2-factory" + ;; If Clojure fails to start (e.g. because of a compilation error somewhere) print the error + ;; report/stacktrace to stderr rather than to a random EDN file in /tmp/ + "-Dclojure.main.report=stderr" + ;; Exceptions that get thrown repeatedly are created without stacktraces as a performance + ;; optimization in newer Java versions. This makes debugging pretty hard when working on stuff + ;; locally -- prefer debuggability over performance for local dev work. + "-XX:-OmitStackTraceInFastThrow" + ;; prevent Java icon from randomly popping up in macOS dock + "-Djava.awt.headless=true" + ;; ignore options that aren't present in older versions of Java, like the one below: + "-XX:+IgnoreUnrecognizedVMOptions" + ;; include more details for debugging NPEs (Java 14+) + "-XX:+ShowCodeDetailsInExceptionMessages"]} + + ;; includes test code as source paths. Run tests with clojure -X:dev:test + :test + {:extra-paths ["test_config"] + :exec-fn metabase.test-runner/find-and-run-tests-cli + :jvm-opts ["-Dmb.run.mode=test" + "-Dmb.db.in.memory=true" + "-Dmb.jetty.join=false" + "-Dmb.field.filter.operators.enabled=true" + "-Dmb.api.key=test-api-key" + ;; Different port from normal `:dev` so you can run tests on a different server. + ;; TODO -- figure out how to do a random port like in the old project.clj? + "-Dmb.jetty.port=3001"]} + + ;; run the dev server with + ;; clojure -M:run + ;; clojure -M:run:drivers (include all drivers) + ;; clojure -M:run:ee (include EE code) + :run + {:main-opts ["-m" "metabase.bootstrap"] + :jvm-opts ["-Dmb.run.mode=dev" + "-Djava.awt.headless=true" ; prevent Java icon from randomly popping up in macOS dock + "-Dmb.jetty.port=3000"]} + + ;; alias for CI-specific options. + :ci + {:jvm-opts ["-Xmx2g" + ;; normally CircleCI sets `CI` as an env var, so this is mostly to replicate that locally. Hawk will not + ;; print the progress bar in output when this is set. Progress bars aren't very CI friendly + "-Dci=TRUE"]} + + ;; include EE source code. + :ee + {:extra-paths ["enterprise/backend/src"]} + + ;; Include EE tests. + ;; for ee dev: :dev:ee:ee-dev + ;; for ee tests: clojure -X:dev:ee:ee-dev:test + :ee-dev + {:extra-paths ["enterprise/backend/test"]} + + ;; these aliases exist for symmetry with the ee aliases. Empty for now. + :oss + {} + + :oss-dev + {} + + :cljs + {:extra-paths ["test" "shared/test"] + :extra-deps + {binaryage/devtools {:mvn/version "1.0.6"} + cider/cider-nrepl {:mvn/version "0.30.0"} + cider/piggieback {:mvn/version "0.5.3"} + cljs-bean/cljs-bean {:mvn/version "1.9.0"} + com.lambdaisland/glogi {:mvn/version "1.2.164"} + io.github.metabase/hawk {:sha "45ed36008014f9ac1ea66beb56fb1c4c39f8342b"} + ;; Forcibly targeting a newer release than ships by default with shadow-cljs 2.20.20. + ;; It fixes an issue where TypeScript can't parse the `cljs_env.js` output file. + org.clojure/google-closure-library {:mvn/version "0.0-20230227-c7c0a541"} + refactor-nrepl/refactor-nrepl {:mvn/version "3.6.0"} + thheller/shadow-cljs {:mvn/version "2.20.20"}}} + + ;; for local dev -- include the drivers locally with :dev:drivers + :drivers + {:extra-deps + {metabase/driver-modules {:local/root "modules/drivers"}}} + + ;; for local dev: include drivers as well as their tests. + ;; + ;; clojure -X:dev:drivers:drivers-dev:test + ;; + ;; or + ;; + ;; clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test (for EE) + :drivers-dev + {:extra-paths + ["modules/drivers/athena/test" + "modules/drivers/bigquery-cloud-sdk/test" + "modules/drivers/druid/test" + "modules/drivers/googleanalytics/test" + "modules/drivers/mongo/test" + "modules/drivers/oracle/test" + "modules/drivers/presto-jdbc/test" + "modules/drivers/redshift/test" + "modules/drivers/snowflake/test" + "modules/drivers/sparksql/test" + "modules/drivers/sqlite/test" + "modules/drivers/sqlserver/test" + "modules/drivers/vertica/test"]} + +;;; Linters + + ;; clojure -M:ee:drivers:check + ;; + ;; 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. + :check + {:extra-deps {athos/clj-check {:git/url "https://github.com/athos/clj-check.git" + :sha "518d5a1cbfcd7c952f548e6dbfcb9a4a5faf9062"}} + :main-opts ["-m" "clj-check.check" + "src" + "shared/src" + "enterprise/backend/src" + "modules/drivers/athena/src" + "modules/drivers/bigquery-cloud-sdk/src" + "modules/drivers/druid/src" + "modules/drivers/googleanalytics/src" + "modules/drivers/mongo/src" + "modules/drivers/oracle/src" + "modules/drivers/presto-jdbc/src" + "modules/drivers/redshift/src" + "modules/drivers/snowflake/src" + "modules/drivers/sparksql/src" + "modules/drivers/sqlite/src" + "modules/drivers/sqlserver/src" + "modules/drivers/vertica/src"] + :jvm-opts ["-Dclojure.main.report=stderr"]} + + ;; clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood + :eastwood + {:exec-fn metabase.linters.eastwood/eastwood + :exec-args {;; manually specify the source paths for the time being (exclude test paths) until we fix Eastwood + ;; errors in the test paths (once PR #17193 is merged) + :source-paths ["src" + "shared/src" + "enterprise/backend/src" + "modules/drivers/athena/src" + "modules/drivers/bigquery-cloud-sdk/src" + "modules/drivers/druid/src" + "modules/drivers/googleanalytics/src" + "modules/drivers/mongo/src" + "modules/drivers/oracle/src" + "modules/drivers/presto-jdbc/src" + "modules/drivers/redshift/src" + "modules/drivers/snowflake/src" + "modules/drivers/sparksql/src" + "modules/drivers/sqlite/src" + "modules/drivers/sqlserver/src" + "modules/drivers/vertica/src"] + :exclude-linters [ ;; Turn this off temporarily until we finish removing + ;; self-deprecated functions & macros + :deprecations + ;; this has a fit in libs that use Potemkin `import-vars` such + ;; as `java-time` + :implicit-dependencies + ;; too many false positives for now + :unused-ret-vals + ;; Kondo lints this for us anyway, and this isn't as easy to configure. + :wrong-arity]}} + + ;; clojure -T:whitespace-linter + :whitespace-linter + {:deps {com.github.camsaul/whitespace-linter {:sha "e35bc252ccf5cc74f7d543ef95ad8a3e5131f25b"}} + :ns-default whitespace-linter + :exec-fn whitespace-linter/lint + :exec-args {:paths ["./.dir-locals.el" + "./deps.edn" + "./package.json" + "./shadow-cljs.edn" + ".circleci" + ".clj-kondo" + ".github" + "bin" + "enterprise" + "frontend" + "resources" + "shared" + "src" + "test"] + :include-patterns ["\\.clj.?$" + "\\.edn$" + "\\.el$" + "\\.html$" + "\\.json$" + "\\.jsx?$" + "\\.sh$" + "\\.yaml$" + "\\.yml$"] + :exclude-patterns [".clj-kondo/better-cond/.*" + ".clj-kondo/com.github.seancorfield/.*" + "resources/i18n/.*\\.edn$" + "resources/frontend_client" + "resources/frontend_shared" + "resources/html-entities.edn" + "frontend/src/cljs" + "frontend/test/metabase/lib/urls\\.unit\\.spec\\.js$" + "frontend/test/metabase/lib/formatting\\.unit\\.spec\\.js$" + "shared/src/metabase/shared/util/currency\\.cljc$" + "#.+#$" + "\\.transit\\.json$"]}} + + ;; clojure -X:dev:ee:ee-dev:test:cloverage + :cloverage + {:exec-fn metabase.cloverage-runner/run-project + :exec-args {:fail-threshold 69 + :codecov? true + ;; don't instrument logging forms, since they won't get executed as part of tests anyway + ;; log calls expand to these + :exclude-call + [clojure.tools.logging/logf + clojure.tools.logging/logp + metabase.util.log/logf + metabase.util.log/logp] + + :src-ns-path + ["src" "enterprise/backend/src" "shared/src"] + + :test-ns-path + ["test" "enterprise/backend/test" "shared/test"] + + :ns-regex + ["^metabase.*" "^metabase-enterprise.*"] + + ;; don't instrument Postgres/MySQL driver namespaces, because we don't current run tests for them + ;; as part of recording test coverage, which means they can give us false positives. + ;; + ;; regex literals aren't allowed in EDN. We parse them in `./test/cloverage.clj` + :ns-exclude-regex + ["metabase\\.driver\\.mysql" "metabase\\.driver\\.postgres"]} + ;; different port from `:test` so you can run it at the same time as `:test`. + :jvm-opts ["-Dmb.jetty.port=3002"]} + +;;; building Uberjar, build and release scripts + + :build + {:extra-paths + ["bin/build/resources" + "bin/build/src"] + + :extra-deps + {com.bhauman/spell-spec {:mvn/version "0.1.1"} ; used to find misspellings in YAML files + com.github.seancorfield/depstar {:mvn/version "2.1.303"} + expound/expound {:mvn/version "0.7.0"} ; better output of spec validation errors + io.github.borkdude/grasp {:mvn/version "0.0.3"} + io.github.clojure/tools.build {:git/tag "v0.9.3" :git/sha "e537cd1"} + org.clojure/data.xml {:mvn/version "0.2.0-alpha8"} + org.clojure/tools.deps.alpha {:mvn/version "0.12.985"} + org.fedorahosted.tennera/jgettext {:mvn/version "0.15.1"}} + + :jvm-opts + ["-Dclojure.main.report=stderr" + "-XX:-OmitStackTraceInFastThrow" + "-XX:+IgnoreUnrecognizedVMOptions" + "-XX:+ShowCodeDetailsInExceptionMessages"]} + + ;; Build everything: + ;; + ;; clojure -X:drivers:build:build/all + ;; clojure -X:drivers:build:build/all :edition :ee + ;; + ;; Run just a specific build step: + ;; + ;; clojure -X:drivers:build:build/all :steps '[:version]' + ;; + ;; the various steps available are: + ;; + ;; :version :translations :frontend :licenses :drivers :uberjar + :build/all + {:exec-fn build/build-cli} + + ;; build just the uberjar (without i18n, drivers, etc.) + ;; + ;; clojure -X:build:build/uberjar + ;; clojure -X:build:build/uberjar :edition :ee + :build/uberjar + {:exec-fn build.uberjar/uberjar} + + ;; List dependencies without a license. Not really 100% sure why this needs `:dev` + ;; + ;; clojure -X:build:build/list-without-license + :build/list-without-license + {:exec-fn build/list-without-license} + + ;; Build a single driver. + ;; + ;; clojure -X:build:drivers:build/driver :driver :sqlserver :edition :oss + :build/driver + {:exec-fn build-driver/build-driver} + + ;; Build all of the drivers. + ;; + ;; clojure -X:build:drivers:build/drivers + ;; clojure -X:build:drivers:build/drivers :edition :ee + :build/drivers + {:exec-fn build-drivers/build-drivers} + + ;; Verify that a driver JAR looks correct. + ;; + ;; clojure -X:build:build/verify-driver :driver :mongo + :build/verify-driver + {:exec-fn verify-driver/verify-driver} + + ;; Build i18n artifacts. + ;; + ;; clojure -X:build:build/i18n + :build/i18n + {:exec-fn i18n.create-artifacts/create-all-artifacts!} + + ;; Build everything, and run the release steps. + ;; + ;; clojure -X:drivers:build:build/release + ;; clojure -X:drivers:build:build/release :steps '[:build-uberjar :upload-uberjar]' + ;; + ;; Available steps: + ;; + ;; :build-uberjar + ;; :upload-uberjar + ;; :push-git-tags + ;; :publish-draft-release + ;; :publish-elastic-beanstalk-artifacts + ;; :update-version-info + :build/release + {:exec-fn release/release} + + ;; Only in the CI for now: publish EBS artifact + ;; FIXME: remove after full release automation on CI + ;; + ;; clojure -X:build:build/publish-ebs + :build/publish-ebs + {:exec-fn release/publish-ebs} + + ;; extra paths and deps for working on build scripts, or running the tests. + :build-dev + {:extra-paths ["bin/build/test"] + + :extra-deps + {org.clojure/data.json {:mvn/version "2.0.2"}}} + + ;;; Run tests for the build scripts: + ;;; + ;;; clj -X:dev:drivers:build:build-dev:build-test + :build-test + {:exec-fn hawk.core/find-and-run-tests-cli + :exec-args {:only ["bin/build/test"]}} + + +;;; Other misc convenience aliases + + ;; Profile Metabase start time with clojure -M:profile + :profile + {:main-opts ["-m" "metabase.core" "profile"] + :jvm-opts ["-XX:+CITime" ; print time spent in JIT compiler + "-XX:+PrintGC"]} + + ;; get the H2 shell with clojure -X:dev:h2 + :h2 + {:extra-paths ["dev/src"] + :exec-fn dev.h2-shell/shell + :java-opts ["-Dfile.encoding=UTF-8"]} + + ;; clojure -M:generate-automagic-dashboards-pot + :generate-automagic-dashboards-pot + {:main-opts ["-m" "metabase.automagic-dashboards.rules"]} + + ;; Start a Network REPL (nrepl) that you can connect your editor to. + ;; + ;; clojure -M:dev:nrepl (etc.) + :nrepl + {:extra-deps {nrepl/nrepl {:mvn/version "0.9.0"}} + :main-opts ["-m" "nrepl.cmdline"]} + + ;; - start a Socket REPL on port 50505 that you can connect your editor to: + :socket {:jvm-opts ["-Dclojure.server.repl={:address,\"0.0.0.0\",:port,50505,:accept,clojure.core.server/repl}"]} + + ;; Liquibase CLI: + ;; + ;; clojure -M:liquibase + ;; + ;; e.g. + ;; + ;; clojure -M:liquibase dbDoc target/liquibase + :liquibase + {:extra-deps {ch.qos.logback/logback-classic {:mvn/version "1.4.5"}} + :extra-paths ["dev/src"] + :main-opts ["-m" "dev.liquibase"]}}} + + ;; TODO -- consider creating an alias that includes the `./bin` build-drivers & release code as well so we can work + ;; on them all from a single REPL process. diff --git a/docs/README.md b/docs/README.md index 2b5240fcf6ecf..fdabec555014b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,8 @@ redirect_from: # Metabase documentation +![Metabase dashboard](./images/metabase-product-screenshot.png) + Metabase is an open-source business intelligence platform. You can use Metabase to ask questions about your data, or embed Metabase in your app to let your customers explore their data on their own. ## First steps @@ -51,6 +53,8 @@ Metabase's reference documentation. - [Databases overview](./databases/start.md) - [Adding and managing databases](./databases/connecting.md) +- [Database users, roles, and privileges](./databases/users-roles-privileges.md) +- [Syncing and scanning databases](./databases/sync-scan.md) - [Encrypting your database connection](./databases/encrypting-details-at-rest.md) - [SSH tunneling](./databases/ssh-tunnel.md) - [SSL certificate](./databases/ssl-certificates.md) @@ -177,6 +181,10 @@ Metabase's reference documentation. - [Auditing tools](./usage-and-performance-tools/audit.md) - [Admin tools](./usage-and-performance-tools/tools.md) +### Cloud + +- [Documentation for Metabase Cloud and Store](https://www.metabase.com/docs/latest/cloud/start) + ### Metabase API - [Metabase API documentation](./api-documentation.md) @@ -198,7 +206,7 @@ Metabase's reference documentation. - [Metabase forum](https://discourse.metabase.com/) - [Configuring logging](./configuring-metabase/log-configuration.md) -### [Tutorials and guides](https://www.metabase.com/learn) +### Tutorials and guides [Learn Metabase](https://www.metabase.com/learn) has a ton of articles on how to use Metabase, data best practices, and more. diff --git a/docs/actions/basic.md b/docs/actions/basic.md index 35d1d506e4b02..180ef4012a8d0 100644 --- a/docs/actions/basic.md +++ b/docs/actions/basic.md @@ -4,22 +4,22 @@ title: Basic actions # Basic actions -Basic actions are premade [actions](./introduction.md) that do things that people typically want to do when interacting with a database. +Basic actions are "implicit" [actions](./introduction.md) that do things that people typically want to do when interacting with a database: Create, Update, Delete. Basic actions auto-track the schema of the source table backing the model. By auto-track the schema, we mean that Metabase will create action forms for people to fill out that include all of the fields from the primary source table that underlies that model. -Basic actions auto-track the schema of primary source table backing the model. By auto-track the schema, we mean that Metabase will create forms for people to fill out that include all of the fields in the model from the primary source table that underlies that model, and the action will update whenever you change the model. Basic actions are only be available for models that include a single primary key. +Basic actions are only available for models that "wrap" a single table in a database (so, no joins or custom columns in the model definition). -Custom columns are also excluded because they are computed columns; if you want to change a custom column's values, you should update the values in the columns used to compute that column. - -If you only want to give people the option to update a subset of columns, you can write a [custom action](./custom.md). +If you only want to give people the option to update a subset of columns, or update columns in multiple tables, you can write a [custom action](./custom.md). ## Creating basic actions -Once actions are enabled, you can create basic actions on a new or existing [model](../data-modeling/models.md). +Once actions are enabled, you can create basic actions on a new or existing [model](../data-modeling/models.md) that only wraps a single database table. 1. Select a model and click on the **info** button, then click on **Model detail**. 2. On the model detail page, click on the **Actions** tab. 3. Click on the **...** next to the **New Action** and select **Create basic actions**. +> If your model includes a join or a custom column, or otherwise doesn't map to a single raw table in your database, Metabase cannot create these basic actions. + ## Basic action types ![Basic actions](./images/basic-actions.png) @@ -30,33 +30,34 @@ Basic actions include: - [Delete](#delete) - [Create](#create) -By default, none of the input fields are required for basic actions. - ### Update -The update action will present people with a form with editable fields for each column in the primary source table that's also included in the model. So if the model's source table has columns a, b, c, and d, but the model only includes columns a, b, and c, then the form will only show input fields for columns a, b, and c. +The update action will present people with a form with editable fields for each column in the primary source table. -When setting up an update action on a dashboard, you can either prompt the person to fill in a value for each field, or have a field automatically filled in via parameters (such as values set in dashboard filters). +When setting up an Update action on a dashboard, you must pass an entity key (like an ID) to the action from a dashboard filter. For other values, you can either prompt the person to fill in a value for each field, or have a field automatically filled in via parameters (such as values set in dashboard filters). ### Delete -The Delete action will create a form that prompts people for an ID, and will delete the record (row) in the underlying table that backs the model. +The Delete action will create a form that prompts people for an entity key, and will delete the record (row) corresponding to that ID in the underlying table that backs the model. ### Create -The Create actions is the `INSERT INTO` action. The Create action will present a form with editable fields for each column in the primary source table that's also included in the model. So if the model's source table has columns a, b, c, and d, but the model only includes columns a, b, and c, then the form will only show input fields for columns a, b, and c. - -Once filled out, the action will insert the record into the primary table that underlies the model. +The Create action is the `INSERT INTO` action. The Create action will present a form with editable fields for each column in the primary source table backing the model. Once filled out, the action will insert the record into the primary table that underlies the model. ## Basic actions on dashboards -When setting up actions on a dashboard, you can either prompt the person to fill in a value for each field, or have a field automatically filled in via parameters (such as values set in dashboard filters). See [Actions in dashboards](../dashboards/actions.md). +When setting up actions on a dashboard, you can either prompt the person to fill in a value for each field, or have a field automatically filled in via parameters (such as values set in dashboard filters). + +The Update basic action requires you to pass a value for the entity key. + +See [Actions in dashboards](../dashboards/actions.md). ## Archiving basic actions -Because basic actions are made of magic, you cannot archive them. You can just toggle them on or off. From the model detail page, next the **New action** button, click on the **...** menu and click **Disable basic actions**. +Because basic actions are made of magic, you cannot archive them. You can just toggle them on or off. From the model detail page, next to the **New action** button, click on the **...** menu and click **Disable basic actions**. ## Further reading - [Introduction to actions](./introduction.md) - [Custom actions](./custom.md) +- [Actions in dashboards](../dashboards/actions.md) diff --git a/docs/actions/custom.md b/docs/actions/custom.md index 8b13ac3ecf779..80043b6bebf9f 100644 --- a/docs/actions/custom.md +++ b/docs/actions/custom.md @@ -14,33 +14,18 @@ Write SQL to update records in your databases. There are two ways to create a custom action: -1. Click the **+ New** > **Action**. When you save your action, you'll be prompted to associate that action with a model. (NOTE: the **Action** option will only show up in the **+ New** menu if you've first created, or have access to, a model in Metabase.) +1. Click the **+ New** > **Action**. When you save your action, you'll be prompted to associate that action with a model. (NOTE: the **Action** option will only show up in the **+ New** menu if you've first created, or have access to, a [model](../data-modeling/models.md) in Metabase.) 2. Via the model detail page: from a model, click on the **info** button in the upper right. In the upper right of the sidebar, click **Model detail** > **Actions** > **New action**. -## Custom action editor - -Here you can write your own code to create an action, like writing an action that would only update a subset of the columns in a model. - -For example, you could write an action that would update the `plan` column for a record in the `Invoices` table in the Sample Database: - -``` -{% raw %} -UPDATE invoices -SET plan = {{ plan }} -[[, payment = {{ payment }}]] -WHERE id = {{ id }} -{% endraw %} -``` - -The above code will create a form that prompts people to input updated values for the (required) `plan` field and optionally the `payment` field for a given record specified by `ID`. The code in brackets `[[ ]]` makes it optional: the enclosed statement will only run if someone inserts a value in the payment field. +In the action editor, you can write your own code to create an action, like writing an action that would only update a subset of the columns in a table. See [Example actions](#example-custom-actions). ## Field types for action variables For each {% raw %}{{ variable }}{% endraw %} that you set in an action, you'll need to set the field type. -Each of these variable field types present different options. +Each of these variable field types present different options. Click on the gear icon to change options. -If you don't require a variable, you can optionally specify a default value for Metabase to use in cases where people don't fill out the field. +If you don't require a variable, you can optionally specify a default value for Metabase to use in cases where people don't fill out the field. In the SQL code, remember to enclose any optional variables and commas in brackets, like `{% raw %}[[, column = {{ optional_variable }}]] {% endraw %}`. You can include placeholder text for all fields in the action form. @@ -62,22 +47,23 @@ You can include placeholder text for all fields in the action form. - Date - Date + Time -**Category** - -- Dropdown -- Inline select - For both **Dropdown** and **Inline select**, you can specify a list of options to present on the form, with each option on its own line. ![Dropdown select](./images/dropdown.png) +## Appearance + +The appearance tab in the action editor modal will display a preview of the variable's form element. In the image below, we've clicked on the variable's **gear** and set the variable to use a text > dropdown list. The appearance section gives a preview for what the form element would look like: + +![Appearance gives a preview of the form element](./images/appearance.png) + ## Action settings -From the model detail page, click on the **...** next to the action. Once in the action editor, click on the **gear** icon to bring up the action settings. +From the model detail page, click on the **three dot menu** (...) next to the action. Once in the action editor, click on the **gear** icon to bring up the action settings. ### Make public -Creates a publicly shareable link to the action form. +Creates a publicly shareable link to the action form. Anyone with access to that link can fill out the form and run the action. Useful for creating surveys. ![Public action form](./images/public-form.png) @@ -87,7 +73,118 @@ Here you can edit the success message, which is the message Metabase will displa If something goes wrong, Metabase will display the error message it received from the database. +## Example custom actions + +### Example `UPDATE` action + +You could write an action that would update the `plan` column for a record in the `invoices` table in the Sample Database: + +``` +{% raw %} +UPDATE invoices +SET plan = {{ plan }} + [[, payment = {{ payment }}]] +WHERE + id = {{ id }} +{% endraw %} +``` + +The above code will create a form that prompts people to input updated values for the (required) `plan` field and optionally the `payment` field for a given record specified by `ID`. + +The code in brackets `[[ ]]` makes the statement optional: the bracket-enclosed statement will only run if someone inserts a value in the payment field. Note the comma that separates the statements is _inside_ the brackets. + +![Example action form](./images/form.png) + +### Example `INSERT` action + +Insert statements are pretty straightforward: + +``` +{% raw %} +INSERT INTO invoices ( + account_id + ,payment + ,expected_invoice + ,plan + ,date_received +) +VALUES ( + {{ account_id }} + ,{{ payment }} + ,CAST ({{expected_invoice}} AS boolean) + ,{{plan}} + ,({{date_received}} +); +{% endraw %} +``` + +### `INSERT` statement with optional fields + +Though if you want to make a field optional in an `INSERT` statement, you need to get a little fancy. Metabase will only run an optional statement if a value is supplied to a variable in that optional statement, so the trick here is to use a SQL comment to sneak a variable into the optional column. + +``` +{% raw %} + [[,expected_invoice -- {{ expected_invoice}}]] +{% endraw %} +``` + +So a full `INSERT` statement with an optional variable would look like so (note that the optional clauses for the `{% raw %}{{ expected_invoice}}{{% endraw %}` variable are in both the column and values lists): + +``` +{% raw %} +INSERT INTO invoices ( + account_id + ,payment + [[,expected_invoice -- {{expected_invoice}}]] + ,plan + ,date_received +) +VALUES ( + {{ account_id }} + ,{{ payment }} + [[,{{ expected_invoice }}]] + ,{{ plan }} + ,({{ date_received }} +); +{% endraw %} +``` + +### Casting field values in actions + +If you get a type error when you submit a form, you may need to `CAST` the data type in the query so it matches the data type of the target field in the database. Here we're casting a value to a `boolean`: + +``` +{% raw %} +UPDATE invoices +SET expected_invoice = CAST({{expected_invoice}} AS boolean) +WHERE id = {{id}}; +{% endraw %} +``` + +### Referencing saved questions in actions + +You can also reference saved questions in actions. Here we're taking the results of a `SELECT` statement on a saved question ("Potential customers") and inserting the results into a `people_to_write` table. + +``` +{% raw %} +WITH prospects AS {{#6-potential-customers}} + +INSERT INTO + people_to_write ( + first_name + ,last_name + ,email + ) +SELECT + first_name + ,last_name + ,email +FROM prospects; +{% endraw %} +``` + ## Further reading - [Introduction to actions](./introduction.md) - [Basic actions](./basic.md) +- [Actions in dashboards](../dashboards/actions.md) diff --git a/docs/actions/images/appearance.png b/docs/actions/images/appearance.png new file mode 100644 index 0000000000000..3dcb976a226fa Binary files /dev/null and b/docs/actions/images/appearance.png differ diff --git a/docs/actions/images/dashboard-action.gif b/docs/actions/images/dashboard-action.gif new file mode 100644 index 0000000000000..dd962a5ed9051 Binary files /dev/null and b/docs/actions/images/dashboard-action.gif differ diff --git a/docs/actions/images/dropdown.png b/docs/actions/images/dropdown.png index b3c82f0d92e14..14094b6656835 100644 Binary files a/docs/actions/images/dropdown.png and b/docs/actions/images/dropdown.png differ diff --git a/docs/actions/images/form.png b/docs/actions/images/form.png new file mode 100644 index 0000000000000..b1dc59318a253 Binary files /dev/null and b/docs/actions/images/form.png differ diff --git a/docs/actions/introduction.md b/docs/actions/introduction.md index c99dd2aba71ca..9396f5491ab81 100644 --- a/docs/actions/introduction.md +++ b/docs/actions/introduction.md @@ -4,34 +4,35 @@ title: Introduction to actions # Introduction to actions -> For now, actions are only available for PostgreSQL and MySQL +> For now, actions are only available for PostgreSQL, MySQL, and H2. ![Example action](./images/example-action.png) -**Actions** are entities in Metabase that let you build custom forms and business logic. - ## What are actions? -Actions let you write parameterized SQL that writes back to your database. Actions can be attached to [buttons on dashboards](../dashboards/actions.md) to create custom workflows. You can even publicly share the parameterized forms they generate to collect data. +**Actions** are entities in Metabase that let you build custom forms and business logic. + +Actions let you write parameterized SQL that writes back to your database. Actions can be attached to [buttons on dashboards](../dashboards/actions.md) to create custom workflows. You can even publicly share the parameterized forms that actions generate to collect data. Here are a few ideas for what you can do with actions: -- Create a customer feedback form and embed it on your website. - Mark the customer you’re viewing in a dashboard as a VIP. - Let team members remove redundant data. +- Create a customer feedback form and embed it on your website. -## Enabling actions +Actions must be added to a [model](../data-modeling/models.md), but actions only run on the raw tables that back those models (so actions will never edit your [model definition](../data-modeling/models.md#edit-a-models-query)). -To enable actions for a database connection, admins should click on the gear icon in the upper right and navigate to **Admin settings** > **Databases**, then click on the database you want to create actions for. On the right side of the connection settings form, toggle the **Model actions** option. +## Enabling actions for a database -For actions to work, the database user account (the account you're using to connect to the database) must have write permissions. And for now, actions are only supported on PostgreSQL, MySQL, and H2 databases. +For actions to work, you'll first need to do the following two things: -## Who can use actions +1. **Enable model actions for the database connection**. To enable actions for a database connection, admins should click on the **gear** icon in the upper right and navigate to **Admin settings** > **Databases**, then click on the database you want to create actions for. On the right side of the connection settings form, toggle the **Model actions** option. For actions to work, the database user account (the account you're using to connect to the database) must have [write permissions](../databases/users-roles-privileges.md#privileges-to-enable-actions). And for now, actions are only supported on PostgreSQL, MySQL, and H2 databases. +2. **Create at least one model from that database.** Actions are associated with models, so you'll need to have created (or have access to) at least one model before you can start creating actions. -Actions are associated with models, so you'll need to have created (or access to) at least one model before you can start using actions. +## Who can use actions -- **To create or edit an action**, a person must be in a group with Native query editing privileges for the relevant database. -- **To run an action**, all you need is view access to the action's model or dashboard (or a link to a public action). +- **To create or edit an action**, a person must be in a group with [Native query editing](../permissions/data.md) privileges for the relevant database. +- **To run an action**, all you need is view access to the action's model or dashboard (or a link to a public action). ## Types of actions @@ -48,3 +49,22 @@ There are multiple ways to run actions: - From a [public form](./custom.md#make-public) of an action. - From a [button on dashboard](../dashboards/actions.md). +## Actions change data in tables, which affect models + +Just something to clarify here: actions, even though they are added to models, make their changes to the underlying table that a model queries. Which means that anyone who has access to the underlying table, or to questions or other models based on that table, will be able to see the effects of an action. Tools other than Metabase that are connected to that database will also pick up these changes. + +In this sense, models are containers for actions; models are a way to organize actions. In fact, you could (in theory) add a [custom action](./custom.md) to a model that performs some update unrelated to its model's data. For example, you could write a custom action that updates the `Accounts` table, and add that action to a model that only queries an unrelated table (e.g., the `Orders` table). But, you know, maybe don't do that (unless you have a really good reason). [Basic actions](./basic.md), however, are only be available for models that wrap a single raw table. + +Before using actions in production, consider playing around with actions on some sample data (like the Sample Database included with Metabase) to get a feel for how they work. + +## Action gotchas + +- If caching is enabled for the relevant table or model, you may not see the effects of an action in Metabase until Metabase refreshes the data (though you can always manually refresh the data). +- When creating records on a table that lacks an automatically generated primary key, you'll need to input an available ID (i.e., an ID not already in use by another record). +- You can't "undo" actions. You can, however, create and run an action to recreate a deleted record, or change an updated record back to its original values (provided you know the original values). + +## Further reading + +- [Basic actions](./basic.md) +- [Custom actions](./custom.md) +- [Actions in dashboards](../dashboards/actions.md) diff --git a/docs/actions/start.md b/docs/actions/start.md index c3f2003c79d0a..0dc531bd0d130 100644 --- a/docs/actions/start.md +++ b/docs/actions/start.md @@ -4,6 +4,8 @@ title: Actions overview # Actions overview +![An action updating a plan on a dashboard](./images/dashboard-action.gif) + Actions let you write parameterized SQL that can then be attached to buttons, clicks, or even added on the page as form elements. ## [Introduction to actions ](./introduction.md) @@ -17,3 +19,7 @@ Metabase will create basic actions that auto-track a model's schema. ## [Custom actions](./custom.md) Write SQL to create new actions. + +## [Actions on dashboards](../dashboards/actions.md) + +Add actions on dashboards as buttons that you can pass filter values to. \ No newline at end of file diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 18b825384114a..fe51d0209f601 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -26,6 +26,7 @@ _* indicates endpoints used for features available on [paid plans](https://www.m - [Action](api/action.md) - [Activity](api/activity.md) +- [Advanced config logs*](api/ee/advanced-config-logs.md) - [Advanced permissions application*](api/ee/advanced-permissions-application.md) - [Alert](api/alert.md) - [Audit app user*](api/ee/audit-app-user.md) diff --git a/docs/api/action.md b/docs/api/action.md index 8abb671b88750..2df69eb34d66a 100644 --- a/docs/api/action.md +++ b/docs/api/action.md @@ -24,11 +24,12 @@ Delete the publicly-accessible link to this Dashboard. ## `GET /api/action/` -Returns cards that can be used for QueryActions. +Returns actions that can be used for QueryActions. By default lists all viewable actions. Pass optional + `?model-id=` to limit to actions on a particular model. ### PARAMS: -* **`model-id`** integer greater than 0 +* **`model-id`** nullable value must be an integer greater than zero. ## `GET /api/action/:action-id` diff --git a/docs/api/activity.md b/docs/api/activity.md index b98dcd0affdc9..66a9753786d7b 100644 --- a/docs/api/activity.md +++ b/docs/api/activity.md @@ -19,7 +19,7 @@ Get the list of 5 popular things for the current user. Query takes 8 and limits ## `GET /api/activity/recent_views` -Get the list of 5 things the current user has been viewing most recently. +Get a list of 5 things the current user has been viewing most recently. --- diff --git a/docs/api/collection.md b/docs/api/collection.md index 61261fd9c6c8f..69827cdd86bd8 100644 --- a/docs/api/collection.md +++ b/docs/api/collection.md @@ -22,10 +22,15 @@ Fetch a list of all Collections that the current user has read permissions for ( By default, this returns non-archived Collections, but instead you can show archived ones by passing `?archived=true`. + By default, admin users will see all collections. To hide other user's collections pass in + `?exclude-other-user-collections=true`. + ### PARAMS: * **`archived`** value may be nil, or if non-nil, value must be a valid boolean string ('true' or 'false'). +* **`exclude-other-user-collections`** value may be nil, or if non-nil, value must be a valid boolean string ('true' or 'false'). + * **`namespace`** value may be nil, or if non-nil, value must be a non-blank string. ## `GET /api/collection/:id` @@ -157,6 +162,8 @@ Similar to `GET /`, but returns Collections in a tree structure, e.g. * **`exclude-archived`** value may be nil, or if non-nil, value must be a valid boolean string ('true' or 'false'). +* **`exclude-other-user-collections`** value may be nil, or if non-nil, value must be a valid boolean string ('true' or 'false'). + * **`namespace`** value may be nil, or if non-nil, value must be a non-blank string. ## `POST /api/collection/` diff --git a/docs/api/ee/advanced-config-logs.md b/docs/api/ee/advanced-config-logs.md new file mode 100644 index 0000000000000..b54c5b11fe0d5 --- /dev/null +++ b/docs/api/ee/advanced-config-logs.md @@ -0,0 +1,32 @@ +--- +title: "Advanced config logs" +summary: | + /api/logs endpoints. + + These endpoints are meant to be used by admins to download logs before entries are auto-removed after the day limit. + + For example, the `query_execution` table will have entries removed after 30 days by default, and admins may wish to + keep logs externally for longer than this retention period. +--- + +# Advanced config logs + +/api/logs endpoints. + + These endpoints are meant to be used by admins to download logs before entries are auto-removed after the day limit. + + For example, the `query_execution` table will have entries removed after 30 days by default, and admins may wish to + keep logs externally for longer than this retention period. + +## `GET /api/ee/advanced-config/logs/query_execution/:yyyy-mm` + +Fetch rows for the month specified by `:yyyy-mm` from the query_execution logs table. + Must be a superuser. + +### PARAMS: + +* **`yyyy-mm`** + +--- + +[<< Back to API index](../../api-documentation.md) \ No newline at end of file diff --git a/docs/api/ee/sandbox-user.md b/docs/api/ee/sandbox-user.md index a2cd17b7401e1..5006c542caf7b 100644 --- a/docs/api/ee/sandbox-user.md +++ b/docs/api/ee/sandbox-user.md @@ -21,7 +21,7 @@ Update the `login_attributes` for a User. * **`id`** -* **`login_attributes`** value must be a valid user attributes map (name -> value) +* **`login_attributes`** nullable value must be a valid user attributes map (name -> value) --- diff --git a/docs/api/permissions.md b/docs/api/permissions.md index df32837961414..67368daf175fa 100644 --- a/docs/api/permissions.md +++ b/docs/api/permissions.md @@ -123,7 +123,7 @@ You must be a superuser to do this. ### PARAMS: -* **`body`** value must be a map. +* **`body`** map ## `PUT /api/permissions/group/:group-id` diff --git a/docs/api/pulse.md b/docs/api/pulse.md index 5d286fca26f15..e8d27c20c6a8c 100644 --- a/docs/api/pulse.md +++ b/docs/api/pulse.md @@ -23,9 +23,11 @@ Fetch all dashboard subscriptions. By default, returns only subscriptions for wh If `dashboard_id` is specified, restricts results to subscriptions for that dashboard. - If `can_read` is `true`, it specifically returns all subscriptions for which the current user + If `created_or_receive` is `true`, it specifically returns all subscriptions for which the current user created *or* is a known recipient of. Note that this is a superset of the default items returned for non-admins, and a subset of the default items returned for admins. This is used to power the /account/notifications page. + This may include subscriptions which the current user does not have collection permissions for, in which case + some sensitive metadata (the list of cards and recipients) is stripped out. ### PARAMS: @@ -33,11 +35,12 @@ Fetch all dashboard subscriptions. By default, returns only subscriptions for wh * **`dashboard_id`** value may be nil, or if non-nil, value must be an integer greater than zero. -* **`can_read`** value may be nil, or if non-nil, value must be a valid boolean string ('true' or 'false'). +* **`creator_or_recipient`** value may be nil, or if non-nil, value must be a valid boolean string ('true' or 'false'). ## `GET /api/pulse/:id` -Fetch `Pulse` with ID. +Fetch `Pulse` with ID. If the user is a recipient of the Pulse but does not have read permissions for its collection, + we still return it but with some sensitive metadata removed. ### PARAMS: diff --git a/docs/configuring-metabase/appearance.md b/docs/configuring-metabase/appearance.md index f48c4a53df34b..5e5d4655e5ef2 100644 --- a/docs/configuring-metabase/appearance.md +++ b/docs/configuring-metabase/appearance.md @@ -49,6 +49,14 @@ You can customize the colors that Metabase uses throughout the app: You can choose up to 24 hex values. If you choose fewer than 24 colors, Metabase will auto-generate colors to fill in the rest of the values. +Custom colors are unavailable for: + +- [Number charts](../questions/sharing/visualizing-results.md#numbers) +- [Trend charts](../questions/sharing/visualizing-results.md#trends) +- [Funnel charts](../questions/sharing/visualizing-results.md#funnel-charts) +- Conditional formatting ([tables](../questions/sharing/visualizing-results.md#tables) and [pivot tables](../questions/sharing/visualizing-results.md#pivot-tables)) +- [Maps](../questions/sharing/visualizing-results.md#maps) + ## Logo You can replace Metabase’s familiar, tasteful, inspired-yet-not-threateningly-avant-garde dotted M logo with your very own logo. For things to work best, the logo you upload should be an SVG file that looks good when it’s around 60px tall. (In other words, ask the nearest designer for help.) diff --git a/docs/configuring-metabase/caching.md b/docs/configuring-metabase/caching.md index 4cc2708df2fa8..8f21c54722890 100644 --- a/docs/configuring-metabase/caching.md +++ b/docs/configuring-metabase/caching.md @@ -7,44 +7,121 @@ redirect_from: # Caching query results -Metabase now gives you the ability to automatically cache the results of queries that take a long time to run. +If your question results don't change frequently, you may want to store the results in Metabase so that the next time you visit the question, Metabase can retrieve the stored results rather than query the database again. -## Enabling caching +For example, if your data only updates once a day, there's no point in querying the database more than once a day, as they data won't have changed. Returning cached results can be significantly faster, as the database won't have to recompute the results to load your question. -To start caching your queries, head to the Settings section of the Admin Panel, and click on the `Caching` tab at the bottom of the side navigation. Then turn the caching toggle to `Enabled`. +Metabase gives you the ability to automatically cache question results that meet a [minimum query duration](#minimum-query-duration). -![Caching](images/caching.png) +If your questions share a common model, you can enable [model caching](../data-modeling/models.md#model-caching) instead. -End-users will see a timestamp on cached questions in the top right of the question detail page showing the time when that question was last updated (i.e., the time when the current result was cached). Clicking on the `Refresh` button on a question page will manually rerun the query and override the cached result with the new result. +## Enabling global caching + +1. Go to **Admin settings** > **Caching** (in the sidebar). +2. Click the toggle under **Saved Questions**. + +Once you've enabled caching, you can choose when and what to cache from your [caching settings](#caching-settings). + +By default, questions will get cached once their [average execution time](#average-query-execution-time) meets a [minimum query duration](#minimum-query-duration) of 60 seconds. + +## Caching location + +If you're self-hosting Metabase, cached question results will be saved to your [application database](../installation-and-operation/configuring-application-database.md). + +If you're using Metabase Cloud, cached question results will be saved to Metabase's servers in the United States. + +## Last updated at + +Questions that use the cache will display a "last cached at" timestamp in the question's **info** panel. + +## Getting fresh results + +To override a cached question result, re-run the question using the **refresh** button (counterclockwise arrow). + +## Average query execution time + +Your Metabase instance keeps track of how long it takes each question to run. The average query execution time is used in your [caching settings](#caching-settings). + +On [paid plans](https://www.metabase.com/pricing/), you can view statistics about query execution time from your [auditing tools](../usage-and-performance-tools/audit.md). ## Caching settings -In Metabase, rather than setting cache settings manually on a per-query basis, we give you two parameters to set to automatically cache the results of long queries: the minimum average query duration, and the cache TTL multiplier. +You can tell Metabase when and what to cache from **Admin settings** > **Caching**: + +- [Minimum query duration](#minimum-query-duration) +- [Cache time-to-live (TTL) multiplier](#cache-time-to-live-ttl-multiplier) +- [Max cache entry size](#max-cache-entry-size) ### Minimum query duration -Your Metabase instance keeps track of the average query execution times of your queries, and it will cache the results of all saved questions with an average query execution time longer than the number you put in this box (in seconds). +Metabase uses this number to decide whether a question will be cached or not. + +Choose a duration (in seconds) that will trigger the cache. For example, you'd enter "60" if you want to cache all questions that take longer than 1 minute to load ([on average](#average-query-execution-time)). -### Cache Time-to-live (TTL) +### Cache time-to-live (TTL) multiplier -Instead of setting an absolute number of minutes or seconds for a cached result to persist, Metabase lets you put in a multiplier to determine the cache's TTL. Each query's cache TTL is computed by multiplying its average execution time by the number you put in this box. So if you put in `10`, a query that takes 5 seconds on average to execute will have its cache last for 50 seconds; and a query that takes 10 minutes will have a cached result lasting 100 minutes. This way, each query's cache is proportional to its execution time. +The TTL multiplier tells Metabase how long to persist a cached question result: + +> Cache lifetime per question = TTL multiplier x [average execution time](#average-query-execution-time) per question + +For example, if you enter a TTL multiplier of 10, a question that takes 5 seconds on average will be cached for 50 seconds. A question that takes 10 minutes will be cached for 100 minutes. This way, each question's cache lifetime is proportional to that question's execution time. ### Max cache entry size -Lastly, you can set the maximum size of each question's cache in kilobytes, to prevent them from taking up too much space on your server. +To prevent cached results from taking up too much space on your server, you can set the maximum size of the cache (per question) in kilobytes. ## Advanced caching controls -{% include plans-blockquote.html feature="Question-specific caching" %} +{% include plans-blockquote.html feature="Advanced caching controls" %} -All Metabase editions include global caching controls. Some plans include additional caching options that let you control caching for each database, as well as individual questions. +All Metabase editions include global caching controls. On [paid plans](https://www.metabase.com/pricing/), you can override your global [time-to-live (TTL) setting](#cache-time-to-live-ttl-multiplier) to set different cache lifetimes for specific databases, questions, or dashboards. ### Caching per database -You can override your default caching options for each database connection, caching the results for more or less time than the default time-to-live (TTL) duration set by your site-wide settings. Setting caching per question is especially useful when data relevant to the question has a different natural cadence than your site-wide caching rule. +This setting tells Metabase how long to keep the cached results from a specific database. + +1. Make sure [caching is enabled](#enabling-global-caching). +2. Go to **Admin settings** > **Databases** and select your database. +3. Open **Advanced options** and find the **Default result cache duration**. +4. Click **Custom** and enter a cache duration in hours. + +The cache duration setting is useful for databases that take longer to query, or databases that are kept up to date on a special cadence. -Go to **Admin settings** > **Databases** and select your database connection. Under **Advanced settings**, set the **Default result cache duration**, which determines how long to keep question results for that database. By default, Metabase will use the value you supply on the [cache settings page](#caching-settings), but if this database has other factors that influence the freshness of data, it could make sense to set a custom duration. You can also choose custom durations on individual questions or dashboards to help improve performance. +This setting will override your [global cache duration](#cache-time-to-live-ttl-multiplier). ### Caching per question -You can override your default caching options for questions, caching the results for more or less time than the default time-to-live (TTL) duration set by your site-wide caching settings. Setting caching per question is especially useful when data relevant to the question has a different natural cadence than your site-wide caching rule, such as when the question queries data that doesn't change often. \ No newline at end of file +You can tell Metabase how long to keep the cached results for specific questions. You'll only find these cache settings on questions that exceed the [minimum query duration](#minimum-query-duration). + +1. Make sure [caching is enabled](#enabling-global-caching). +2. Go to your question. +3. Click on the **info** icon. +4. Click **Cache configuration**. +5. Enter a cache duration in hours. +6. Click **Save changes**. + +You can use this setting to update questions on the same cadence as your data. For example, if your data gets updated daily, you can set the **Cache configuration** to 24 hours. + +If set, your question cache duration will override the: + +- [global cache duration](#cache-time-to-live-ttl-multiplier) +- [database cache duration](#caching-per-database) + +### Caching per dashboard + +You can tell Metabase how long to keep the cached results for each of the questions on a dashboard. + +1. Make sure [caching is enabled](#enabling-global-caching). +2. Go to your dashboard. +3. Click on the **info** icon. +4. Click **Cache configuration**. +5. Enter a cache duration in hours. +6. Click **Save changes**. + +> This setting won't cache the entire dashboard at once. The dashboard cache duration will only apply to questions that exceed the [minimum query duration](#minimum-query-duration). + +If set, your dashboard cache duration will override the: + +- [global cache duration](#cache-time-to-live-ttl-multiplier) +- [database cache duration](#caching-per-database) +- [question cache duration](#caching-per-question) diff --git a/docs/configuring-metabase/environment-variables.md b/docs/configuring-metabase/environment-variables.md index fe85187fc9dd4..1ca517d7ba34a 100644 --- a/docs/configuring-metabase/environment-variables.md +++ b/docs/configuring-metabase/environment-variables.md @@ -73,7 +73,7 @@ Middleware that enforces validation of the client via the request header `X-Meta ### `MB_APPLICATION_COLORS` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"{}"` @@ -120,7 +120,7 @@ See [MB_JDBC_DATA_WAREHOUSE_MAX_CONNECTION_POOL_SIZE](#mb_jdbc_data_warehouse_ma ### `MB_APPLICATION_FAVICON_URL` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"frontend_client/favicon.ico"` @@ -128,7 +128,7 @@ Path or URL to favicon file. ### `MB_APPLICATION_FONT` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"Lato"`
Since: v44.0 @@ -137,7 +137,7 @@ Change the font used in Metabase. See [fonts](../configuring-metabase/fonts.md). ### `MB_APPLICATION_FONT_FILES` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"{}"`
Since: v44.0 @@ -163,7 +163,7 @@ See [fonts](../configuring-metabase/fonts.md). ### `MB_APPLICATION_LOGO_URL` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"app/assets/img/logo.svg"` @@ -171,7 +171,7 @@ Path or URL to logo file. For best results use SVG format. ### `MB_APPLICATION_NAME` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"Metabase"` @@ -406,7 +406,7 @@ SMTP username. ### `MB_EMBEDDING_APP_ORIGIN` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null` @@ -449,7 +449,7 @@ Allow using a saved question as the source for other queries. ### `MB_ENABLE_PASSWORD_LOGIN` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `true` @@ -651,7 +651,7 @@ Password for Java TrustStore file. ### `MB_JWT_ATTRIBUTE_EMAIL` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"email"` @@ -659,7 +659,7 @@ Key to retrieve the JWT user's email address. ### `MB_JWT_ATTRIBUTE_FIRSTNAME` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"first_name"` @@ -667,7 +667,7 @@ Key to retrieve the JWT user's first name. ### `MB_JWT_ATTRIBUTE_GROUPS` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"groups"` @@ -675,7 +675,7 @@ Key to retrieve the JWT user's groups. ### `MB_JWT_ATTRIBUTE_LASTNAME` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"last_name"` @@ -683,7 +683,7 @@ Key to retrieve the JWT user's last name. ### `MB_JWT_ENABLED` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `false` @@ -693,7 +693,7 @@ This is for JWT SSO authentication, and has nothing to do with Signed Embedding, ### `MB_JWT_GROUP_MAPPINGS` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"{}"` @@ -701,7 +701,7 @@ JSON object containing JWT to Metabase group mappings. Should be in the form: `' ### `MB_JWT_GROUP_SYNC` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `false` @@ -709,7 +709,7 @@ Enable group membership synchronization with JWT. ### `MB_JWT_IDENTITY_PROVIDER_URI` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null` @@ -717,7 +717,7 @@ URL of JWT based login page. ### `MB_JWT_SHARED_SECRET` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null` @@ -725,7 +725,7 @@ String used to seed the private key used to validate JWT messages. ### `MB_LANDING_PAGE` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `""` @@ -782,7 +782,7 @@ JSON object containing LDAP to Metabase group mappings. ### `MB_LDAP_GROUP_MEMBERSHIP_FILTER` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"(member={dn})"`
Since: v40.0 @@ -826,7 +826,7 @@ Use SSL, TLS or plain text. ### `MB_LDAP_SYNC_USER_ATTRIBUTES` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `true` @@ -834,7 +834,7 @@ Sync user attributes when someone logs in via LDAP. ### `MB_LDAP_SYNC_USER_ATTRIBUTES_BLACKLIST` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"userPassword,dn,distinguishedName"` @@ -856,7 +856,7 @@ User lookup filter. The placeholder `{login}` will be replaced by the user suppl ### `MB_LOADING_MESSAGE` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string (`"doing-science"`, `"running-query"`, `"loading-results"`)
Default: `"doing-science."`
Since: v44.0 @@ -880,7 +880,7 @@ Matching style for native query editor's autocomplete. Larger instances can have ### `MB_NOTIFICATION_LINK_BASE_URL` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null`
Since: v42.0 @@ -1036,7 +1036,7 @@ Connection timezone to use when executing queries. Defaults to system timezone. ### `MB_SAML_APPLICATION_NAME` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"Metabase"` @@ -1044,7 +1044,7 @@ This application name will be used for requests to the Identity Provider. ### `MB_SAML_ATTRIBUTE_EMAIL` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"` @@ -1052,7 +1052,7 @@ SAML attribute for the user's email address. ### `MB_SAML_ATTRIBUTE_FIRSTNAME` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"` @@ -1060,7 +1060,7 @@ SAML attribute for the user's first name. ### `MB_SAML_ATTRIBUTE_GROUP` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"member_of"` @@ -1068,7 +1068,7 @@ SAML attribute for group syncing. ### `MB_SAML_ATTRIBUTE_LASTNAME` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"` @@ -1076,7 +1076,7 @@ SAML attribute for the user's last name. ### `MB_SAML_ENABLED` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `false` @@ -1084,7 +1084,7 @@ When set to `true`, will enable SAML authentication with the options configured ### `MB_SAML_GROUP_MAPPINGS` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"{}"` @@ -1092,7 +1092,7 @@ JSON object containing SAML to Metabase group mappings. Should be in the form: ` ### `MB_SAML_GROUP_SYNC` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `false` @@ -1100,7 +1100,7 @@ Enable group membership synchronization with SAML. ### `MB_SAML_IDENTITY_PROVIDER_CERTIFICATE` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null` @@ -1108,7 +1108,7 @@ Encoded certificate for the identity provider, provided as the content, not a fi ### `MB_SAML_IDENTITY_PROVIDER_ISSUER` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null` @@ -1116,7 +1116,7 @@ This is a unique identifier for the IdP. Often referred to as Entity ID or simpl ### `MB_SAML_IDENTITY_PROVIDER_URI` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null` @@ -1124,7 +1124,7 @@ This is the URL where your users go to log in to your identity provider. Dependi ### `MB_SAML_KEYSTORE_ALIAS` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"metabase"` @@ -1132,7 +1132,7 @@ Alias for the key that Metabase should use for signing SAML requests. ### `MB_SAML_KEYSTORE_PASSWORD` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `"changeit"` @@ -1140,7 +1140,7 @@ Password for opening the KeyStore. ### `MB_SAML_KEYSTORE_PATH` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null` @@ -1166,7 +1166,7 @@ Also, this variable controls the geocoding service that Metabase uses to know th ### `MB_SEND_NEW_SSO_USER_ADMIN_EMAIL` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `true` @@ -1174,7 +1174,7 @@ Send email notifications to users in Admin group, when a new SSO users is create ### `MB_SESSION_COOKIE_SAMESITE` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string (`"none"`, `"lax"`, `"strict"`)
Default: `"lax"` @@ -1199,7 +1199,7 @@ Also see the [Changing session expiration](../people-and-groups/changing-session ### `MB_SESSION_TIMEOUT` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null`
Since: v44.0 @@ -1238,7 +1238,7 @@ Hide the X-rays section from the homepage by setting it to `false`. Show the sec ### `MB_SHOW_LIGHTHOUSE_ILLUSTRATION` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `true`
Since: v44.0 @@ -1247,7 +1247,7 @@ Display the lighthouse illustration on the home and login pages. ### `MB_SHOW_METABOT` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: boolean
Default: `true`
Since: v44.0 @@ -1259,7 +1259,7 @@ Display the MetaBot character on the home page. Type: string
Default: `"en"` -The default language for this Metabase instance. This only applies to emails, Pulses, etc. Users' browsers will specify the language used in the user interface. +The default language for this Metabase instance. This setting applies to the Metabase UI, system emails, [dashboard subscriptions](../dashboards/subscriptions.md), and [alerts](../questions/sharing/alerts.md). People can override the default language from their [account settings](../people-and-groups/account-settings.md). ### `MB_SITE_NAME` @@ -1334,7 +1334,7 @@ This will affect things like grouping by week or filtering in GUI queries. It wo ### `MB_SUBSCRIPTION_ALLOWED_DOMAINS` -Only available in [some plans](https://www.metabase.com/pricing)
+Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Type: string
Default: `null`
Since: v41.0 diff --git a/docs/configuring-metabase/images/caching.png b/docs/configuring-metabase/images/caching.png deleted file mode 100644 index 836b7ad79801c..0000000000000 Binary files a/docs/configuring-metabase/images/caching.png and /dev/null differ diff --git a/docs/configuring-metabase/localization.md b/docs/configuring-metabase/localization.md index c89f376520662..ca1fe007447c6 100644 --- a/docs/configuring-metabase/localization.md +++ b/docs/configuring-metabase/localization.md @@ -19,6 +19,7 @@ Thanks to our amazing user community, Metabase has been translated into many dif The languages you can currently pick from are: - English (default) +- Albanian - Arabic - Bulgarian - Catalan @@ -32,6 +33,7 @@ The languages you can currently pick from are: - Indonesian - Italian - Japanese +- Korean - Norwegian Bokmål - Polish - Portuguese @@ -73,6 +75,7 @@ Report timezone is only supported for the following databases: - Oracle - PostgreSQL - Presto + - Redshift - Vertica ## First day of the week diff --git a/docs/configuring-metabase/settings.md b/docs/configuring-metabase/settings.md index 5efde497c08c1..b4ee162155c42 100644 --- a/docs/configuring-metabase/settings.md +++ b/docs/configuring-metabase/settings.md @@ -44,7 +44,9 @@ To allow all domains, leave the field empty (allowing all domains is the default To specify multiple domains, separate each domain with a comma, with no space in between (e.g., "domain1,domain2"). -This setting doesn't affect existing subscriptions. +You can also set this property using the environment variable [`MB_SUBSCRIPTION_ALLOWED_DOMAINS`](../configuring-metabase/environment-variables.md#mb_subscription_allowed_domains). + +> This setting doesn't affect existing subscriptions and alerts. ## Anonymous tracking @@ -54,7 +56,9 @@ This option turns determines whether or not you allow [anonymous data about your By default, Metabase attempts to make field and table names more readable by changing things like `somehorriblename` to `Some Horrible Name`. This does not work well for languages other than English, or for fields that have lots of abbreviations or codes in them. If you'd like to turn this setting off, you can do so from the Admin Panel under **Settings** > **Admin settings** > **General**. -To manually label field or table names in Metabase, check out the [Data Model](../data-modeling/metadata-editing.md) section in your admin settings. +If you re-enable this setting, Metabase will run a [scan](../databases/sync-scan.md#how-database-scans-work) against your database to review your table and column names again. + +To manually label field or table names in Metabase, check out the [Data Model](../data-modeling/metadata-editing.md) section in your admin settings. Metadata in the Data Model can be further curated in [models](../data-modeling/models.md). ## Enable nested queries diff --git a/docs/dashboards/actions.md b/docs/dashboards/actions.md index 4c6a98cac7d21..c135c81ba0a83 100644 --- a/docs/dashboards/actions.md +++ b/docs/dashboards/actions.md @@ -32,16 +32,25 @@ You can select from a variety of handsome buttons: ## Connecting an action to a dashboard filter -Many types of actions rely on knowing the entity IDs for the model to determine which records to update or delete. You can add a filter to the dashboard to filter on ID, and wire up that filter to the action button. +For most actions, when people click on a button, they'll be prompted to input values in the fields defined by that action. -![Wiring up an action button to a dashboard filter](./images/filter-to-action-button.png) -If you also wire that filter up to the model cards on the dashboard, you can filter for individual records, view them in the model cards, and update them with action buttons with the ID already filled in: +Many types of actions rely on knowing the entity IDs for the model to determine which records to update or delete. To have people select the ID for the action, you'll need to: + +1. Click on the pencil icon to edit the dashboard. +2. Add an action to the dashboard (if you haven't already). +2. [Add a filter](./filters.md) to a dashboard, wire it up to any cards that you want to, and click **Done** in the bottom of the sidebar. +3. Hover over the action button and click on the **gear** icon, and select **Change action**. +4. Click on the field's dropdown to select where the action should get it's value. Here you can select "Ask the user" or have it automatically populated by a dashboard filter. In this case, we'll select our "ID" filter. + +![Wiring up an action button to a dashboard filter](./images/id-value.png) + +If you also wire that filter up to a card with a model on the dashboard, you can filter for individual records in that model, view them in the model's card, and have the action buttons auto-populate the id. ![Button form](./images/button-form.png) - You can add as many buttons as you want, and wire them up to one or more filters. +You can add as many buttons as you want, and wire them up to one or more filters. - ## Further reading +## Further reading - - [Actions](../actions/start.md) \ No newline at end of file +- [Actions](../actions/start.md) \ No newline at end of file diff --git a/docs/dashboards/images/filter-to-action-button.png b/docs/dashboards/images/filter-to-action-button.png deleted file mode 100644 index 47932de685647..0000000000000 Binary files a/docs/dashboards/images/filter-to-action-button.png and /dev/null differ diff --git a/docs/dashboards/images/id-value.png b/docs/dashboards/images/id-value.png new file mode 100644 index 0000000000000..aeb81fd7c10c7 Binary files /dev/null and b/docs/dashboards/images/id-value.png differ diff --git a/docs/dashboards/interactive.md b/docs/dashboards/interactive.md index 80ae148e15c8e..d3505f159017e 100644 --- a/docs/dashboards/interactive.md +++ b/docs/dashboards/interactive.md @@ -38,17 +38,17 @@ Metabase will slide out the **Click behavior sidebar**: For questions composed using the query builder, you can select from three options: -- Open the Metabase action menu. +- Open the Metabase drill-through menu. - Go to a custom destination. - Update a dashboard filter (if the dashboard has a filter). -SQL questions will only have the option to **Go to a custom destination**, and **Update a dashboard filter**, as the action menu is only available to questions composed with the query builder. +SQL questions will only have the option to **Go to a custom destination**, and **Update a dashboard filter**, as the drill-through menu is only available to questions composed with the query builder. If your dashboard has a filter, you'll also see an option to [update the filter](#use-a-chart-to-filter-a-dashboard). -## Open the action menu +## Open the drill-through menu -For questions composed using the query builder, the default click behavior is to open the **action menu**, which presents people with the option to [drill through the data](https://www.metabase.com/learn/questions/drill-through): +For questions composed using the query builder, the default click behavior is to open the **drill-through menu**, which presents people with the option to [drill through the data](https://www.metabase.com/learn/questions/drill-through): ![Action menu](./images/action-menu.png) diff --git a/docs/dashboards/introduction.md b/docs/dashboards/introduction.md index 54f04f15b7ae2..67c667b29f2dc 100644 --- a/docs/dashboards/introduction.md +++ b/docs/dashboards/introduction.md @@ -153,13 +153,11 @@ Enabling auto refresh will re-run all the queries on the dashboard at the interv Combining fullscreen mode and auto refresh is a great way to keep your team in sync with your data throughout the day. -## Caching dashboards +## Caching dashboard results -{% include plans-blockquote.html feature="Caching dashboards" %} +{% include plans-blockquote.html feature="Caching dashboard results" %} -If your results don't change frequently, you may want to cache your results, that is: store your results in Metabase so that the next time you visit the dashboard, Metabase can retrieve the stored results rather than query the database(s) again. For example, if your data only updates once a day, there's no point in querying the database more than once a day, as they data won't have changed. Returning cached results can be significantly faster, as the database won't have to redo the work to answer your query. - -You can set cache duration for a dashboard by clicking on the _..._ > **Edit dashboard details** > **More options**. +See [Caching per dashboard](../configuring-metabase/caching.md#caching-per-dashboard). ## Sharing dashboards with public links diff --git a/docs/dashboards/multiple-series.md b/docs/dashboards/multiple-series.md index d60ea5260549a..c6a1e4c7fba42 100644 --- a/docs/dashboards/multiple-series.md +++ b/docs/dashboards/multiple-series.md @@ -17,7 +17,7 @@ Data in isolation is rarely all that useful. One of the best ways to add context There are two main ways to visualize data side by side: -1. [**Ask a question that involves multiple dimensions**](#ask-a-question-that-involves-multiple-dimensions) with the Simple or Custom query builders (or in SQL, if you’re fancy). Example: the count of users by region over time. +1. [**Ask a question that involves multiple dimensions**](#ask-a-question-that-involves-multiple-dimensions) with the query builder (or in SQL, if you’re fancy). Example: the count of users by region over time. 2. [**Combine two saved questions**](#combining-two-saved-questions) that share a common dimension (like time) on a dashboard. For example, you could look at revenue over time and costs over time together. @@ -41,7 +41,7 @@ Note: you won’t be able to add another saved question to multi-series visualiz ## Combining two saved questions -If you already have two or more saved questions you’d like to compare, and they share a dimension, they can be combined on any dashboard. Here’s how: +If you already have two or more saved questions you’d like to compare, and they share a dimension, they can be combined onto a single dashboard card. You can even compare questions that pull data from different databases. Here’s how: 1. Add a question with a dimension like time or category to a dashboard. In practice, these will usually be line charts or bar charts. diff --git a/docs/dashboards/subscriptions.md b/docs/dashboards/subscriptions.md index 7feb6d2ad191b..fb44ea82147b4 100644 --- a/docs/dashboards/subscriptions.md +++ b/docs/dashboards/subscriptions.md @@ -86,7 +86,6 @@ See [Notification permissions](../permissions/notifications.md). ## Further reading - [Alerts](../questions/sharing/alerts.md) -- [Notification permissions](../permissions/notifications.md) - [Setting up email](../configuring-metabase/email.md) - [Setting up Slack](../configuring-metabase/slack.md) - [Auditing Metabase](../usage-and-performance-tools/audit.md) diff --git a/docs/data-modeling/metadata-editing.md b/docs/data-modeling/metadata-editing.md index 5b93a7042c6cf..db56b7b1434e1 100644 --- a/docs/data-modeling/metadata-editing.md +++ b/docs/data-modeling/metadata-editing.md @@ -40,7 +40,7 @@ To add a table description, click into the box below the table name. Description **Queryable** tables are visible across all of Metabase. -**Hidden** tables won't show up in the [query builder](../questions/query-builder/introduction.md) or [data reference](../exploration-and-organization/data-model-reference.md), but hidden tables can still be used in SQL questions if someone writes `SELECT * FROM hidden_table` from the [SQL editor](../questions/native-editor/writing-sql.md). To prevent people from writing queries against specific tables, see [data permissions](../permissions/data.md). +**Hidden** tables won't show up in the [query builder](../questions/query-builder/introduction.md) or [data reference](../exploration-and-organization/data-model-reference.md). But this is not a security feature: hidden tables can still be used in SQL questions if someone writes `SELECT * FROM hidden_table` from the [SQL editor](../questions/native-editor/writing-sql.md). To prevent people from writing queries against specific tables, see [data permissions](../permissions/data.md). Tip: To hide all of the tables in a database (say, if you've migrated to a new database), click on the **hidden eye** icon beside "# queryable tables" in the left sidebar. @@ -83,7 +83,8 @@ To add a description, click into the box below the column name. Descriptions are **Only in detail views** will hide lengthy text from question results. This setting is applied by default if a column's values have an average length of more than 50 characters. For example, you could use this setting on a column like "Customer Comments" if you already have a column for "Customer Rating". -**Do not include** columns won't show up in the query builder or data reference, but these columns are still accessible if someone writes `SELECT hidden_column FROM table` from the [SQL editor](../questions/native-editor/writing-sql.md). You can set "do not include" on sensitive columns (such as PII) or irrelevant columns. +**Do not include** columns won't show up in the query builder or data reference. You can set "do not include" on sensitive columns (such as PII) or irrelevant columns. But this visibility option is a simple omit/hide option; it's not a security feature. These columns are still accessible for people with native query privileges; they can write `SELECT hidden_column FROM table` or `SELECT * from table` in the [SQL editor](../questions/native-editor/writing-sql.md) and they'll be able to view these fields and their values. To prevent people from viewing certain columns, see [data sandboxing](../permissions/data-sandboxes.md). + ### Column order @@ -150,7 +151,7 @@ To change a column's [filter widget](../dashboards/filters.md): ### Changing a search box filter to a dropdown filter -The dropdown filter widget can be finicky, because Metabase needs to run a [scan](../databases/connecting.md#how-database-scans-work) to get the list of values for the dropdown menu. +The dropdown filter widget can be finicky, because Metabase needs to run a [scan](../databases/sync-scan.md#how-database-scans-work) to get the list of values for the dropdown menu. 1. Go to **Admin settings** > **Data Model**. 2. Find your database and table. @@ -209,7 +210,7 @@ https://www.google.com/search?q=askew To update the values in your filter dropdown menus, refresh or reset the cached values. **Cache actions** include: -- **Re-scan this table or field** to run a manual scan for new or updated column values. If possible, re-scan the table during off-peak hours, as [scans](../databases/connecting.md#how-database-scans-work) can slow down your database. +- **Re-scan this table or field** to run a manual scan for new or updated column values. If possible, re-scan the table during off-peak hours, as [scans](../databases/sync-scan.md#how-database-scans-work) can slow down your database. - **Discard cached field values** to clear cached values and stop them from showing up in your [filter widgets](#changing-the-filter-widget). ### Table cache actions @@ -223,7 +224,7 @@ To update the values in your filter dropdown menus, refresh or reset the cached 1. Go to **Admin settings** > **Data Model**. 2. Find your database and table. -3. Click **gear** icon at the right of a column's settings box. +3. Click the **gear** icon at the right of a column's settings box. 4. Scroll to **Cached field values**. 5. Select a cache action. diff --git a/docs/databases/connecting.md b/docs/databases/connecting.md index 7cedbaac42ec7..69f43505b5589 100644 --- a/docs/databases/connecting.md +++ b/docs/databases/connecting.md @@ -24,7 +24,6 @@ The databases listed below have official drivers maintained by the Metabase team - [Amazon Athena](./connections/athena.md) - [BigQuery](./connections/bigquery.md) (Google Cloud Platform) - [Druid](./connections/druid.md) -- [Google Analytics](./connections/google-analytics.md) - [H2](./connections/h2.md) - [MongoDB (version 4.2 or higher)](./connections/mongodb.md) - [MySQL (version 5.7 or higher, as well as MariaDB version 10.2 or higher)](./connections/mysql.md) @@ -46,91 +45,13 @@ For provider-specific connection details, like connecting to a PostgreSQL data w - [AWS's Relational Database Service (RDS)](./connections/aws-rds.md) -## Syncing and scanning databases - -Metabase runs syncs and scans to stay up to date with your database. - -- **Syncs** get updated schemas to display in the [Data Browser](https://www.metabase.com/learn/getting-started/data-browser). -- **Scans** take samples of column values to populate filter dropdown menus and suggest helpful visualizations. Metabase does not store _complete_ tables from your database. - -When Metabase first connects to your database, it performs a **scan** to determine the metadata of the columns in your tables and automatically assign each column a [semantic type](../data-modeling/field-types.md). - -During the scan, Metabase also takes a sample of each table to look for URLs, JSON, encoded strings, etc. You can map table and column metadata to new values from **Admin settings** > **Data model**. Check out [editing metadata](../data-modeling/metadata-editing.md). - -### Choose when Metabase syncs and scans - -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. - -#### Scheduling database syncs - -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: - -- **Scan** sets the frequency of the [sync query](#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). - -#### Scheduling database scans - -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: - -![Scanning options](./images/scanning-options.png) - -- **Regularly, on a schedule** allows you to run [scan queries](#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. -- **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. - -### How database syncs work - -A Metabase **sync** is a query that gets a list of updated table and view names, column names, and column data types from your database. This query runs against your database during setup, and again every hour by default. This scanning query is fast with most relational databases, but can be slower with MongoDB and some [community-built database drivers](../developers-guide/partner-and-community-drivers.md). Syncing can't be turned off completely, otherwise Metabase wouldn't work. - -### How database scans work +## Granting database privileges -A Metabase **scan** is a query that caches the column _values_ for filter dropdowns by looking at the first 1,000 distinct records from each table, in ascending order. For each record, Metabase only stores the first 100 kilobytes of text, so if you have data with 1,000 characters each (like addresses), and your column has more than 100 unique addresses, Metabase will only cache the first 100 values from the scan query. +For Metabase to connect, query, or write to your database, you must give Metabase a database user account with the correct database privileges. See [Database roles, users, and privileges](./users-roles-privileges.md). -Cached column values are displayed in filter dropdown menus. If people type in the filter search box for values that aren't in the first 1,000 distinct records or 100kB of text, Metabase will run a query against your database to look for those values on the fly. - -A scan is more intensive than a sync query, so it only runs once during setup, and again once a day by default. If you [disable scans](#scheduling-database-scans) entirely, you'll need to bring things up to date by running [manual scans](#manually-scanning-column-values). - -To reduce the number of tables and fields Metabase needs to scan in order to stay current with your connected database, Metabase will only scan values for fields that someone has queried in the last fourteen days. - -### Manually syncing tables and columns - -1. Go to **Admin settings** > **Databases** > your database. -2. Click on **Sync database schema now**. - -### Manually scanning column values - -To scan values from all the columns in a table: - -1. Go to **Admin settings** > **Data model** > your database. -2. Select the table that you want to bring up to date with your database. -3. Click the **gear icon** at the top of the page. -4. Click **Re-scan this table**. - -To scan values from a specific column: - -1. Go to **Admin settings** > **Data model** > your database. -2. Select the table and find the column you want bring up to date with your database. -3. Click the **gear icon** in the panel for that column. -4. Click **Re-scan this field**. - -### Clearing cached values - -To forget the data that Metabase has stored from previous [database scans](#syncing-and-scanning-databases): - -1. Go to **Admin settings** > **Data model** > your database. -2. Select the table. -3. Optional: select the column. -4. Click the **gear icon**. -5. Click **Discard cached field values**. - -![Re-scan options](./images/re-scan-options.png) - -### Syncing and scanning using the API - -Metabase syncs and scans regularly, but if the database administrator has just changed the database schema, or if a lot of data is added automatically at specific times, you may want to write a script that uses the [Metabase API](https://www.metabase.com/learn/administration/metabase-api) to force a sync or scan. [Our API](../api-documentation.md) provides two ways to initiate a sync or scan of a database: +## Syncing and scanning databases -1. Using a session token: the `/api/database/:id/sync_schema` or `api/database/:id/rescan_values` endpoints. These endpoints do the same things as going to the database in the Admin Panel and choosing **Sync database schema now** or **Re-scan field values now** respectively. To use these endpoints, you have to authenticate with a user ID and pass a session token in the header of your request. -2. Using an API key: `/api/notify/db/:id`. We created this endpoint so that people could notify their Metabase to sync after an [ETL operation](https://www.metabase.com/learn/analytics/etl-landscape) finishes. To use this endpoint, you must pass an API key by defining the `MB_API_KEY` environment variable. +See [Syncing and scanning](./sync-scan.md). ## Deleting databases diff --git a/docs/databases/connections/athena.md b/docs/databases/connections/athena.md index ab5e04741c6c2..c1008ad86db05 100644 --- a/docs/databases/connections/athena.md +++ b/docs/databases/connections/athena.md @@ -66,18 +66,18 @@ You can specify additional options via a string, e.g. `UseResultsetStreaming=0;L Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. #### Scanning for filter values @@ -85,15 +85,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration @@ -108,6 +110,116 @@ Options are: If you are on a paid plan, you can also set cache duration per questions. See [Advanced caching controls](../../configuring-metabase/caching.md#advanced-caching-controls). +## Example IAM Policy + +This policy provides read-only permissions for data in S3. You'll need to specify any S3 buckets that you want Metabase to be able to query from _as well as_ the S3 bucket provided as part of the configuration where results are written to. + +There may be additional permissions required for other Athena functionality, like federated queries. For details, check out the [Athena docs](https://docs.aws.amazon.com/athena/latest/ug/security-iam-athena). + + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Athena", + "Effect": "Allow", + "Action": [ + "athena:BatchGetNamedQuery", + "athena:BatchGetQueryExecution", + "athena:GetNamedQuery", + "athena:GetQueryExecution", + "athena:GetQueryResults", + "athena:GetQueryResultsStream", + "athena:GetWorkGroup", + "athena:ListDatabases", + "athena:ListDataCatalogs", + "athena:ListNamedQueries", + "athena:ListQueryExecutions", + "athena:ListTagsForResource", + "athena:ListWorkGroups", + "athena:ListTableMetadata", + "athena:StartQueryExecution", + "athena:StopQueryExecution", + "athena:CreatePreparedStatement", + "athena:DeletePreparedStatement", + "athena:GetPreparedStatement" + ], + "Resource": "*" + }, + { + "Sid": "Glue", + "Effect": "Allow", + "Action": [ + "glue:BatchGetPartition", + "glue:GetDatabase", + "glue:GetDatabases", + "glue:GetPartition", + "glue:GetPartitions", + "glue:GetTable", + "glue:GetTables", + "glue:GetTableVersion", + "glue:GetTableVersions" + ], + "Resource": "*" + }, + { + "Sid": "S3ReadAccess", + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:ListBucket", "s3:GetBucketLocation"], + "Resource": [ + "arn:aws:s3:::bucket1", + "arn:aws:s3:::bucket1/*", + "arn:aws:s3:::bucket2", + "arn:aws:s3:::bucket2/*" + ] + }, + { + "Sid": "AthenaResultsBucket", + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:GetObject", + "s3:AbortMultipartUpload", + "s3:ListBucket", + "s3:GetBucketLocation" + ], + "Resource": ["arn:aws:s3:::bucket2", "arn:aws:s3:::bucket2/*"] + } + ] +} +``` + +If Metabase also needs to create tables, you'll need additional AWS Glue permissions. The `"Resource": "*"` key-value pair gives the account Delete and Update permissions to any table: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "glue:BatchCreatePartition", + "glue:UpdateDatabase", + "glue:DeleteDatabase", + "glue:CreateTable", + "glue:CreateDatabase", + "glue:UpdateTable", + "glue:BatchDeletePartition", + "glue:BatchDeleteTable", + "glue:DeleteTable", + "glue:CreatePartition", + "glue:DeletePartition", + "glue:UpdatePartition" + ], + "Resource": "*" + } + ] +} +``` + + ## Further reading - [Managing databases](../../databases/connecting.md) diff --git a/docs/databases/connections/bigquery.md b/docs/databases/connections/bigquery.md index 3bac70de72bdd..f1e2b2337a8a8 100644 --- a/docs/databases/connections/bigquery.md +++ b/docs/databases/connections/bigquery.md @@ -60,18 +60,20 @@ You can specify which BigQuery datasets you want to sync and scan. Options are: - Only these... - All except... -For the Only these and All except options, you can input a comma-separated list of values to tell Metabase which datasets you want to include (or exclude). For example: +> A BigQuery dataset is similar to a schema. Make sure to enter your dataset names (like `marketing`), _not_ your table names (`marketing.campaigns`). + +Let's say you have three datasets: foo, bar, and baz. + +To sync all three datasets, select **Only these...** and enter: ``` foo,bar,baz ``` -You can use the `*` wildcard to match multiple datasets. +To sync datasets based on a string match, use the `*` wildcard: -Let's say you have three datasets: foo, bar, and baz. - -- If you have **Only these...** set, and enter the string `b*`, you'll sync with bar and baz. -- If you have **All except...** set, and enter the string `b*`, you'll just sync foo. +- To sync bar and baz, select **Only these...** and enter the string `b*`. +- To sync foo only, select **All except...** and enter the string `b*`. Note that only the `*` wildcard is supported; you can't use other special characters or regexes. @@ -87,18 +89,18 @@ This can be useful for [auditing](../../usage-and-performance-tools/audit.md) an Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md#syncing-and-scanning-databases). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. #### Scanning for filter values @@ -106,15 +108,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. + +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration @@ -136,8 +140,8 @@ You can connect Metabase to Google Drive data sources via BigQuery. There is som To connect to a data source stored in Google Drive (like a Google Sheet), first make sure you've completed the steps above, including: - creating a project in Google Cloud Platform, -- adding a BigQuery dataset, and -- creating a [service account](#google-cloud-platform-creating-a-service-account-and-json-file). +- adding a BigQuery dataset, and +- creating a [service account](#google-cloud-platform-creating-a-service-account-and-json-file). ### Share your Google Drive source with the service account diff --git a/docs/databases/connections/druid.md b/docs/databases/connections/druid.md index 46ff7d7f180da..a1ee1e4ff3beb 100644 --- a/docs/databases/connections/druid.md +++ b/docs/databases/connections/druid.md @@ -22,7 +22,7 @@ Your database's IP address, or its domain name (e.g., esc.mydatabase.com). ### Broker node port -The database port (e.g, 8082). +The database port (e.g, 8082). ### Use an SSH tunnel @@ -32,18 +32,18 @@ See our [guide to SSH tunneling](../ssh-tunnel.md). Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. ### Scanning for filter values @@ -51,15 +51,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration diff --git a/docs/databases/connections/google-analytics.md b/docs/databases/connections/google-analytics.md index 41985016c0b9c..21b97b8311f7a 100644 --- a/docs/databases/connections/google-analytics.md +++ b/docs/databases/connections/google-analytics.md @@ -4,112 +4,15 @@ redirect_from: - /docs/latest/administration-guide/databases/google-analytics --- -# Working with Google Analytics in Metabase +# Google Analytics (DEPRECATED) -This page provides information on how to create and manage a connection to a [Google Analytics][google-analytics] dataset. +Metabase supported Google Analytics version 3, which Google has set an end-of-life date for July 1, 2023. -Setting up Google Analytics will require you to configure: +If you still want to view Google Analytics data in Metabase, we recommend: -1. A [Google Cloud Platform (GCP) account](#prerequisites). -2. The [Google Cloud Platform (GCP) console](#google-cloud-platform-creating-a-service-account-and-json-file). -3. Your [Metabase admin settings](#metabase-adding-a-google-analytics-dataset). +- Upgrading to the latest version of [Google Analytics](https://support.google.com/analytics/answer/10089681?hl=en&ref_topic=12154439,12153943,2986333). +- Setting up a [BigQuery](https://cloud.google.com/bigquery) account. +- Connecting your [Bigquery to Metabase](./bigquery.md). +- Exporting your [Google Analytics 4 data to BigQuery](https://support.google.com/analytics/answer/9358801?hl=en). -Once you've configured Google Analytics in both places, you can [check if your Google Analytics setup is working correctly](#checking-if-google-analytics-is-working-correctly). - -## Prerequisites - -You will need to have a [Google Cloud Platform (GCP)][google-cloud] account and create the [project][google-cloud-create-project] you would like to use in Metabase. Consult the Google Cloud Platform documentation on how to [create and manage a project][google-cloud-management] if you do not have one. - -## Google Cloud Platform: creating a service account and JSON file - -You'll first need a [service account][google-service-accounts] JSON file that Metabase can -use to access your Google Analytics dataset. Service accounts are intended for non-human users (such as applications -like Metabase) to authenticate (who am I?) and authorize (what can I do?) their API calls. - -To create the service account JSON file, follow Google's documentation on [setting up service accounts][google-managing-service-accounts] for your Google Analytics dataset. - -1. From your [Google Cloud Platform console][google-cloud-platform-console], go to **IAM & Admin** > **Service accounts**. - -2. Click **+ CREATE SERVICE ACCOUNT** and fill out your service account details. - - - Name the service account. - - Add a description (the service account ID will populate once you add a name). - -3. Click **Continue** to skip the optional sections. - -4. Click **Done** to create your service account. - -5. From the **...** menu, go to **Manage keys** > **Add key**. - - - Select **JSON** for the **key type**. - - Click **Create** to download the JSON file to your computer. **You can only download the key once**. - - If you delete the key, you'll need to create another service account with the same roles. - -6. [**Add the service account**][google-analytics-add-user] to your Google Analytics account. - - - Find the service account email by clicking into your service account name from **IAM & Admin** > **Service accounts**. - - The service account email will like: - ``` - my_service_account_name@my_project_id.iam.gserviceaccount.com - ``` - - Only Read and Analyze permissions are needed for Metabase. - -7. Enable the Google Analytics API from the [API overview][google-api-overview]. - - Check that you're in the correct project before you click **Enable**. - - For further documentation please refer to [Enable and disable APIs][google-enable-disable-apis]. - -## Metabase: adding a Google Analytics dataset - -In your Metabase, click on **Settings** and select "Admin" to bring up the **Admin Panel**. In the **Databases** section, click on the **Add database** button, then select "Google Analytics" from the "Database type" dropdown and fill in the configuration settings: - -### Settings - -#### Display name - -**Name** is the title of your database in Metabase. - -#### Account ID - -To get the **Google Analytics Account ID**, go to [Google Analytics][google-analytics] and click the **Admin** cog. In -the admin tab, go to the **Account Settings** section: you will find the account ID below the "Basic Settings" -heading. - -#### Service account JSON file - -Upload the service account JSON file you created when following the steps above. The JSON file contains the -credentials your Metabase application will need to read and query your dataset. - -### Advanced settings - -- **Rerun queries for simple explorations**: When this setting is enabled (which it is by default), Metabase automatically runs queries when users do simple explorations with the Summarize and Filter buttons when viewing a table or chart. You can turn this off if you find performance is slow. This setting doesn’t affect drill-throughs or SQL queries. - -- **Choose when syncs and scans happen**: When this setting is disabled (which it is by default), Metabase regularly checks the database to update its internal metadata. If you have a large database, we you can turn this on and control when and how often the field value scans happen. - -- **Periodically refingerprint tables**: This setting — disabled by default — enables Metabase to scan for additional field values during syncs allowing smarter behavior, like improved auto-binning on your bar charts. - -Please see the [database sync and analysis documentation][sync-docs] for more details about these toggle settings. - -## Saving your database configuration - -When you're done, click the **Save** button. A modal dialog will inform you that your database has been added. You can click on **Explore this data** to see some automatic explorations of your data, or click **I'm good thanks** to stay in the **Admin Panel**. - -## Checking if Google Analytics is working correctly - -Give Metabase [some time to sync][sync-docs] with your Google Analytics dataset, then exit the **Admin Panel**, click on **Browse Data**, find your Google Analytics database, and start exploring. Once Metabase is finished syncing, you will see the names of your Properties & Apps in the data browser. - -If you're having trouble, see the guides under [Troubleshooting data sources][troubleshooting-data-sources]. - -[google-analytics]: https://cloud.google.com/analytics -[google-analytics-add-user]: https://support.google.com/analytics/answer/1009702 -[google-api-overview]: https://console.cloud.google.com/apis/api/analytics.googleapis.com/overview -[google-cloud]: https://cloud.google.com/ -[google-cloud-create-project]: https://cloud.google.com/resource-manager/docs/creating-managing-projects#creating_a_project -[google-cloud-management]: https://cloud.google.com/resource-manager/docs/creating-managing-projects -[google-cloud-platform-console]: https://console.cloud.google.com/ -[google-cloud-oauth]: https://support.google.com/cloud/answer/6158849 -[google-enable-disable-apis]: https://support.google.com/googleapi/answer/6158841 -[google-managing-service-accounts]: https://cloud.google.com/iam/docs/creating-managing-service-accounts -[google-oauth-scopes]: https://developers.google.com/identity/protocols/oauth2/scopes -[google-service-accounts]: https://cloud.google.com/iam/docs/service-accounts -[sync-docs]: ../connecting.md#syncing-and-scanning-databases -[troubleshooting-data-sources]: ../../troubleshooting-guide#databases +Google Analytics will export the data to BigQuery in one table per day. You can build native SQL models with [wildcard queries](https://cloud.google.com/bigquery/docs/querying-wildcard-tables), and then build Metabase questions over those views. diff --git a/docs/databases/connections/h2.md b/docs/databases/connections/h2.md index 1342a4f07bb61..17a887d42fd41 100644 --- a/docs/databases/connections/h2.md +++ b/docs/databases/connections/h2.md @@ -28,18 +28,18 @@ The local path relative to where your Metabase is running from. Your string shou Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. #### Scanning for filter values @@ -47,15 +47,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration diff --git a/docs/databases/connections/mongodb.md b/docs/databases/connections/mongodb.md index 09af30fa38130..9a2e0d2dae9e6 100644 --- a/docs/databases/connections/mongodb.md +++ b/docs/databases/connections/mongodb.md @@ -103,9 +103,11 @@ To make sure you are using the correct connection configuration: ## I added fields to my database but don't see them in Metabase -Metabase may not sync all of your fields, as it only scans the first ten thousand documents in a collection to get a sample of the fields the collection contains. Since any document in a MongoDB collection can contain any number of fields, the only way to get 100% coverage of all fields would be to scan every single document in every single collection, which would put too much strain on your database (so we don't do that). +Metabase may not sync all of your fields. Since any document in a MongoDB collection can contain any number of fields, the only way to get 100% coverage of all fields would be to scan every single document in every single collection. The reason Metabase doesn't do a full scan is because it would put too much strain on your database. -One workaround is to include all possible keys in the first document of the collection, and give those keys null values. That way, Metabase will be able to recognize the correct schema for the entire collection. +Instead, Metabase gets a sample of the fields in a collection by scanning a sample of 1000 documents in each collection (the first 500 documents and the last 500 documents in each collection). + +If you're not seeing all of the fields show up for a collection in Metabase, one workaround is to include all possible keys in the first document of the collection, and give those keys null values. That way, Metabase will be able to recognize the correct schema for the entire collection. ## Further reading diff --git a/docs/databases/connections/mysql.md b/docs/databases/connections/mysql.md index 14503fd536ef2..b76a358e399a5 100644 --- a/docs/databases/connections/mysql.md +++ b/docs/databases/connections/mysql.md @@ -26,7 +26,7 @@ The database port. E.g., 3306. ### Username -The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of privileges. +The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of [privileges](../users-roles-privileges.md). ### Password @@ -42,7 +42,7 @@ See our [guide to SSH tunneling](../ssh-tunnel.md). ### Unfold JSON Columns -In some databases, Metabase can unfold JSON columns into component fields to yield a table where each JSON key becomes a column. JSON unfolding is on by default, but you can turn off JSON folding if performance is slow. +In some databases, Metabase can unfold JSON columns into component fields to yield a table where each JSON key becomes a column. JSON unfolding is on by default, but you can turn off JSON unfolding if performance is slow. ### Additional JDBC connection string options @@ -52,18 +52,18 @@ You can append options to the connection string that Metabase uses to connect to Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. #### Scanning for filter values @@ -71,15 +71,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration @@ -96,7 +98,7 @@ If you are on a paid plan, you can also set cache duration per questions. See [A ## Connecting to MySQL 8+ servers -Metabase uses the MariaDB connector to connect to MariaDB and MySQL servers. The MariaDB connector does not currently support MySQL 8's default authentication plugin, so in order to connect, you'll need to change the plugin used by the Metabase user to +Metabase uses the MariaDB connector to connect to MariaDB and MySQL servers. The MariaDB connector does not currently support MySQL 8's default authentication plugin, so in order to connect, you'll need to change the plugin used by the Metabase user to ``` mysql_native_password`: `ALTER USER 'metabase'@'%' IDENTIFIED WITH mysql_native_password BY 'thepassword'; @@ -118,13 +120,13 @@ Note the host name `172.17.0.1` (in this case a Docker network IP address), and You'll see the same error message when attempting to connect to the MySQL server with the command-line client: `mysql -h 127.0.0.1 -u metabase -p`. -**How to fix this:** Recreate the MySQL user with the correct host name: +**How to fix this:** Recreate the MySQL user with the correct host name: ``` CREATE USER 'metabase'@'172.17.0.1' IDENTIFIED BY 'thepassword'; ``` -Otherwise, if necessary, a wildcard may be used for the host name: +Otherwise, if necessary, a wildcard may be used for the host name: ``` CREATE USER 'metabase'@'%' IDENTIFIED BY 'thepassword'; @@ -137,7 +139,7 @@ GRANT SELECT ON targetdb.* TO 'metabase'@'172.17.0.1'; FLUSH PRIVILEGES; ``` -Remember to drop the old user: +Remember to drop the old user: ``` DROP USER 'metabase'@'localhost'; diff --git a/docs/databases/connections/oracle.md b/docs/databases/connections/oracle.md index a580b6c3a0ba1..c087302c05c44 100644 --- a/docs/databases/connections/oracle.md +++ b/docs/databases/connections/oracle.md @@ -34,7 +34,7 @@ Optional TNS alias. ### Username -The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of privileges. +The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of [privileges](../users-roles-privileges.md). ### Password diff --git a/docs/databases/connections/postgresql.md b/docs/databases/connections/postgresql.md index 841dda4aeb4dd..7ade62d149dc1 100644 --- a/docs/databases/connections/postgresql.md +++ b/docs/databases/connections/postgresql.md @@ -32,7 +32,7 @@ The name of the database you're connecting to. ### Username -The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of privileges. +The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of [privileges](../users-roles-privileges.md). ### Password @@ -109,7 +109,7 @@ openssl pkcs8 -topk8 -inform PEM -outform DER -in client-key.pem -out client-key ### Unfold JSON Columns -In some databases, Metabase can unfold JSON columns into component fields to yield a table where each JSON key becomes a column. JSON unfolding is on by default, but you can turn off JSON folding if performance is slow. +In some databases, Metabase can unfold JSON columns into component fields to yield a table where each JSON key becomes a column. JSON unfolding is on by default, but you can turn off JSON unfolding if performance is slow. ### Additional JDBC connection string options @@ -119,18 +119,18 @@ You can append options to the connection string that Metabase uses to connect to Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. ### Scanning for filter values @@ -138,15 +138,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration diff --git a/docs/databases/connections/presto.md b/docs/databases/connections/presto.md index 6fb1836d3f8d7..574c3c0b71114 100644 --- a/docs/databases/connections/presto.md +++ b/docs/databases/connections/presto.md @@ -34,7 +34,7 @@ Only add tables to Metabase that come from a specific schema. ### Username -The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of privileges. +The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of [privileges](../users-roles-privileges.md). ### Password @@ -76,18 +76,18 @@ You can append options to the connection string that Metabase uses to connect to Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. #### Scanning for filter values @@ -95,15 +95,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration diff --git a/docs/databases/connections/redshift.md b/docs/databases/connections/redshift.md index 7a86142cbd6c2..710952c76abb2 100644 --- a/docs/databases/connections/redshift.md +++ b/docs/databases/connections/redshift.md @@ -53,7 +53,7 @@ Note that only the `*` wildcard is supported; you can't use other special charac ### Username -The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of privileges. +The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of [privileges](../users-roles-privileges.md). ### Password @@ -71,18 +71,18 @@ You can append options to the connection string that Metabase uses to connect to Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ## Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). ### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. ### Scanning for filter values @@ -90,15 +90,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#manually-scanning-column-values) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ## Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ## Default result cache duration diff --git a/docs/databases/connections/snowflake.md b/docs/databases/connections/snowflake.md index def4398d0fa8e..1fd4e78b644b0 100644 --- a/docs/databases/connections/snowflake.md +++ b/docs/databases/connections/snowflake.md @@ -30,7 +30,7 @@ You'd enter `az12345.ca-central-1.aws` as the account name in Metabase. ### Username -The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of privileges. +The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of [privileges](../users-roles-privileges.md). ### Password @@ -93,18 +93,18 @@ Some databases allow you to append options to the connection string that Metabas Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. #### Scanning for filter values @@ -112,15 +112,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration diff --git a/docs/databases/connections/sparksql.md b/docs/databases/connections/sparksql.md index b3077f07cc074..3ae8b6530c6bf 100644 --- a/docs/databases/connections/sparksql.md +++ b/docs/databases/connections/sparksql.md @@ -24,7 +24,7 @@ The database port. E.g., 10000 ### Username -The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of privileges. +The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of [privileges](../users-roles-privileges.md). ### Password @@ -42,18 +42,18 @@ You can append options to the connection string that Metabase uses to connect to Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. #### Scanning for filter values @@ -61,15 +61,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration diff --git a/docs/databases/connections/sql-server.md b/docs/databases/connections/sql-server.md index 181ec26de75e1..19dd173da469b 100644 --- a/docs/databases/connections/sql-server.md +++ b/docs/databases/connections/sql-server.md @@ -34,7 +34,7 @@ If you're running multiple databases on the same host, you can include the insta ### Username -The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of privileges. +The database username for the account that you want to use to connect to your database. You can set up multiple connections to the same database using different user accounts to connect to the same database, each with different sets of [privileges](../users-roles-privileges.md). ### Password @@ -60,18 +60,18 @@ You can append options to the connection string that Metabase uses to connect to Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. ### Scanning for filter values @@ -79,15 +79,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration diff --git a/docs/databases/connections/sqlite.md b/docs/databases/connections/sqlite.md index b1737d2d1dbb7..378f090095117 100644 --- a/docs/databases/connections/sqlite.md +++ b/docs/databases/connections/sqlite.md @@ -24,18 +24,18 @@ The location of the SQLite database (the absolute path). Turn this option **OFF** if people want to click **Run** (the play button) before applying any [Summarize](../../questions/query-builder/introduction.md#grouping-your-metrics) or filter selections. -By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [action menu](https://www.metabase.com/glossary/action_menu). If your database is slow, you may want to disable re-running to avoid loading data on each click. +By default, Metabase will execute a query as soon as you choose an grouping option from the **Summarize** menu or a filter condition from the [drill-through menu](https://www.metabase.com/learn/questions/drill-through). If your database is slow, you may want to disable re-running to avoid loading data on each click. ### Choose when Metabase syncs and scans -Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../connecting.md#syncing-and-scanning-databases). +Turn this option **ON** to manage the queries that Metabase uses to stay up to date with your database. For more information, see [Syncing and scanning databases](../sync-scan.md). #### Database syncing -If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Database syncing**: +If you've selected **Choose when syncs and scans happen** > **ON**, you'll be able to set: -- **Scan** sets the frequency of the [sync query](../connecting.md#how-database-syncs-work) to hourly (default) or daily. -- **at** sets the time when your sync query will run against your database (in the timezone of the server where your Metabase app is running). +- The frequency of the [sync](../sync-scan.md#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. ### Scanning for filter values @@ -43,15 +43,17 @@ Metabase can scan the values present in each field in this database to enable ch If you've selected **Choose when syncs and scans happen** > **ON**, you'll see the following options under **Scanning for filter values**: -- **Regularly, on a schedule** allows you to run [scan queries](../connecting.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Regularly, on a schedule** allows you to run [scan queries](../sync-scan.md#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. - **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. -- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../connecting.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](../sync-scan.md#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. ### Periodically refingerprint tables -Turn this option **ON** to scan a _sample_ of values every time Metabase runs a [sync](../connecting.md#how-database-syncs-work). +> Periodic refingerprinting will increase the load on your database. -A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you turn this option **OFF**, Metabase will only fingerprint your columns once during setup. +Turn this option **ON** to scan a sample of values every time Metabase runs a [sync](../sync-scan.md#how-database-syncs-work). + +A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup. ### Default result cache duration diff --git a/docs/databases/images/re-scan-options.png b/docs/databases/images/re-scan-options.png deleted file mode 100644 index 5831da63e61f6..0000000000000 Binary files a/docs/databases/images/re-scan-options.png and /dev/null differ diff --git a/docs/databases/images/scanning-options.png b/docs/databases/images/scanning-options.png deleted file mode 100644 index 455bb67876b3c..0000000000000 Binary files a/docs/databases/images/scanning-options.png and /dev/null differ diff --git a/docs/databases/sync-scan.md b/docs/databases/sync-scan.md new file mode 100644 index 0000000000000..6a551d49a2747 --- /dev/null +++ b/docs/databases/sync-scan.md @@ -0,0 +1,165 @@ +--- +title: Syncing and scanning databases +--- + +# Syncing and scanning databases + +Metabase runs different types of queries to stay up to date with your database. + +- [Syncs](#how-database-syncs-work) get updated schemas to display in the [Data Browser](https://www.metabase.com/learn/getting-started/data-browser). +- [Scans](#how-database-scans-work) take samples of column values to populate filter dropdown menus and suggest helpful visualizations. Metabase does not store _complete_ tables from your database. +- [Fingerprinting](#how-database-fingerprinting-works) takes an additional sample of column values to help with smart behavior, such as auto-binning for bar charts. + +## Initial sync, scan, and fingerprinting + +When Metabase first connects to your database, Metabase performs a [sync](#how-database-scans-work) to determine the metadata of the columns in your tables and automatically assign each column a [semantic type](../data-modeling/field-types.md). Once the sync is successful, Metabase runs [scans](#scheduling-database-scans) of each table to look for URLs, JSON, encoded strings, etc. The [fingerprinting](#how-database-fingerprinting-works) queries run once the syncs are complete. + +You can follow the progress of these queries from **Admin** > **Troubleshooting** > **Logs**. + +Once the queries are done running, you can view and edit the synced metadata from **Admin settings** > **Data model**. For more info, see [editing metadata](../data-modeling/metadata-editing.md). + +## Choose when Metabase syncs and scans + +If you want to change the default schedule for [sync](#how-database-scans-work) and [scan](#scheduling-database-scans) queries: + +1. Go to **Admin** > **Databases** > your database. +2. Expand **Show advanced options**. +3. Turn ON **Choose when syncs and scans happen**. + +## Scheduling database syncs + +If you've turned on [Choose when syncs and scans happen](#choose-when-metabase-syncs-and-scans), you'll be able to set: + +- The frequency of the [sync](#how-database-syncs-work): hourly (default) or daily. +- The time to run the sync, in the timezone of the server where your Metabase app is running. + +## Scheduling database scans + +If you've turned ON [Choose when syncs and scans happen](#choose-when-metabase-syncs-and-scans), you'll see the following [scan](#how-database-scans-work) options: + +- **Regularly, on a schedule** allows you to run [scan queries](#how-database-scans-work) at a frequency that matches the rate of change to your database. The time is set in the timezone of the server where your Metabase app is running. This is the best option for a small database, or tables with distinct values that get updated often. +- **Only when adding a new filter widget** is a great option if you want scan queries to run on demand. Turning this option **ON** means that Metabase will only scan and cache the values of the field(s) that are used when a new filter is added to a dashboard or SQL question. +- **Never, I'll do this manually if I need to** is an option for databases that are either prohibitively large, or which never really have new values added. Use the [Re-scan field values now](#manually-scanning-column-values) button to run a manual scan and bring your filter values up to date. + +## Manually syncing tables and columns + +1. Go to **Admin settings** > **Databases** > your database. +2. Click **Sync database schema now**. + +## Manually scanning column values + +To scan values from all the columns in a table: + +1. Go to **Admin settings** > **Data model** > your database. +2. Select the table that you want to bring up to date with your database. +3. Click the **gear icon** at the top of the page. +4. Click **Re-scan this table**. + +To scan values from a specific column: + +1. Go to **Admin settings** > **Data model** > your database. +2. Select the table. +3. Find the column you want bring up to date with your database. +4. Click the **gear icon** in the panel for that column. +5. Click **Re-scan this field**. + +## Periodically refingerprint tables + +> Periodic refingerprinting will increase the load on your database. + +By default, Metabase only runs [fingerprinting](#how-database-fingerprinting-works) queries when you first connect your database. + +Turn this setting ON if you want Metabase to use larger samples of column values when making suggestions in the UI: + +1. Go to **Admin** > **Databases** > your database. +2. Expand **Show advanced options**. +3. Turn ON **Periodically refingerprint tables**. + +## Clearing cached values + +To ask Metabase to forget the data from previous [scans](#syncing-and-scanning-databases) and [fingerprinting](#how-database-fingerprinting-works): + +1. Go to **Admin settings** > **Data model** > your database. +2. Select the table. +3. Optional: select the column. +4. Click the **gear icon**. +5. Click **Discard cached field values**. + +## Disabling syncing and scanning for specific tables + +To prevent Metabase from running syncs and scans against a specific table, change the [table visibility](../data-modeling/metadata-editing.md#table-visibility) to **Hidden**: + +1. Go to **Admin settings** > **Data model** > your database. +2. Hover over the table name in the sidebar. +3. Click the **eye** icon. + +> Hiding a table will also prevent it from showing up in the [query builder](../questions/query-builder/introduction.md) and [data reference](../exploration-and-organization/data-model-reference.md). People can still query hidden tables from the [SQL editor](../questions/native-editor/writing-sql.md). + +## Syncing and scanning using the API + +Metabase syncs and scans regularly, but if the database administrator has just changed the database schema, or if a lot of data is added automatically at specific times, you may want to write a script that uses the [Metabase API](https://www.metabase.com/learn/administration/metabase-api) to force a sync or scan. [Our API](../api-documentation.md) provides two ways to initiate a sync or scan of a database: + +1. Using a session token: the `/api/database/:id/sync_schema` or `api/database/:id/rescan_values` endpoints. These endpoints do the same things as going to the database in the Admin Panel and choosing **Sync database schema now** or **Re-scan field values now** respectively. To use these endpoints, you have to authenticate with a user ID and pass a session token in the header of your request. +2. Using an API key: `/api/notify/db/:id`. We created this endpoint so that people could notify their Metabase to sync after an [ETL operation](https://www.metabase.com/learn/analytics/etl-landscape) finishes. To use this endpoint, you must pass an API key by defining the `MB_API_KEY` environment variable. + +## How database syncs work + +A Metabase **sync** is a query that gets a list of updated table and view names, column names, and column data types from your database: + +```sql +SELECT + TRUE +FROM + "your_schema"."your_table_or_view" +WHERE + 1 <> 1 +LIMIT 0 +``` + +This query runs against your database during setup, and again every hour by default. This scanning query is fast with most relational databases, but can be slower with MongoDB and some [community-built database drivers](../developers-guide/partner-and-community-drivers.md). Syncing can't be turned off completely, otherwise Metabase wouldn't work. + +## How database scans work + +A Metabase **scan** is a query that caches the column _values_ for filter dropdowns by looking at the first 1,000 distinct records from each table, in ascending order: + +```sql +SELECT + "your_table_or_view"."column" AS "column" +FROM + "your_schema"."your_table_or_view" +GROUP BY + "your_table_or_view"."column" +ORDER BY + "your_table_or_view"."column" ASC +LIMIT 1000 +``` + +For each record, Metabase only stores the first 100 kilobytes of text, so if you have data with 1,000 characters each (like addresses), and your column has more than 100 unique addresses, Metabase will only cache the first 100 values from the scan query. + +Cached column values are displayed in filter dropdown menus. If people type in the filter search box for values that aren't in the first 1,000 distinct records or 100kB of text, Metabase will run a query against your database to look for those values on the fly. + +A scan is more intensive than a sync query, so it only runs once during setup, and again once a day by default. If you [disable scans](#scheduling-database-scans) entirely, you'll need to bring things up to date by running [manual scans](#manually-scanning-column-values). + +To reduce the number of tables and fields Metabase needs to scan in order to stay current with your connected database, Metabase will only scan values for fields that someone has queried in the last fourteen days. + +## How database fingerprinting works + +The fingerprinting query looks at the first 10,000 rows from a given table or view in your database: + +```sql +SELECT + * +FROM + "your_schema"."your_table_or_view" +LIMIT 10000 +``` + +The result of this query is used to provide better suggestions in the Metabase UI (such as filter dropdowns and auto-binning). +To avoid putting strain on your database, Metabase only runs fingerprinting queries the [first time](#initial-sync-scan-and-fingerprinting) you set up a database connection. To change this default, you can turn ON [Periodically refingerprint tables](#periodically-refingerprint-tables). + +## Further reading + +Metabase doesn't do any caching or rate limiting during the sync and scan process. If your data appears to be missing or out of date, check out: + +- [Can’t see tables](../troubleshooting-guide/cant-see-tables.md). +- [Data in Metabase doesn’t match my database](../troubleshooting-guide/sync-fingerprint-scan.md). diff --git a/docs/databases/users-roles-privileges.md b/docs/databases/users-roles-privileges.md new file mode 100644 index 0000000000000..f8614644925d6 --- /dev/null +++ b/docs/databases/users-roles-privileges.md @@ -0,0 +1,184 @@ +--- +title: Database users, roles, and privileges +--- + +# Database users, roles, and privileges + +We recommend creating a `metabase` database user with the following database roles: + +- [`analytics` for read access](#minimum-database-privileges) to any schemas or tables used for analysis. +- Optional [`metabase_actions` for write access](#privileges-to-enable-actions) to tables used for Metabase actions. +- Optional [`metabase_model_caching` for write access](#privileges-to-enable-model-caching) to the schema used for Metabase model caching. + +Bundling your privileges into roles based on use cases makes it easier to manage privileges in the future (especially in [multi-tenant situations](#multi-tenant-permissions)). For example, you could: + +- Use the same `analytics` role for other BI tools in your [data stack](https://www.metabase.com/learn/databases/data-landscape#data-analysis-layer) that need read-only access to the analytics tables in your database. +- Revoke the write access for `metabase_model_caching` without affecting the write access for `metabase_actions`. + +## Minimum database privileges + +In order to view and query your tables in Metabase, you'll have to give Metabase's database user: + +- `CONNECT` to your database. +- `SELECT` privileges to any schemas or tables that you want to use in Metabase. + +To organize these privileges (and make maintenance easier down the line): + +- Create a database role called `analytics`. +- Create a database user called `metabase`. +- Add `metabase` to the `analytics` role. +- Add privileges to the `analytics` role. + +For example, if you're using a Postgres database, you'd log in as an admin and run the SQL statements: + +```sql +-- Create a role named "analytics". +CREATE ROLE analytics WITH LOGIN; + +-- Add the CONNECT privilege to the role. +GRANT CONNECT ON DATABASE "your_database" TO analytics; + +-- Create a database user named "metabase". +CREATE USER metabase WITH PASSWORD "your_password"; + +-- Give the role to the metabase user. +GRANT analytics TO metabase; + +-- Add query privileges to the role (options 1-3): + +-- Option 1: Uncomment the line below to let users with the analytics role query anything in the DATABASE. +-- GRANT pg_read_all_data ON DATABASE "your_database" TO analytics; + +-- Option 2: Uncomment the line below to let users with the analytics role query anything in a specific SCHEMA. +-- GRANT USAGE ON SCHEMA "your_schema" TO analytics; +-- GRANT SELECT ON ALL TABLES IN SCHEMA "your_schema" TO analytics; + +-- Option 3: Uncomment the line below to let users with the analytics role query anything in a specific TABLE. +-- GRANT USAGE ON SCHEMA "your_schema" TO analytics; +-- GRANT SELECT ON "your_table" IN SCHEMA "your_schema" TO analytics; +``` + +Depending on how you use Metabase, you can also additonally grant: + +- `TEMPORARY` privileges to create temp tables. +- `EXECUTE` privileges to use stored procedures or user-defined functions. + +Remember that when you grant privileges to a role, all users with that role will get those privileges. + +## Grant all database privileges + +If you don't want to structure your database privileges yet: + +- Create a `metabase` database user. +- Give `metabase` all privileges to the database. + +```sql +-- Create a database user named "metabase". +CREATE USER metabase WITH PASSWORD "your_password"; + +-- Give the user read and write privileges to anything in the database. +GRANT ALL PRIVILEGES ON "database" TO metabase; +``` + +This is a good option if you're connecting to a local database for development or testing. + +## Privileges to enable actions + +[Actions](../actions/introduction.md) let Metabase write back to specific tables in your database. + +In addition to the [minimum database privileges](#minimum-database-privileges), you'll need to grant write access to any tables used with actions: + +- Create a new role called `metabase_actions`. +- Give the role `INSERT`, `UPDATE`, and `DELETE` privileges to any tables used with Metabase actions. +- Give the `metabase_actions` role to the `metabase` user. + +```sql +-- Create a role to bundle database privileges for Metabase actions. +CREATE ROLE metabase_actions WITH LOGIN; + +-- Grant write privileges to the TABLE used with Metabase actions. +GRANT INSERT, UPDATE, DELETE ON "your_table" IN SCHEMA "your_schema" TO metabase_actions; + +-- Grant role to the metabase user. +GRANT metabase_actions TO metabase; +``` + +## Privileges to enable model caching + +[Model caching](../data-modeling/models.md#model-caching) lets Metabase save query results to a specific schema in your database. Metabase's database user will need the `CREATE` privilege to set up the dedicated schema for model caching, as well as write access (`INSERT`, `UPDATE`, `DELETE`) to that schema. + +In addition to the [minimum database privileges](#minimum-database-privileges): + +- Create a new role called `metabase_model_caching`. +- Give the role `CREATE` access to the database. +- Give the role `INSERT`, `UPDATE`, and `DELETE` privileges to the schema used for model caching. +- Give the `metabase_model_caching` role to the `metabase` user. + +```sql +-- Create a role to bundle database privileges for Metabase model caching. +CREATE ROLE metabase_model_caching WITH LOGIN; + +-- If you don't want to give CREATE access to your database, +-- add the schema manually before enabling modeling caching. +GRANT CREATE ON "database" TO metabase_model_caching; + +-- Grant write privileges to the SCHEMA used for model caching. +GRANT USAGE ON "your_schema" TO metabase_model_caching; +GRANT INSERT, UPDATE, DELETE ON "your_model's_table" IN SCHEMA "your_schema" TO metabase_model_caching; + +-- Grant role to the metabase user. +GRANT metabase_model_caching TO metabase; +``` + +## Multi-tenant permissions + +If you're setting up multi-tenant permissions for customers who need SQL access, you can [create one database connection per customer](https://www.metabase.com/learn/permissions/multi-tenant-permissions#option-2-granting-customers-native-sql-access-to-their-schema). That means each customer will connect to the database using their own database user. + +Let's say you have customers named Tangerine and Lemon: + +- Create new database users `metabase_tangerine` and `metabase_lemon`. +- Create a `customer_facing_analytics` role with the `CONNECT` privilege. +- Create roles to bundle privileges specific to each customer's use case. For example: + - `tangerine_queries` to bundle read privileges for people to query and create stored procedures against the Orange schema. + - `lemon_queries` to bundle read privileges for people to query tables in the Lemon schema. + - `lemon_actions` to bundle the write privileges needed to create [actions](#privileges-to-enable-actions) on a Lemonade table in the Lemon schema. +- Add each user to their respective roles. + +```sql +-- Create one database user per customer. +CREATE USER metabase_tangerine WITH PASSWORD "orange"; +CREATE USER metabase_lemon WITH PASSWORD "yellow"; + +-- Create a role to bundle privileges for all customers. +CREATE ROLE customer_facing_analytics; +GRANT CONNECT ON DATABASE "citrus" TO customer_facing_analytics; +GRANT customer_facing_analytics TO metabase_tangerine, metabase_lemon; + +-- Create a role to bundle analytics read access for customer Tangerine. +CREATE ROLE tangerine_queries; +GRANT USAGE ON SCHEMA "tangerine" TO tangerine_queries; +GRANT SELECT, EXECUTE ON ALL TABLES IN SCHEMA "tangerine" TO tangerine_queries; +GRANT tangerine_queries TO metabase_tangerine; + +-- Create a role to bundle analytics read access for customer Lemon. +CREATE ROLE lemon_queries; +GRANT USAGE ON SCHEMA "lemon" TO lemon_queries; +GRANT SELECT ON ALL TABLES IN SCHEMA "lemon" TO lemon_queries; +GRANT lemon_queries TO metabase_lemon; + +-- Create a role to bundle privileges to Metabase actions for customer Lemon. +CREATE ROLE lemon_actions; +GRANT INSERT, UPDATE, DELETE ON TABLE "lemonade" IN SCHEMA "lemon" TO lemon_actions; +GRANT lemon_actions TO metabase_lemon; +``` + +We recommend bundling privileges into roles based on use cases per customer. That way, you can reuse common privileges across customers while still being able to grant or revoke granular privileges per customer. For example: + +- If customer Tangerine needs to query the Tangerine schema from another analytics tool, you can use the `tangerine_queries` role when setting up that tool. +- If customer Lemon decides that they don't want to use Metabase actions anymore (but they still want to ask questions), you can simply revoke or drop the `lemon_actions` role. + +## Further reading + +- [Permissions strategies](https://www.metabase.com/learn/permissions/strategy) +- [Permissions introduction](../permissions/introduction.md) +- [People overview](../people-and-groups/start.md) diff --git a/docs/developers-guide/build.md b/docs/developers-guide/build.md index 7f52b6e8614c0..f84b2614be186 100644 --- a/docs/developers-guide/build.md +++ b/docs/developers-guide/build.md @@ -8,7 +8,7 @@ This doc will show you how you can build and run Metabase on your own computer s ## Install the prerequisites -If you're using macOS, you'll want to install Xcode Command Line Tools first, by running: +If you're using macOS, you'll want to install Xcode Command Line Tools first, by running: ``` xcode-select --install @@ -23,7 +23,7 @@ To complete any build of the Metabase code, you'll need to install the following 3. [Node.js (http://nodejs.org/)](http://nodejs.org/) - latest LTS release 4. [Yarn package manager for Node.js](https://yarnpkg.com/) - latest release of version 1.x - you can install it in any OS by running: - + ``` npm install --global yarn ``` @@ -34,11 +34,11 @@ On a most recent stable Ubuntu/Debian, all the tools above, with the exception o sudo apt install openjdk-11-jdk nodejs && sudo npm install --global yarn ``` -If you have multiple JDK versions installed in your machine, be sure to switch your JDK before building with: +If you have multiple JDK versions installed in your machine, be sure to switch your JDK before building with: ``` sudo update-alternatives --config java -``` +``` Then select Java 11 in the menu. @@ -70,7 +70,7 @@ Once you've installed all the build tools, you'll need to clone the [Metabase re 1. Create a `workspace` folder (you can name it that or whatever you want), which will store the Metabase code files. -2. Open up your terminal app, and navigate to your workspace folder with: +2. Open up your terminal app, and navigate to your workspace folder with: ``` cd ~/workspace @@ -85,20 +85,20 @@ git clone https://github.com/metabase/metabase ## Choose the branch you want to run, and run it -This is the part that you’ll use over and over. +This is the part that you’ll use over and over. The “official” branch of Metabase is called `master`, and other feature development branches get merged into it when they’re approved. So if you want to try out a feature before then, you’ll need to know the name of that branch so you can switch over to it. Here’s what to do: {:start="4"} 4. Open up your terminal app -5. Navigate to where you're storing the Metabase code. If you followed this guide exactly, you'd get there by entering this command: - +5. Navigate to where you're storing the Metabase code. If you followed this guide exactly, you'd get there by entering this command: + ``` cd ~/workspace/metabase ``` -6. "Pull” down the latest code by running: +6. "Pull” down the latest code by running: ``` git pull @@ -114,15 +114,15 @@ The “official” branch of Metabase is called `master`, and other feature deve ``` git checkout ``` - + If we wanted to switch to the branch in the previous step, we'd run: ``` git checkout fix-native-dataset-drill-popover ``` - - When you want to switch back to `master`, run: - + + When you want to switch back to `master`, run: + ``` git checkout master ``` @@ -135,21 +135,21 @@ The “official” branch of Metabase is called `master`, and other feature deve ``` clojure -M:run ``` - + When it’s done, you should see a message that says something like “Metabase initialization complete.” Keep this tab in your terminal app running, otherwise it’ll stop Metabase. -10. Open up another tab or window of your terminal app, and then “build” the frontend (all the UI) with this command: +10. Open up another tab or window of your terminal app, and then “build” the frontend (all the UI) with this command: ``` yarn build-hot ``` - + If you're having trouble with this step, make sure you are using the LTS version of [Node.js (http://nodejs.org/)](http://nodejs.org/). {:start="11"} 11. In your web browser of choice, navigate to `http://localhost:3000`, where you should see Metabase! - - This is the local “server” on your computer, and 3000 is the “port” that Metabase is running on. You can have multiple different apps running on different ports on your own computer. Note that if you share any URLs with others that begin with `localhost`, they won’t be able to access them because your computer by default isn’t open up to the whole world, for security. + + This is the local “server” on your computer, and 3000 is the “port” that Metabase is running on. You can have multiple different apps running on different ports on your own computer. Note that if you share any URLs with others that begin with `localhost`, they won’t be able to access them because your computer by default isn’t open up to the whole world, for security. To switch to a different branch or back to `master`, open up another Terminal tab, and repeat steps 6, 7, and 8. If Metabase wasn’t already running, you'll need to complete steps 9 and 10 again too. If it was already running, the frontend will automatically rebuild itself. You can check its progress by switching to that tab in your Terminal — it usually takes something like 15 seconds, but will depend on your hardware. @@ -161,7 +161,7 @@ If you want to make Metabase stop running, you can either quit your terminal pro The entire Metabase application is compiled and assembled into a single .jar file which can run on any modern JVM. There is a script which will execute all steps in the process and output the final artifact for you. You can pass the environment variable MB_EDITION before running the build script to choose the version that you want to build. If you don't provide a value, the default is `oss` which will build the Community Edition. - ./bin/build + ./bin/build.sh After running the build script simply look in `target/uberjar` for the output .jar file and you are ready to go. @@ -171,4 +171,4 @@ If you want to build Metabase without installing Clojure, Java, and Node.js on y ``` DOCKER_BUILDKIT=1 docker build --output container-output/ . ``` -Make sure that your Docker Daemon is running before executing the command. After running the command, you'll find the Metabase JAR file at `./container-output/app/metabase.jar`. \ No newline at end of file +Make sure that your Docker Daemon is running before executing the command. After running the command, you'll find the Metabase JAR file at `./container-output/app/metabase.jar`. diff --git a/docs/developers-guide/devenv.md b/docs/developers-guide/devenv.md index 48ef8a71a202f..c288b9d987d11 100644 --- a/docs/developers-guide/devenv.md +++ b/docs/developers-guide/devenv.md @@ -234,4 +234,4 @@ It is also possible to execute front-end and back-end checks separately ```sh $ yarn ci-frontend $ yarn ci-backend -``` \ No newline at end of file +``` diff --git a/docs/developers-guide/driver-changelog.md b/docs/developers-guide/driver-changelog.md index 4f60f6b7e301d..531b56af32c60 100644 --- a/docs/developers-guide/driver-changelog.md +++ b/docs/developers-guide/driver-changelog.md @@ -6,6 +6,36 @@ title: Driver interface changelog ## Metabase 0.46.0 +- The process for building a driver has changed slightly in Metabase 0.46.0. Your build command should now look + something like this: + + ```sh + # Example for building the driver with bash or similar + + # switch to the local checkout of the Metabase repo + cd /path/to/metabase/repo + + # get absolute path to the driver project directory + DRIVER_PATH=`readlink -f ~/sudoku-driver` + + # Build driver. See explanation in sample Sudoku driver README + clojure \ + -Sdeps "{:aliases {:sudoku {:extra-deps {com.metabase/sudoku-driver {:local/root \"$DRIVER_PATH\"}}}}}" \ + -X:build:sudoku \ + build-drivers.build-driver/build-driver! \ + "{:driver :sudoku, :project-dir \"$DRIVER_PATH\", :target-dir \"$DRIVER_PATH/target\"}" + ``` + + Take a look at our [build instructions for the sample Sudoku + driver](https://github.com/metabase/sudoku-driver#build-it-updated-for-build-script-changes-in-metabase-0460) + for an explanation of the command. + + Note that while this command itself is quite a lot to type, you no longer need to specify a `:build` alias in your + driver's `deps.edn` file. + + Please upvote https://ask.clojure.org/index.php/7843/allow-specifying-aliases-coordinates-that-point-projects , + which will allow us to simplify the driver build command in the future. + - The multimethod `metabase.driver/table-rows-sample` has been added. This method is used in situations where Metabase needs a limited sample from a table, like when fingerprinting. The default implementation defined in the `metabase.db.metadata-queries` namespace runs an MBQL query using the regular query processor to produce the sample diff --git a/docs/developers-guide/drivers/multimethods.md b/docs/developers-guide/drivers/multimethods.md index 2d7f93e1110a4..024c1d7869270 100644 --- a/docs/developers-guide/drivers/multimethods.md +++ b/docs/developers-guide/drivers/multimethods.md @@ -35,7 +35,7 @@ All core Metabase drivers live in `metabase.driver.` namespaces. ### Many drivers are further broken out into additional namespaces -Especially larger drivers. Commonly, a driver will have a `query-processor` namespace (e.g., `com.mycompany.metabase.driver.foxpro98.query-processor`) that contains the logic for converting MBQL queries (queries built using Metabase's graphical query builder) into native queries (like SQL). The query processor is often the most complicated part of a driver, so keeping that logic separate can help make things easier to work with. Some drivers also have a separate `sync` namespace that has implementations for methods used by Metabase's [database synchronization](../../databases/connecting.md#syncing-and-scanning-databases). +Especially larger drivers. Commonly, a driver will have a `query-processor` namespace (e.g., `com.mycompany.metabase.driver.foxpro98.query-processor`) that contains the logic for converting MBQL queries (queries built using Metabase's graphical query builder) into native queries (like SQL). The query processor is often the most complicated part of a driver, so keeping that logic separate can help make things easier to work with. Some drivers also have a separate `sync` namespace that has implementations for methods used by Metabase's [database synchronization](../../databases/sync-scan.md). ## Driver initialization diff --git a/docs/developers-guide/drivers/plugins.md b/docs/developers-guide/drivers/plugins.md index 99053dbf91581..bf08022eea23c 100644 --- a/docs/developers-guide/drivers/plugins.md +++ b/docs/developers-guide/drivers/plugins.md @@ -43,7 +43,7 @@ You _can_ (but shouldn't) set a driver to `lazy-load: false`, as this will make ## Plugin initialization -Metabase will initialize plugins automatically as needed. Initialization goes something like this: Metabase adds the driver to the classpath, then it performs ea `init` section of the plugin manifest, in order. In the [example manifest above](#example-manifest), there are two steps, a `load-namespace` step, and a `register-jdbc-driver` step: +Metabase will initialize plugins automatically as needed. Initialization goes something like this: Metabase adds the driver to the classpath, then it performs each `init` section of the plugin manifest, in order. In the [example manifest above](#example-manifest), there are two steps, a `load-namespace` step, and a `register-jdbc-driver` step: ```yaml init: @@ -55,9 +55,9 @@ init: ## Loading namespaces -You'll need to add one or more `load-namespace` steps to your driver manifest to tell Metabase which namespaces contain your driver method implementations. In the example above, the namespace is `metabase.driver.sqlite`. `load-namespace` calls `require` the [normal Clojure way, meaning it will load other namespaces listed in the `:require` section of its namespace declaration as needed. If your driver's method implementations are split across multiple namespaces, make sure they'll get loaded as well -- you can either have the main namespace handle this (e.g., by including them in the `:require` form in the namespace declaration) or by adding additional `load-namespace` steps. +You'll need to add one or more `load-namespace` steps to your driver manifest to tell Metabase which namespaces contain your driver method implementations. In the example above, the namespace is `metabase.driver.sqlite`. `load-namespace` calls `require` the [normal Clojure way](https://clojuredocs.org/clojure.core/require), meaning it will load other namespaces listed in the `:require` section of its namespace declaration as needed. If your driver's method implementations are split across multiple namespaces, make sure they'll get loaded as well -- you can either have the main namespace handle this (e.g., by including them in the `:require` form in the namespace declaration) or by adding additional `load-namespace` steps. -For some background on namespaces, see [Clojure namespaces][clojure-namespace]. +For some background on namespaces, see [Clojure namespaces](https://clojure.org/guides/learn/namespaces). ## Registering JDBC Drivers @@ -67,7 +67,7 @@ The if-you're-interested reason is that Java's JDBC `DriverManager` won't use JD ## Building the driver -To build a driver as a plugin JAR, check out the [Build-driver scripts README](https://github.com/metabase/metabase/tree/master/bin/build-drivers). +To build a driver as a plugin JAR, check out the [Build-driver scripts README](https://github.com/metabase/metabase/tree/master/bin/build-drivers.md). Place the JAR you built in your Metabase's `/plugins` directory, and you're off to the races. diff --git a/docs/developers-guide/e2e-tests.md b/docs/developers-guide/e2e-tests.md index 1acd92b0bf3cb..4eceaeb319479 100644 --- a/docs/developers-guide/e2e-tests.md +++ b/docs/developers-guide/e2e-tests.md @@ -8,7 +8,7 @@ Metabase uses Cypress for “end-to-end testing”, that is, tests that are exec ## Getting Started -Metabase’s Cypress tests are located in the `frontend/test/metabase/scenarios` source tree, in a structure that roughly mirrors Metabase’s URL structure. For example, tests for the admin “datamodel” pages are located in `frontend/test/metabase/scenarios/admin/datamodel`. +Metabase’s Cypress tests are located in the `e2e/test/scenarios` source tree, in a structure that roughly mirrors Metabase’s URL structure. For example, tests for the admin “datamodel” pages are located in `e2e/test/scenarios/admin/datamodel`. During development you will want to run `yarn build-hot` to continuously build the frontend, and `yarn test-cypress-open` to open the Cypress application where you can execute the tests you are working on. @@ -17,7 +17,7 @@ To run all Cypress tests programmatically in the terminal: yarn run test-cypress-run ``` -You can run a specific set of scenarios by using the `--folder` flag, which will pick up the chosen scenarios under `frontend/test/metabase/scenarios/`. +You can run a specific set of scenarios by using the `--folder` flag, which will pick up the chosen scenarios under `e2e/test/scenarios/`. ``` yarn run test-cypress-run --folder sharing @@ -26,7 +26,7 @@ yarn run test-cypress-run --folder sharing You can quickly test a single file only by using the `--spec` flag. ``` -yarn test-cypress-run --spec frontend/test/metabase/scenarios/question/new.cy.spec.js +yarn test-cypress-run --spec e2e/test/scenarios/question/new.cy.spec.js ``` Cypress test files are structured like Mocha tests, where `describe` blocks are used to group related tests, and `it` blocks are the tests themselves. diff --git a/docs/developers-guide/partner-and-community-drivers.md b/docs/developers-guide/partner-and-community-drivers.md index 567267da1a429..68417e44baccf 100644 --- a/docs/developers-guide/partner-and-community-drivers.md +++ b/docs/developers-guide/partner-and-community-drivers.md @@ -45,8 +45,10 @@ Current partner drivers: - [Clickhouse](https://github.com/ClickHouse/metabase-clickhouse-driver) - [Exasol](https://github.com/exasol/metabase-driver) - [Firebolt](https://docs.firebolt.io/integrations/business-intelligence/connecting-to-metabase.html) +- [Ocient](https://github.com/Xeograph/metabase-ocient-driver) - [Starburst (compatible with Trino)](https://github.com/starburstdata/metabase-driver) + Partner drivers are available to Cloud customers out-of-the-box. If you have interest in becoming a partner, please fill the [partner form](https://www.metabase.com/partners/join) and we will get in touch. diff --git a/docs/developers-guide/start.md b/docs/developers-guide/start.md index 7e404b6cfa488..1497c464965f9 100644 --- a/docs/developers-guide/start.md +++ b/docs/developers-guide/start.md @@ -43,3 +43,7 @@ This guide contains detailed information on how to work on Metabase codebase. ## Metabase documentation - [Developing Metabase documentation](./docs.md) + +## Releases + +- [Metabase release versioning](./versioning.md) diff --git a/docs/developers-guide/versioning.md b/docs/developers-guide/versioning.md new file mode 100644 index 0000000000000..4e47beb3c26f1 --- /dev/null +++ b/docs/developers-guide/versioning.md @@ -0,0 +1,65 @@ +--- +title: Metabase release versioning +--- + +# Metabase release versioning + +We follow our own flavor of the [semantic versioning guidelines](https://semver.org/) in order to distinguish the [open-source version](https://www.metabase.com/product/starter) of Metabase from the paid, source-available version of Metabase (available in the [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans). + +Semantic versioning typically follows the format: `Major.Minor.Point.Patch`. For example, version `3.15.2` or `3.15.2.1`. + +With Metabase releases, we prefix the version with a `0` or `1`, depending on the license. + +## The Metabase version schema + +``` +License.Major.Point.Hotfix +``` + +E.g., + +``` +v0.46.3.1 +``` + +`v0.46.3.1` would be for a hotfix (`1`) for the third (`3`) point release of Metabase `46`, the open-source edition (`0`). + +### License + +- `0` for the free, open-source version (sometimes called OSS, for open-source software). +- `1` for the paid, source-available version that has all the bells and whistles (sometimes called EE for "Enterprise Edition"). + +### Major + +We release major version when we introduce new features or breaking changes. + +### Point + +Sometimes called a minor release, we issue point releases when we add bug fixes and refinements to existing features. + +### Hotfix + +Sometimes called a patch release, we issue these hotfix releases to fix security issues in a timely manner, or to undo a horrific regression. + +## Other release terms + +### The Gold Release + +The gold release is the first release of a new major version of Metabase. So for Metabase version 46, the gold releases would be: + +- `v0.46.0` (the OSS version) +- `v1.46.0` (the EE version) + +### Release Candidates + +We usually publish release candidates to kick the tires on new features before releasing a new major version (a gold release). To distinguish these release candidates, we append an `-RC#` at the end. + +E.g., + +- `v1.46.0-RC1` (the first release candidate for the EE version) +- `v0.46.0-RC3` (the third release candidate for the OSS version) + +## Further reading + +- [Metabase releases on Github](https://github.com/metabase/metabase/releases) +- [Metabase release blog posts](https://www.metabase.com/releases) diff --git a/docs/developers-guide/visual-tests.md b/docs/developers-guide/visual-tests.md index 2e4e5e45b82b9..24248f7fbb31d 100644 --- a/docs/developers-guide/visual-tests.md +++ b/docs/developers-guide/visual-tests.md @@ -35,7 +35,7 @@ To recap: We use Cypress to write Percy tests so we can fully use all existing helpers and custom commands. -Visual regression tests live inside the `frontend/test/metabase-visual` directory. Writing a Percy test consists of creating a desired page state and executing `cy.createPercySnapshot()` command. +Visual regression tests live inside the `e2e/test/visual` directory. Writing a Percy test consists of creating a desired page state and executing `cy.createPercySnapshot()` command. ### Goal @@ -45,7 +45,7 @@ Each visual test should cover as many as possible different elements, variants o 1. Run Metabase in the dev mode locally (`yarn dev` or similar commands). 2. Run `yarn test-visual-open` to open Cypress locally. You do not need to export any `PERCY_TOKEN`. -3. Create a spec inside `frontend/test/metabase-visual` and run it via Cypress runner. +3. Create a spec inside `e2e/test/visual` and run it via Cypress runner. At this step, if you added `percySnapshot` command somewhere in your test, you will see `percyHealthCheck` step in your test: diff --git a/docs/embedding/full-app-embedding.md b/docs/embedding/full-app-embedding.md index 9d96342668401..5e8efd46c4c8c 100644 --- a/docs/embedding/full-app-embedding.md +++ b/docs/embedding/full-app-embedding.md @@ -66,16 +66,16 @@ To embed a specific Metabase dashboard, use the dashboard's URL, such as: Use this option if you want to send people directly to your SSO login screen (i.e., skip over the Metabase login screen with an SSO button), and redirect to Metabase automatically upon authentication. -You'll need to set the `src` attribute to your auth endpoint, with a parameter containing the encoded Metabase URL. For example, to send people to your SSO login page and automatically redirect them to `http://metabase.yourcompany.com/dashboard/1`: +You'll need to set the `src` attribute to your auth endpoint, with a `return_to` parameter pointing to the encoded Metabase URL. For example, to send people to your SSO login page and automatically redirect them to `http://metabase.yourcompany.com/dashboard/1`: ``` -https://metabase.example.com/auth/sso?redirect=http%3A%2F%2Fmetabase.yourcompany.com%2Fdashboard%2F1 +https://metabase.example.com/auth/sso?return_to=http%3A%2F%2Fmetabase.yourcompany.com%2Fdashboard%2F1 ``` If you're using [JWT](../people-and-groups/authenticating-with-jwt.md), you can use the relative path for the redirect (i.e., your Metabase URL without the [site URL](../configuring-metabase/settings.md#site-url)). For example, to send people to a Metabase page at `/dashboard/1`: ``` -https://metabase.example.com/auth/sso?jwt=&redirect=%2Fdashboard%2F1 +https://metabase.example.com/auth/sso?jwt=&return_to=%2Fdashboard%2F1 ``` You must URL encode (or double encode, depending on your web setup) all of the parameters in your redirect link, including parameters for filters (e.g., `filter=value`) and [UI settings](#showing-or-hiding-metabase-ui-components) (e.g., `top_nav=true`). For example, if you added two filter parameters to the JWT example shown above, your `src` link would become: @@ -84,17 +84,27 @@ You must URL encode (or double encode, depending on your web setup) all of the p https://metabase.example.com/auth/sso?jwt=&redirect=%2Fdashboard%2F1%3Ffilter1%3Dvalue%26filter2%3Dvalue ``` +## Cross-browser compatibility + +To make sure that your embedded Metabase works in all browsers, put Metabase and the embedding app in the same top-level domain (TLD). The TLD is indicated by the last part of a web address, like `.com` or `.org`. + +Note that your full-app embed must be compatible with Safari to run on _any_ browser in iOS (such as Chrome on iOS). + ## Embedding Metabase in a different domain -If you want to embed Metabase in another domain (say, if Metabase is hosted at `metabase.yourcompany.com`, but you want to embed Metabase at `yourcompany.github.io`), set the following [environment variable](../configuring-metabase/environment-variables.md): +> Skip this section if your Metabase and embedding app are already in the same top-level domain (TLD). + +If you want to embed Metabase in another domain (say, if Metabase is hosted at `metabase.yourcompany.com`, but you want to embed Metabase at `yourcompany.github.io`), you can set the following [environment variable](../configuring-metabase/environment-variables.md): `MB_SESSION_COOKIE_SAMESITE=None` -If you set this environment variable to "None", you must use HTTPS in Metabase to prevent browsers from rejecting the request. For more information, see MDN's documentation on [SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite). +If you set this environment variable to "None", you must use HTTPS in Metabase to prevent browsers from rejecting the request. For more information, see MDN's documentation on [SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite). + +Note that `SameSite=None` is incompatible with most Safari and iOS browser versions (including any browser that runs on iOS, such as Chrome on iOS). ## Securing full-app embeds -Metabase uses HTTP cookies to authenticate people and keep them signed into your embedded Metabase, even when someone closes their browser session. +Metabase uses HTTP cookies to authenticate people and keep them signed into your embedded Metabase, even when someone closes their browser session. If you enjoy diagrammed auth flows, check out [Full-app embedding with SSO](https://www.metabase.com/learn/customer-facing-analytics/securing-embeds#full-app-embedding-with-sso). To limit the amount of time that a person stays logged in, set [`MAX_SESSION_AGE`](../configuring-metabase/environment-variables.md#max_session_age) to a number in minutes. The default value is 20,160 (two weeks). diff --git a/docs/embedding/introduction.md b/docs/embedding/introduction.md index 9457f53dad5ce..78ebd066ad170 100644 --- a/docs/embedding/introduction.md +++ b/docs/embedding/introduction.md @@ -42,7 +42,7 @@ If you'd like to share your data with the good people of the internet, you can c | Display interactive [filter widgets](https://www.metabase.com/glossary/filter_widget) | ✅ | ✅ | ✅ | | Restrict data with [locked filters](./signed-embedding-parameters.md#restricting-data-in-a-signed-embed) | ❌ | ✅ | ❌ | | Restrict data with [sandboxes](../permissions/data-sandboxes.md) | ✅ | ❌ | ❌ | -| Drill-down using the [action menu](https://www.metabase.com/learn/questions/drill-through) | ✅ | ❌ | ❌ | +| Use the [drill-through menu](https://www.metabase.com/learn/questions/drill-through) | ✅ | ❌ | ❌ | | Self-serve via [query builder](https://www.metabase.com/glossary/query_builder) | ✅ | ❌ | ❌ | | View usage of embeds with [auditing tools](../usage-and-performance-tools/audit.md) | ✅ | ❌ | ❌ | diff --git a/docs/exploration-and-organization/collections.md b/docs/exploration-and-organization/collections.md index 4982fd09d2b3d..a78e66920b5e0 100644 --- a/docs/exploration-and-organization/collections.md +++ b/docs/exploration-and-organization/collections.md @@ -6,7 +6,7 @@ redirect_from: # Collections - After your team has been using Metabase for a while, you’ll probably end up with lots of saved questions. + After your team has been using Metabase for a while, you’ll probably end up with lots of saved questions and dashboards. ![Our analytics](./images/our-analytics-page.png) @@ -16,16 +16,18 @@ Collections are the main way to organize questions, dashboards, and [models][mod ### Regular collections -They're just basic collections. You can put stuff in them. +They're like file-system folders. You can put stuff in them. ### Official collections {% include plans-blockquote.html feature="Official collections" %} -These are special collections, in that they have a badge to let people know that the items in this collection are the ones people should be looking at (or whatever "official" means to you). Questions and dashboards in official collections are also more likely to show up at the top of search results. - ![Official collections](./images/official-collection.png) +Metabase admins can designate collections as "official". These collections have a yellow badge to let people know that the items in the collection are the ones people should be looking at (or whatever "official" means to you). Questions and dashboards in official collections are also more likely to show up at the top of search results. Pairing Official badges with [verified items](./exploration.md#verified-items) can help everyone in your Metabase sort out which questions people can trust. + +To add an official badge to a collection, an admin can visit the collection and click on the dot dot dot menu (**...**) and select **Make collection official**. Admins can also remove an official badge in the same menu. Admins can also mark a collection as official or not when they first create the collection. + ## Collection permissions [Administrators can give you different kinds of access](../permissions/collections.md) to each collection: @@ -62,5 +64,11 @@ Note that you have to have Curate permission for the collection that you're movi You can add events to collections, and organize those events into timelines. See [Events and timelines](events-and-timelines.md). +## Further reading + +- [Keeping your analytics organized](https://www.metabase.com/learn/administration/same-page) +- [Multiple environments](https://www.metabase.com/learn/administration/multi-env#one-collection-per-environment) + [dashboards]: ../dashboards/introduction.md [models]: ../data-modeling/models.md + diff --git a/docs/exploration-and-organization/exploration.md b/docs/exploration-and-organization/exploration.md index 281b36ce03980..5c6d9354d8e13 100644 --- a/docs/exploration-and-organization/exploration.md +++ b/docs/exploration-and-organization/exploration.md @@ -46,7 +46,7 @@ In this example of orders by product category over time, clicking on a dot on th - **View these Orders**: See a list of the orders for a particular month - **Break out by a category**: See things like the Gizmo orders in June 2017 broken out by the status of the customer (e.g., `new` or `VIP`). Different charts will have different breakout options, such as **Location** and **Time**. -> Note that while charts created with SQL don't currently have the action menu, you can add SQL questions to a dashboard and customize their click behavior. You can send people to a [custom destination](https://www.metabase.com/learn/building-analytics/dashboards/custom-destinations.html) (like another dashboard or an external URL), or have the clicked value [update a dashboard filter](https://www.metabase.com/learn/building-analytics/dashboards/cross-filtering.html). +> Note that while charts created with SQL don't currently have the drill-through menu, you can add SQL questions to a dashboard and customize their click behavior. You can send people to a [custom destination](https://www.metabase.com/learn/building-analytics/dashboards/custom-destinations.html) (like another dashboard or an external URL), or have the clicked value [update a dashboard filter](https://www.metabase.com/learn/building-analytics/dashboards/cross-filtering.html). Clicking on a table cell will often allow you to filter the results using a comparison operator, like =, >, or <. For example, you can click on a table cell, and select the less than operator `<` to filter for values that are less than the selected value. @@ -56,7 +56,7 @@ Lastly, clicking on the ID of an item in a table gives you the option to go to a ![Detail view](images/detail-view.png) -When you add questions to a dashboard, you can have even more control over what happens when people click on your chart. In addition to the default action menu, you can add a [custom destination](https://www.metabase.com/learn/building-analytics/dashboards/custom-destinations.html) or [update a filter](https://www.metabase.com/learn/building-analytics/dashboards/cross-filtering). Check out [interactive dashboards](../dashboards/interactive.md). to learn more. +When you add questions to a dashboard, you can have even more control over what happens when people click on your chart. In addition to the default drill-through menu, you can add a [custom destination](https://www.metabase.com/learn/building-analytics/dashboards/custom-destinations.html) or [update a filter](https://www.metabase.com/learn/building-analytics/dashboards/cross-filtering). Check out [interactive dashboards](../dashboards/interactive.md). to learn more. ## Exploring saved questions diff --git a/docs/exploration-and-organization/images/official-collection.png b/docs/exploration-and-organization/images/official-collection.png index 732652cf9a6eb..aaf327081d55d 100644 Binary files a/docs/exploration-and-organization/images/official-collection.png and b/docs/exploration-and-organization/images/official-collection.png differ diff --git a/docs/exploration-and-organization/images/verified-icon.png b/docs/exploration-and-organization/images/verified-icon.png index 7ab54270f31a2..1b6ccf96fcff1 100644 Binary files a/docs/exploration-and-organization/images/verified-icon.png and b/docs/exploration-and-organization/images/verified-icon.png differ diff --git a/docs/exploration-and-organization/x-rays.md b/docs/exploration-and-organization/x-rays.md index 9f08a092f471c..0d873927e9405 100644 --- a/docs/exploration-and-organization/x-rays.md +++ b/docs/exploration-and-organization/x-rays.md @@ -10,13 +10,13 @@ X-rays are a fast and easy way to get automatic insights and explorations of you ## Viewing X-rays by clicking on charts or tables -One great way to explore your data in general in Metabase is to click on points of interest in charts or tables, which shows you ways to further explore that point. We've added X-rays to this action menu, so if you for example find a point on your line chart that seems extra interesting, give it a click and X-ray it! We think you'll like what you see. +One great way to explore your data in general in Metabase is to click on points of interest in charts or tables, which shows you ways to further explore that point. We've added X-rays to this drill-through menu, so if you for example find a point on your line chart that seems extra interesting, give it a click and X-ray it! We think you'll like what you see. ![X-ray action in drill-through menu](./images/drill-through.png) ## Comparisons -To see how the value of a bar, point, or geographic region compares to the rest of the data, click on it to pull up the action menu, then select **Compare to the rest**. +To see how the value of a bar, point, or geographic region compares to the rest of the data, click on it to pull up the drill-through menu, then select **Compare to the rest**. ![Compare menu](./images/x-ray-compare-popover.png) diff --git a/docs/images/metabase-product-screenshot.png b/docs/images/metabase-product-screenshot.png new file mode 100644 index 0000000000000..1d150efa07daf Binary files /dev/null and b/docs/images/metabase-product-screenshot.png differ diff --git a/docs/installation-and-operation/serialization.md b/docs/installation-and-operation/serialization.md index f253ac386349d..178327ee6470c 100644 --- a/docs/installation-and-operation/serialization.md +++ b/docs/installation-and-operation/serialization.md @@ -14,30 +14,19 @@ To help you out in situations like this, Metabase has a serialization feature wh If you're looking to do a one-time migration from H2 to MySQL/Postgres, then use the [migration guide instead](./migrating-from-h2.md). -### What gets dumped and loaded +## What gets dumped and loaded -**Currently, dumps consist of the following Metabase artifacts:** +Only some artifacts are included in dumps and loads: -- Collections +- Collections (except for personal collections) - Dashboards - Saved questions - Segments and Metrics defined in the Data Model - Public sharing settings for questions and dashboards - -**They also contain a number of system settings:** - - Admin Panel settings, except for permissions - Database connection settings - Data Model settings -**Dumps do _not_ contain:** - -- Permission settings -- User accounts or settings -- Alerts on saved questions -- Personal Collections or their contents (except for the user specified with the `--user` flag; see below) -- Archived items - ### Before creating or loading a dump If your instance is currently running, you will need to stop it first before creating or loading a dump, unless your Metabase application database supports concurrent reads. The default application database type, H2, does not. diff --git a/docs/installation-and-operation/upgrading-metabase.md b/docs/installation-and-operation/upgrading-metabase.md index b0e49d74f6683..9e8475b417e89 100644 --- a/docs/installation-and-operation/upgrading-metabase.md +++ b/docs/installation-and-operation/upgrading-metabase.md @@ -139,10 +139,4 @@ If you're running Docker, the command would be: docker run --rm metabase/metabase migrate down ``` -The default is to migrate down by one major version, but you can also specify a major version (as an integer) to downgrade to: - -``` -java -jar metabase.jar migrate down 44 -``` - Once the migration process completes, start up Metabase using the JAR or Docker image for the version you want to run. diff --git a/docs/paid-features/overview.md b/docs/paid-features/overview.md index 4fea9a5cdbd08..f91f8f147bf86 100644 --- a/docs/paid-features/overview.md +++ b/docs/paid-features/overview.md @@ -6,13 +6,10 @@ redirect_from: # Overview of premium features -Metabase's [Enterprise and Pro](https://www.metabase.com/pricing) plans provide additional features that help organizations scale Metabase and deliver self-service, embedded analytics. +Metabase's [Enterprise and Pro](https://www.metabase.com/pricing) plans provide additional features that help organizations scale Metabase and deliver self-service internal or embedded analytics. -## Setting up - -Metabase Pro is hosted, so you should already be setup with all the paid features, but you may have to activate a Metabase Enterprise edition to access all the features. - -- [Getting and activating the Enterprise edition](activating-the-enterprise-edition.md) +- **If you're on Metabase Cloud**, your paid features will activate automatically. +- **If you're self-hosting,** you'll need to [activate your license](./activating-the-enterprise-edition.md). ## Authentication @@ -34,6 +31,13 @@ Paid plans include more ways to manage permissions, including data sandboxing, w - [Block permissions](../permissions/data.md#block-access) - [SQL snippet folder permissions](../permissions/snippets.md) - [Application permissions](../permissions/application.md) +- [Download permissions](../permissions/data.md#download-results) +- [Database management permissions](../permissions/data.md#manage-database) +- [Data model management permissions](../permissions/data.md#manage-data-model) + +## People and group management + +- [Group managers](../people-and-groups/managing.md#group-managers) ## Embedding @@ -54,21 +58,19 @@ As an additional security layer, you can whitelist domains, which restricts peop - [Approved domains for notifications](../configuring-metabase/settings.md#approved-domains-for-notifications) -## Official collections - -You can mark certain collections as [official](../exploration-and-organization/collections.md#official-collections), which helps people find your most important questions, dashboards, and models. - -## Question moderation +## Content moderation tools -People can ask administrators to verify their questions and models. +Tools for keeping your Metabase organized, so people can find your most important, verified items. +- [Official collections](../exploration-and-organization/collections.md#official-collections) - [Verified items](../exploration-and-organization/exploration.md#verified-items) ## Advanced caching controls All Metabase editions include global caching controls. Paid plans includes additional caching options that let you control caching for individual questions. -- [Caching controls for individual questions](../questions/sharing/answers.md#caching-results) +- [Caching controls for individual questions](../configuring-metabase/caching.md#caching-per-question) +- [Caching control per database](../configuring-metabase/caching.md#caching-per-database) ## Auditing diff --git a/docs/people-and-groups/authenticating-with-jwt.md b/docs/people-and-groups/authenticating-with-jwt.md index c35dc0e6669f2..55b304abead00 100644 --- a/docs/people-and-groups/authenticating-with-jwt.md +++ b/docs/people-and-groups/authenticating-with-jwt.md @@ -62,9 +62,19 @@ You can use your JWT to assign Metabase users to custom groups by following thes 2. In the Admin Panel in Metabase, go to the Authentication tab of the Settings section and click the Configure button on JWT. On this screen, turn on the toggle under "SYNCHRONIZE GROUP MEMBERSHIPS". 3. Next, click Edit Mappings. In this modal, type in the name of one of your groups as defined in the JWT, then click Add. In the row that appears, click the dropdown to pick the Metabase group that this should map to. Repeat this for each of the groups you want to map. -## Disabling password login +## Creating Metabase accounts with SSO -Once you have set up your JWT authentication and confirmed that it's working, if you want to disable the option for users to log in via username and password, return to **Admin** > **Settings** > **Authentication** and scroll to the bottom. A toggle should now be visible that allows you disable password authentication. +> Paid plans [charge for each additional account](https://www.metabase.com/docs/latest/cloud/how-billing-works#what-counts-as-a-user-account). + +A new SSO login will automatically create a new Metabase account. + +Metabase accounts created with an external identity provider login don't have passwords. People who sign up for Metabase using an IdP must continue to use the IdP to log into Metabase. + +## Disabling password logins + +> **Avoid locking yourself out of your Metabase!** This setting will apply to all Metabase accounts, _including your Metabase admin account_. We recommend that you keep password authentication **enabled**. This will safeguard you from getting locked out of Metabase in case of any problems with SSO. + +To require people to log in with SSO, disable password authentication from **Admin settings** > **Authentication**. ![Password disable](images/password-disable.png) diff --git a/docs/people-and-groups/authenticating-with-saml.md b/docs/people-and-groups/authenticating-with-saml.md index 7f67d018ef3de..4d92a161b54a2 100644 --- a/docs/people-and-groups/authenticating-with-saml.md +++ b/docs/people-and-groups/authenticating-with-saml.md @@ -75,7 +75,7 @@ Most IdPs already include these assertions by default, but some (such as [Okta]( Generally you'll need to paste these user attributes (first name, last name, and email) into fields labelled "Name", "Attributes" or "Parameters". -**End-users should not be able to edit the email address attribute**. Your IdP will pass the email address attribute to Metabase in order to log people into their Metabase accounts (or to create an account on the first login). If a person can change the email address attribute, they'll potentially be able to access Metabase accounts other than their own. +> If you allow people to edit their email addresses: make sure to update the corresponding account emails in Metabase. Keeping email addresses in sync will protect people from losing access to their accounts. ### Settings for signing SSO requests (optional) @@ -151,9 +151,19 @@ After that, type in the name of the user attribute you added in your SAML provid ![Group schema](images/saml-group-schema.png) -## Disabling password log-in +## Creating Metabase accounts with SSO -Once you have configured SAML authentication, you can choose to disable the option for users to log in via email and password. To do this, return to the main Authentication settings page and scroll to the bottom. A toggle will now be visible allowing you to disable password authentication. +> Paid plans [charge for each additional account](https://www.metabase.com/docs/latest/cloud/how-billing-works#what-counts-as-a-user-account). + +A new SSO login will automatically create a new Metabase account. + +Metabase accounts created with an external identity provider login don't have passwords. People who sign up for Metabase using an IdP must continue to use the IdP to log into Metabase. + +## Disabling password logins + +> **Avoid locking yourself out of your Metabase!** This setting will apply to all Metabase accounts, _including your Metabase admin account_. We recommend that you keep password authentication **enabled**. This will safeguard you from getting locked out of Metabase in case of any problems with SSO. + +To require people to log in with SSO, disable password authentication from **Admin settings** > **Authentication**. ![Password disable](images/password-disable.png) diff --git a/docs/people-and-groups/changing-session-expiration.md b/docs/people-and-groups/changing-session-expiration.md index d58d5a39bffa5..8e6b0d44bda97 100644 --- a/docs/people-and-groups/changing-session-expiration.md +++ b/docs/people-and-groups/changing-session-expiration.md @@ -6,18 +6,20 @@ redirect_from: # Session expiration -By default, Metabase sessions are valid for two weeks after a user last authenticated (e.g. by entering their email -address/password or via an SSO provider). For example, even if you visit your Metabase instance every day, you'll -still have to log in again every two weeks. +By default, Metabase sessions are valid for two weeks after a user last authenticated (e.g. by entering their email address/password or via an SSO provider). For example, even if you visit your Metabase instance every day, you'll still have to log in again every two weeks. -This "session expiration" is configurable via the environment variable `MAX_SESSION_AGE` or as a Java system property: +## Session age + +The session age is the maximum time that a person stays logged into Metabase (even if the person closes the browser). + +You can set the environment variable [`MAX_SESSION_AGE`](../configuring-metabase/environment-variables.md#max_session_age): ``` # Change session expiration to 24 hours MAX_SESSION_AGE=1440 java -jar metabase.jar ``` -or +or set the Java system property: ``` java -DMAX_SESSION_AGE=1440 -jar metabase.jar @@ -25,26 +27,26 @@ java -DMAX_SESSION_AGE=1440 -jar metabase.jar `MAX_SESSION_AGE` is in minutes. +## Session timeout + +{% include plans-blockquote.html feature="Session timeout" %} + +The session timeout is the maximum time that a person can be inactive (for example, if someone leaves Metabase open in a long-forgotten browser tab). + +You can toggle this setting from **Admin** > **Authentication**, or set the environment variable [`MB_SESSION_TIMEOUT`](../configuring-metabase/environment-variables.md#mb_session_timeout). + +Session timeout is null by default. You can use a session timeout to log people out earlier than the max [session age](#session-age). -### Using Session cookies +## Session cookies -Metabase also supports using [session -cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Session_cookies), which mean users will only stay -authenticated until they close their browser. This can be enabled on a per-user basis by unchecking the "Remember me" -box when logging in. Once the user closes their browser, the next time they visit Metabase they'll have to log in -again. Session expiration still applies, so even if you leave your browser open forever, you'll still be -required to re-authenticate after two weeks or whatever session expiration you've configured. +Metabase also supports using [session cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Session_cookies), which mean users will only stay authenticated until they close their browser. This can be enabled on a per-user basis by unchecking the "Remember me" box when logging in. Once the user closes their browser, the next time they visit Metabase they'll have to log in again. Session expiration still applies, so even if you leave your browser open forever, you'll still be required to re-authenticate after two weeks or whatever session expiration you've configured. -You can tell Metabase to always use session cookies with the environment variable or Java system property -`MB_SESSION_COOKIES`: +You can tell Metabase to always use session cookies with the environment variable or Java system property `MB_SESSION_COOKIES`: ``` MB_SESSION_COOKIES=true java -jar metabase.jar ``` -Setting this environment variable will override the behavior of the "Remember me" checkbox and enforce the use of -session cookies for all users. +Setting this environment variable will override the behavior of the "Remember me" checkbox and enforce the use of session cookies for all users. -Note that browsers may use "session restoring", which means they automatically restore their previous session when -reopened. In this case, the browser effectively acts as if it was never closed; session cookies will act -the same as permanent cookies. For browsers that support this feature, this behavior is usually configurable. +Note that browsers may use "session restoring", which means they automatically restore their previous session when reopened. In this case, the browser effectively acts as if it was never closed; session cookies will act the same as permanent cookies. For browsers that support this feature, this behavior is usually configurable. diff --git a/docs/people-and-groups/google-and-ldap.md b/docs/people-and-groups/google-and-ldap.md index 5b186b07e5016..408ee8d54d21e 100644 --- a/docs/people-and-groups/google-and-ldap.md +++ b/docs/people-and-groups/google-and-ldap.md @@ -31,9 +31,11 @@ Now existing Metabase users signed into a Google account that matches their Meta ### Creating Metabase accounts with Google Sign-in -If you’ve added your Google client ID to your Metabase settings, you can also let users sign up on their own without creating accounts for them. +> On [paid plans](https://www.metabase.com/pricing), you're [charged for each additional account](https://www.metabase.com/docs/latest/cloud/how-billing-works#what-counts-as-a-user-account). -To enable this, go to the Google Sign-In configuration page, and specify the email domain you want to allow. For example, if you work at WidgetCo you could enter "widgetco.com" in the field to let anyone with a company email sign up on their own. +You can optionally tell Metabase to automatically create an account on someone's first SSO login. + +Once you've added your Google Client ID to your Metabase settings, go to the Google Sign-In configuration page, and specify the email domain you want to allow. For example, if you work at WidgetCo you could enter "widgetco.com" in the field to let anyone with a company email sign up on their own. Note that Metabase accounts created with Google Sign-In do not have passwords and must use Google to sign in to Metabase. @@ -113,14 +115,13 @@ You can manage [user attributes][user-attributes-def] such as names, emails, and User attributes can't be synced with regular Google Sign-In. You'll need to set up [Google SAML][google-saml-docs] or [JWT][jwt-docs] instead. -## Changing an account's login method from email to SSO +## Disabling password logins -Once a person creates an account, you cannot change the authentication method for that account. However, you can: +{% include plans-blockquote.html feature="Disabling password logins" %} -- Deactivate password authentication for all users from **Admin settings** > **Authentication**. You'll need to ask people to sign in with Google (if they haven't already). -- Manually update the account's login method in the Metabase application database. This option is not recommended unless you're familiar with making changes to the application database. +On paid plans, you can require people to log in with SSO by disabling password authentication from **Admin settings** > **Authentication**. -Note that you must have at least one account with email and password login. This account safeguards you from getting locked out of your Metabase if there are any problems with your SSO provider. +**Avoid locking yourself out of your Metabase!** This setting will apply to all Metabase accounts, _including your Metabase admin account_. We recommend that you keep password authentication **enabled**. This will safeguard you from getting locked out of Metabase in case of any problems with SSO. ## Troubleshooting login issues diff --git a/docs/people-and-groups/managing.md b/docs/people-and-groups/managing.md index cc1178dd6a853..56200fdce0d30 100644 --- a/docs/people-and-groups/managing.md +++ b/docs/people-and-groups/managing.md @@ -6,15 +6,15 @@ redirect_from: # Managing people and groups -To start managing people, click on the **gear** icon > **Admin settings** > **People**. You'll see a list f all the people in your organization. +To start managing people, click on the **gear** icon > **Admin settings** > **People**. You'll see a list of all the people in your organization. ![Admin menu](images/AdminBar.png) ## Creating an account -To add a new person, click **Invite someone** in the upper right corner. You’ll be prompted to enter their first and last names and their email address. +To add a new person, click **Invite someone** in the upper right corner. You’ll be prompted to enter their email, and optionally their first and last names–only the email is required. -You can optionally add attributes to that user account, though you can add attributes to accounts at any time (as well as [via SSO](../people-and-groups/start.md#authentication)). Metabase uses attributes to create [data sandboxes](../permissions/data-sandboxes.md). +On some [paid plans](https://www.metabase.com/pricing), you can also add attributes to that user account. But you don't have to right away; you can add attributes to accounts at any time (as well as [via SSO](../people-and-groups/start.md#authentication)). Metabase uses attributes to create [data sandboxes](../permissions/data-sandboxes.md). Click **Create** to activate an account. An account becomes active once you click **Create**, even if the person never signs into the account. The account remains active until you [deactivate the account](#deactivating-an-account). If you're on a paid Metabase plan, all active accounts will count toward your user account total. If one person has more than one account, each account will count toward the total (see [how billing works](https://www.metabase.com/pricing/how-billing-works)). @@ -22,7 +22,7 @@ If you’ve already [configured Metabase to use email](../configuring-metabase/e ## Editing an account -You can edit someone's name and email address by clicking the three dots icon and choosing **Edit Details**. +You can edit someone's name and email address by clicking the three dots icon and choosing **Edit user**. > Be careful: changing an account's email address _will change the address the person will use to log in to Metabase_. diff --git a/docs/permissions/data.md b/docs/permissions/data.md index 9baed212c73e2..9d13147d617f2 100644 --- a/docs/permissions/data.md +++ b/docs/permissions/data.md @@ -30,7 +30,9 @@ You can click on any cell in the permissions table to change a group’s access ### Unrestricted access -Members of the group can create questions using the graphical query builder on data from all tables (within all namespaces/schemas, if your database uses those), including any tables that might get added to this database in the future. To grant a group the ability to write native/SQL questions, you must additionally set [Native query editing](#native-query-editing) to **Yes**. +Members of the group can create questions using the graphical query builder on data from all tables (within all namespaces/schemas, if your database uses those), including any tables that might get added to this database in the future. + +To grant a group the ability to write native/SQL questions or create [actions](../actions/start.md), you must additionally set [Native query editing](#native-query-editing) to **Yes**. ### Granular access @@ -80,9 +82,14 @@ Sandboxed access to a table can restrict access to columns and rows of a table. ## Native query editing -Members of a group with Native query editing set to Yes can write new SQL/native queries using the [native query editor](../questions/native-editor/writing-sql.md). This access level requires the group to additionally have Unrestricted data access for the database in question, since SQL queries can circumvent table-level permissions. +Members of a group with Native query editing set to "Yes" can: + +- Write new SQL/native queries using the [native query editor](../questions/native-editor/writing-sql.md). +- Create and edit [custom actions](../actions/custom.md). + +This access level requires the group to additionally have Unrestricted data access for the database in question, since SQL queries can circumvent table-level permissions. -People in a group without Native query editing permissions will still be able to view the results of questions created from SQL/native queries (though just the results, not the query), provided they 1) have collection access to the question, and 2) the question doesn't query a database that is [blocked](#block-access) for that group. +People in a group without Native query editing permissions will still be able to view the results of questions created from SQL/native queries (though just the results, not the query), or run an action, provided they 1) have collection access to the question or model, and 2) it doesn't query a database that is [blocked](#block-access) for that group. ## Download results @@ -114,10 +121,10 @@ The **Manage database** permission grants access to the settings page for a give On the database settings page, you can: - Edit any of the [connection options](../databases/connecting.md) for the data source, -- [sync schemas](../databases/connecting.md#manually-syncing-tables-and-columns), and -- [scan field values](../databases/connecting.md#manually-scanning-column-values). +- [sync schemas](../databases/sync-scan.md#manually-syncing-tables-and-columns), and +- [scan field values](../databases/sync-scan.md#manually-scanning-column-values). -Note that only admins can delete database connections in your Metabase, so people with **Manage database** permissions won't see the **Remove database** button. +Note that only admins can delete database connections in your Metabase, so people with **Manage database** permissions won't see the **Remove database** button. ## Further reading diff --git a/docs/permissions/images/change-permissions.png b/docs/permissions/images/change-permissions.png index 7cedf72442d15..3c9f263caba46 100644 Binary files a/docs/permissions/images/change-permissions.png and b/docs/permissions/images/change-permissions.png differ diff --git a/docs/permissions/notifications.md b/docs/permissions/notifications.md index 8ab00eb46b686..9a118ed9210da 100644 --- a/docs/permissions/notifications.md +++ b/docs/permissions/notifications.md @@ -4,15 +4,45 @@ title: Notification permissions # Notification permissions -Some notes on how permissions work with dashboard subscriptions and alerts: - -- **Recipients of notifications can see whatever the creator of the notification can see.** That is, people will get to see charts in their email or Slack _as if_ they had the alert or subscription creator's permissions to view those charts, _regardless of whether their groups have permission to view those charts_. -- **Anyone can create and manage their own notifications**. In addition to the alert and subscription menus on questions and dashboards, people can click on the **gear** icon and go to **Account settings** > **Notifications** to view and unsubscribe from any or all of their dashboard subscriptions and alerts. -- **Anyone can add people via email or Slack to a subscription or alert that they created**. Again, the data Metabase sends to the added recipients depends on the person who created the notification, not the recipient. -- **Some paid plans can restrict which domains Metabase can email.** See [approved domains](../configuring-metabase/settings.md#approved-domains-for-notifications). -- **Admins can see and edit all notifications.** Admins can modify recipients, filters, or delete the subscription without affecting the subscription's permissions; the subscription will continue to send data based on whoever originally created the subscription. Admins can edit alerts and subscriptions on the items themselves, or, if they have a paid plan, in the Admin panel under **Audit** > **Subscriptions and alerts**. See [Auditing Metabase](../usage-and-performance-tools/audit.md). -- **Non-admins can only view and edit notifications they created, not notifications created by others.** -- **Provided the non-admin account isn't sandboxed, non-admins can add anyone in their Metabase to their subscriptions using the dropdown menu.** People who are sandboxed will only see themselves in the list of recipients for dashboard subscriptions and alerts that they create; they won't be able to see other Metabase accounts. +Notifications in Metabase include [alerts](../questions/sharing/alerts.md) and [dashboard subscriptions](../dashboards/subscriptions.md#setting-up-a-dashboard-subscription). + +Notification **recipients** can see whatever the notification **creator** can see. For example, if: + +- Beau creates a subscription to a dashboard saved in their [personal collection](../exploration-and-organization/collections.md#your-personal-collection). +- Beau adds Anya to the dashboard subscription. +- Anya will see the dashboard in her email, even though she doesn't have permissions to view that dashboard in Beau's personal collection. + +## All accounts + +From [Account settings](../people-and-groups/account-settings.md), all accounts can: + +- Create [alerts](../questions/sharing/alerts.md) and [dashboard subscriptions](../dashboards/subscriptions.md#setting-up-a-dashboard-subscription). +- Add new recipients to alerts and subscriptions that they own. +- Unsubscribe from any alert or subscription. + +When a notification creator adds new recipients to an alert or subscription, Metabase will display data to the recipients using the **creator's** [data permissions](../permissions/data.md) and [collection permissions](../permissions/collections.md). + +## Sandboxed accounts + +Same as [all accounts](#all-accounts), but **people using sandboxed accounts will only see themselves in the list of recipients** when creating an alert or subscription. + +## Admins + +{% include plans-blockquote.html feature="Auditing tools" %} + +From Metabase's [auditing tools](../usage-and-performance-tools/audit.md#subscriptions-and-alerts), admins can: + +- View all subscriptions and alerts +- Add or remove recipients from an existing subscription or alert +- Delete subscriptions or alerts + +Admins can add recipients without changing the permissions of the alert or subscription. For example, if an admin adds Anya to a subscription created by Beau, Anya will receive emails with the same data that the Beau can see. + +## Restricting email domains + +{% include plans-blockquote.html feature="Approved domains for notifications" %} + +Admins can limit email recipients to people within an org by going to **Admin setting** > **General settings** > [Approved domains for notifications](../configuring-metabase/settings.md#approved-domains-for-notifications). ## Further reading diff --git a/docs/questions/images/VisualizeChoices.png b/docs/questions/images/VisualizeChoices.png index 8219c0b0cf2a5..4c06d04c60505 100644 Binary files a/docs/questions/images/VisualizeChoices.png and b/docs/questions/images/VisualizeChoices.png differ diff --git a/docs/questions/images/detail.png b/docs/questions/images/detail.png new file mode 100644 index 0000000000000..2e5aff237e5ed Binary files /dev/null and b/docs/questions/images/detail.png differ diff --git a/docs/questions/images/public-link-custom-destination.png b/docs/questions/images/public-link-custom-destination.png new file mode 100644 index 0000000000000..ee4b0bd8c92be Binary files /dev/null and b/docs/questions/images/public-link-custom-destination.png differ diff --git a/docs/questions/images/recipients.png b/docs/questions/images/recipients.png deleted file mode 100644 index 50d02b632ec7e..0000000000000 Binary files a/docs/questions/images/recipients.png and /dev/null differ diff --git a/docs/questions/images/trend-settings.png b/docs/questions/images/trend-settings.png index 91499b9f5cfd4..eed88e7c1c93f 100644 Binary files a/docs/questions/images/trend-settings.png and b/docs/questions/images/trend-settings.png differ diff --git a/docs/questions/images/viz-options.png b/docs/questions/images/viz-options.png new file mode 100644 index 0000000000000..b215f39efc4f1 Binary files /dev/null and b/docs/questions/images/viz-options.png differ diff --git a/docs/questions/images/waterfall-chart.png b/docs/questions/images/waterfall-chart.png index 2f84fe4f92674..25562fffcaf7c 100644 Binary files a/docs/questions/images/waterfall-chart.png and b/docs/questions/images/waterfall-chart.png differ diff --git a/docs/questions/native-editor/sql-snippets.md b/docs/questions/native-editor/sql-snippets.md index 4604ed8e7d07a..14cf8482ef317 100644 --- a/docs/questions/native-editor/sql-snippets.md +++ b/docs/questions/native-editor/sql-snippets.md @@ -95,7 +95,7 @@ Note: two snippets cannot share the same name, as even if a snippet is archived, Any user who has SQL editor permissions to at least one of your connected databases will be able to view the snippets sidebar, and will be able to create, edit, and archive or unarchive any and all snippets — even snippets intended to be used with databases the user lacks SQL editing access to. -Some plans contain additional functionality for organizing snippets into folders and setting permissions on those folders. See our [docs on SQL snippet folders and permissions](./sql-snippets.md). +Some plans contain additional functionality for organizing snippets into folders and setting permissions on those folders. See our [docs on SQL snippet folders and permissions](../../permissions/snippets.md). ## Learn more diff --git a/docs/questions/query-builder/expressions-list.md b/docs/questions/query-builder/expressions-list.md index 44f3328322063..37f421e2f7575 100644 --- a/docs/questions/query-builder/expressions-list.md +++ b/docs/questions/query-builder/expressions-list.md @@ -11,7 +11,7 @@ For an introduction to expressions, check out [Writing expressions in the notebo - [Aggregations](#aggregations) - [Average](#average) - [Count](#count) - - [CountIf](#countif) + - [CountIf](./expressions/countif.md) - [CumulativeCount](#cumulativecount) - [CumulativeSum](#cumulativesum) - [Distinct](#distinct) @@ -50,6 +50,7 @@ For an introduction to expressions, check out [Writing expressions in the notebo - [lower](#lower) - [minute](#minute) - [month](#month) + - [now](./expressions/now.md) - [power](#power) - [quarter](#quarter) - [regexextract](./expressions/regexextract.md) @@ -86,7 +87,7 @@ Syntax: `Count` Example: `Count` If a table or result returns 10 rows, `Count` will return `10`. -### CountIf +### [CountIf](./expressions/countif.md) Only counts rows where the condition is true. @@ -430,6 +431,12 @@ Syntax: `month([datetime column])`. Example: `month("2021-03-25T12:52:37")` would return the month as an integer, `3`. +### [now](./expressions/now.md) + +Returns the current date and time using your Metabase [report timezone](../../configuring-metabase/localization.md#report-timezone). + +Syntax: `now`. + ### power Raises a number to the power of the exponent value. diff --git a/docs/questions/query-builder/expressions/converttimezone.md b/docs/questions/query-builder/expressions/converttimezone.md index 18694a6af5ec8..36ffff4df341c 100644 --- a/docs/questions/query-builder/expressions/converttimezone.md +++ b/docs/questions/query-builder/expressions/converttimezone.md @@ -244,6 +244,4 @@ convertTimezone(convertTimezone([Source Time], "UTC"), "Canada/Eastern", "UTC") - [Custom expressions documentation](../expressions.md) - [Custom expressions tutorial](https://www.metabase.com/learn/questions/custom-expressions) -- [Time series comparisons](https://www.metabase.com/learn/questions/time-series-comparisons) -- [How to compare one time period to another](https://www.metabase.com/learn/dashboards/compare-times) -- [Working with dates in SQL](https://www.metabase.com/learn/sql-questions/dates-in-sql) +- [Time series analysis](https://www.metabase.com/learn/time-series/start) diff --git a/docs/questions/query-builder/expressions/countif.md b/docs/questions/query-builder/expressions/countif.md new file mode 100644 index 0000000000000..55a865132b951 --- /dev/null +++ b/docs/questions/query-builder/expressions/countif.md @@ -0,0 +1,285 @@ +--- +title: CountIf +--- + +# CountIf + +`CountIf` counts the total number of rows in a table that match a condition. `CountIf` counts every row, not just unique rows. + +Syntax: `CountIf(condition)`. + +Example: in the table below, `CountIf([Plan] = "Basic")` would return 3. + +| ID | Plan | +|-----|-------------| +| 1 | Basic | +| 2 | Basic | +| 3 | Basic | +| 4 | Business | +| 5 | Premium | + +> [Aggregations](../expressions-list.md#aggregations) like `CountIf` should be added to the query builder's [**Summarize** menu](../../query-builder/introduction.md#summarizing-and-grouping-by) > **Custom Expression** (scroll down in the menu if needed). + +## Parameters + +`CountIf` accepts a [function](../expressions-list.md#functions) or [conditional statement](../expressions.md#conditional-operators) that returns a boolean value (`true` or `false`). + +## Multiple conditions + +We'll use the following sample data to show you `CountIf` with [required](#required-conditions), [optional](#optional-conditions), and [mixed](#some-required-and-some-optional-conditions) conditions. + +| ID | Plan | Active Subscription | +|-----|-------------| --------------------| +| 1 | Basic | true | +| 2 | Basic | true | +| 3 | Basic | false | +| 4 | Business | false | +| 5 | Premium | true | + +### Required conditions + +To count the total number of rows in a table that match multiple required conditions, combine the conditions using the `AND` operator: + +``` +CountIf(([Plan] = "Basic" AND [Active Subscription] = true)) +``` + +This expression will return 2 on the sample data above (the total number of Basic plans that have an active subscription). + +### Optional conditions + +To count the total rows in a table that match multiple optional conditions, combine the conditions using the `OR` operator: + +``` +CountIf(([Plan] = "Basic" OR [Active Subscription] = true)) +``` + +Returns 4 on the sample data: there are three Basic plans, plus one Premium plan has an active subscription. + +### Some required and some optional conditions + +To combine required and optional conditions, group the conditions using parentheses: + +``` +CountIf(([Plan] = "Basic" OR [Plan] = "Business") AND [Active Subscription] = "false") +``` + +Returns 2 on the sample data: there are only two Basic or Business plans that lack an active subscription. + +> Tip: make it a habit to put parentheses around your `AND` and `OR` groups to avoid making required conditions optional (or vice versa). + +## Conditional counts by group + +In general, to get a conditional count for a category or group, such as the number of inactive subscriptions per plan, you'll: + +1. Write a `CountIf` expression with your conditions. +2. Add a [**Group by**](../../query-builder/introduction.md#summarizing-and-grouping-by) column in the query builder. + +Using the sample data: + +| ID | Plan | Active Subscription | +|-----|-------------| --------------------| +| 1 | Basic | true | +| 2 | Basic | true | +| 3 | Basic | false | +| 4 | Business | false | +| 5 | Premium | true | + +Count the total number of inactive subscriptions per plan: + +``` +CountIf([Active Subscription] = false) +``` + +Alternatively, if your **Active Subscription** column contains `null` (empty) values that represent inactive plans, you could use: + +``` +CountIf([Payment], [Plan] != true) +``` + +> The "not equal" operator `!=` should be written as !=. + +To view your conditional counts by plan, set the **Group by** column to "Plan". + +| Plan | Total Inactive Subscriptions | +|-----------|------------------------------| +| Basic | 1 | +| Business | 1 | +| Premium | 0 | + +> Tip: when sharing your work with other people, it's helpful to use the `OR` filter, even though the `!=` filter is shorter. The inclusive `OR` filter makes it easier to understand which categories (e.g., plans) are included in your conditional count. + +## Accepted data types + +| [Data type](https://www.metabase.com/learn/databases/data-types-overview#examples-of-data-types) | Works with `CountIf` | +| ------------------------------------------------------------------------------------------------ | ------------------------- | +| String | ❌ | +| Number | ❌ | +| Timestamp | ❌ | +| Boolean | ✅ | +| JSON | ❌ | + +`CountIf` accepts a [function](../expressions-list.md#functions) or [conditional statement](../expressions.md#conditional-operators) that returns a boolean value (`true` or `false`). + +## Related functions + +Different ways to do the same thing, because it's fun to try new things. + +**Metabase** +- [case](#case) +- [CumulativeCount](#cumulativecount) + +**Other tools** +- [SQL](#sql) +- [Spreadsheets](#spreadsheets) +- [Python](#python) + +### case + +You can combine [`Count`](../expressions-list.md#count) with [`case`](./case.md): + +``` +Count(case([Plan] = "Basic", [ID])) +``` + +to do the same thing as `CountIf`: + +``` +CountIf([Plan] = "Basic") +``` + +The `case` version lets you count a different column when the condition isn't met. For example, if you've got data from different sources: + +| ID: Source A | Plan: Source A | ID: Source B | Plan: Source B | +|---------------|----------------|---------------| ---------------------| +| 1 | Basic | | | +| | | B | basic | +| | | C | basic | +| 4 | Business | D | business | +| 5 | Premium | E | premium | + +To count the total number of Basic plans across both sources, you could create a `case` expression to: + +- Count the rows in "ID: Source A" where "Plan: Source A = "Basic" +- Count the rows in "ID: Source B" where "Plan: Source B = "basic" + +``` +Count(case([Plan: Source A] = "Basic", [ID: Source A], + case([Plan: Source B] = "basic", [ID: Source B]))) +``` + +### CumulativeCount + +`CountIf` doesn't do running counts. You'll need to combine [CumulativeCount](../expressions-list.md#cumulativecount) with [`case`](./case.md). + +If our sample data is a time series: + +| ID | Plan | Active Subscription | Created Date | +|-----|-------------| --------------------|------------------| +| 1 | Basic | true | October 1, 2020 | +| 2 | Basic | true | October 1, 2020 | +| 3 | Basic | false | October 1, 2020 | +| 4 | Business | false | November 1, 2020 | +| 5 | Premium | true | November 1, 2020 | + +And we want to get the running count of active plans like this: + +| Created Date: Month | Total Active Plans to Date | +|---------------------|----------------------------| +| October 2020 | 2 | +| November 2020 | 3 | + +Create an aggregation from **Summarize** > **Custom expression**: + +``` +CumulativeCount(case([Active Subscription] = true, [ID])) +``` + +You'll also need to set the **Group by** column to "Created Date: Month". + +### SQL + +When you run a question using the [query builder](https://www.metabase.com/glossary/query_builder), Metabase will convert your query builder settings (filters, summaries, etc.) into a SQL query, and run that query against your database to get your results. + +If our [sample data](#multiple-conditions) is stored in a PostgreSQL database, the SQL query: + +```sql +SELECT COUNT(CASE WHEN plan = "Basic" THEN id END) AS total_basic_plans +FROM accounts +``` + +is equivalent to the Metabase expression: + +``` +CountIf([Plan] = "Basic") +``` + +If you want to get [conditional counts broken out by group](#conditional-counts-by-group), the SQL query: + +```sql +SELECT + plan, + COUNT(CASE WHEN active_subscription = false THEN id END) AS total_inactive_subscriptions +FROM accounts +GROUP BY + plan +``` + +The `SELECT` part of the SQl query matches the Metabase expression: + +``` +CountIf([Active Subscription] = false) +``` + +The `GROUP BY` part of the SQL query matches a Metabase [**Group by**](../../query-builder/introduction.md#summarizing-and-grouping-by) set to the "Plan" column. + +### Spreadsheets + +If our [sample data](#multiple-conditions) is in a spreadsheet where "ID" is in column A, the spreadsheet formula: + +``` +=CountIf(B:B, "Basic") +``` + +produces the same result as the Metabase expression: + +``` +CountIf([Plan] = "Basic") +``` + +### Python + +If our [sample data](#multiple-conditions) is in a `pandas` dataframe column called `df`, the Python code: + +```python +len(df[df['Plan'] == "Basic"]) +``` + +uses the same logic as the Metabase expression: + +``` +CountIf([Plan] = "Basic") +``` + +To get a [conditional count with a grouping column](#conditional-counts-by-group): + +```python +## Add your conditions + + df_filtered = df[df['Active subscription'] == false] + +## Group by a column, and count the rows within each group + + len(df_filtered.groupby('Plan')) +``` + +The Python code above will produce the same result as the Metabase `CountIf` expression (with the [**Group by**](../../query-builder/introduction.md#summarizing-and-grouping-by) column set to "Plan"). + +``` +CountIf([Active Subscription] = false) +``` + +## Further reading + +- [Custom expressions documentation](../expressions.md) +- [Custom expressions tutorial](https://www.metabase.com/learn/questions/custom-expressions) diff --git a/docs/questions/query-builder/expressions/datetimeadd.md b/docs/questions/query-builder/expressions/datetimeadd.md index 6eecd449911d0..742f754990274 100644 --- a/docs/questions/query-builder/expressions/datetimeadd.md +++ b/docs/questions/query-builder/expressions/datetimeadd.md @@ -26,6 +26,7 @@ title: DatetimeAdd - "month" - "day" - "hour" +- "minute" - "second" - "millisecond" @@ -50,24 +51,27 @@ Here, **Finish By** is a custom column with the expression: datetimeAdd([Opened On], 14, 'day') ``` -## Comparing a date to a window of time +## Checking if the current datetime is within an interval -To check if a specific datetime falls between your start and end datetimes, use [`between`](../expressions-list.md#between). +Let's say you want to check if today's date falls between a start date and an [end date](#calculating-an-end-date). Assume "today" is December 1, 2022. -Unfortunately, Metabase doesn't currently support functions like `today`. If you want to check if today's date falls between **Opened On** and **Finish By** in the [Coffee example](#calculating-an-end-date): +| Coffee | Opened On | Finish By | Still Fresh Today | +| ---------------------- | ----------------- | ----------------- | ----------------- | +| DAK Honey Dude | October 31, 2022 | November 14, 2022 | No | +| NO6 Full City Espresso | November 7, 2022 | November 21, 2022 | No | +| Ghost Roaster Giakanja | November 27, 2022 | December 11, 2022 | Yes | + +**Finish By** is a custom column with the expression: -1. Ask your database admin if there's table in your database that stores dates for reporting (sometimes called a date dimension table). -2. Create a new question using the date dimension table, with a filter for "Today". -3. Turn the "Today" question into a [model](../../../data-modeling/models.md). -4. Create a [left join](../../query-builder/join.md) between **Coffee** and the "Today" model on `[Opened On] <= [Today]` and `[Finish By] >= [Today]`. +``` +datetimeAdd([Opened On], 14, 'day') +``` -The result should give you a **Today** column that's non-empty if today's date falls inside the coffee freshness window: +**Still Fresh Today** uses [case](../expressions/case.md) to check if the current date ([now](../expressions/now.md)) is [between](../expressions-list.md#between) the dates in **Opened On** and **Finish By**: -| Coffee | Opened On | Finish By | Today | -| ---------------------- | ----------------- | ----------------- | ----------------- | -| DAK Honey Dude | October 31, 2022 | November 14, 2022 | November 11, 2022 | -| NO6 Full City Espresso | November 7, 2022 | November 21, 2022 | November 11, 2022 | -| Ghost Roaster Giakanja | November 27, 2022 | December 11, 2022 | | +``` +case(between(now, [Opened On], [Finish By]), "Yes", "No") +``` ## Accepted data types @@ -79,7 +83,7 @@ The result should give you a **Today** column that's non-empty if today's date f | Boolean | ❌ | | JSON | ❌ | -We use "timestamp" and "datetime" to talk about any temporal data type that's supported by Metabase. +We use "timestamp" and "datetime" to talk about any temporal data type that's supported by Metabase. For more info about these data types in Metabase, see [Timezones](../../../configuring-metabase/timezones.md#data-types). If your timestamps are stored as strings or numbers in your database, an admin can [cast them to timestamps](../../../data-modeling/metadata-editing.md#casting-to-a-specific-data-type) from the Data Model page. @@ -166,6 +170,4 @@ datetimeAdd([Opened On], 14, "day") - [Custom expressions documentation](../expressions.md) - [Custom expressions tutorial](https://www.metabase.com/learn/questions/custom-expressions) -- [Time series comparisons](https://www.metabase.com/learn/questions/time-series-comparisons) -- [How to compare one time period to another](https://www.metabase.com/learn/dashboards/compare-times) -- [Working with dates in SQL](https://www.metabase.com/learn/sql-questions/dates-in-sql) +- [Time series analysis](https://www.metabase.com/learn/time-series/start) diff --git a/docs/questions/query-builder/expressions/datetimediff.md b/docs/questions/query-builder/expressions/datetimediff.md index a9574066af90f..4166c629ff102 100644 --- a/docs/questions/query-builder/expressions/datetimediff.md +++ b/docs/questions/query-builder/expressions/datetimediff.md @@ -24,8 +24,10 @@ title: DatetimeDiff - "year" - "quarter" - "month" +- "week" - "day" - "hour" +- "minute" - "second" - "millisecond" @@ -45,27 +47,16 @@ Let's say you're a cheesemaker, and you want to keep track of your ripening proc datetimeDiff([Aging Start], [Aging End], "month") ``` -## Calculating current age +To calculate the _current_ age of a cheese in months, you use [`now`](../expressions/now.md) as the second datetime parameter, like this: -Metabase doesn't currently support datetime functions like `today`. To calculate the _current_ age in our [cheese example](#calculating-age): - -1. Ask your database admin if there's table in your database that stores dates for reporting (sometimes called a date dimension table). -2. Create a new question using the date dimension table, with a filter for "Today". -3. Turn the "Today" question into a [model](../../../data-modeling/models.md). -4. Create a [left join](../../query-builder/join.md) between **Cheese** and the "Today" model on `[Aging Start] <= [Today]`. - -The result should give you a **Today** column that's non-empty if today's date is on or after the **Aging Start** date. - -| Cheese | Aging Start | Aging End | Mature Age (Months) | Today | Current Age (Months) | -| ------------- | ---------------- | ---------------- | ------------------- | ------------------ | -------------------- | -| Provolone | January 19, 2022 | March 17, 2022 | 1 | September 19, 2022 | 8 | -| Feta | January 25, 2022 | May 3, 2022 | 3 | September 19, 2022 | 7 | -| Monterey Jack | January 27, 2022 | October 11, 2022 | 8 | September 19, 2022 | 7 | +``` +datetimeDiff([Aging Start], now, "month") +``` -Then, you can calculate **Current Age (Months)** like this: +To calculate the current age of a cheese in days, you'd use: ``` -datetimeDiff([Aging Start], [Today], "month") +datetimeDiff([Aging Start], now, "day") ``` ## Accepted data types @@ -78,7 +69,7 @@ datetimeDiff([Aging Start], [Today], "month") | Boolean | ❌ | | JSON | ❌ | -We use "timestamp" and "datetime" to talk about any temporal data type that's supported by Metabase. +We use "timestamp" and "datetime" to talk about any temporal data type that's supported by Metabase. For more info about these data types in Metabase, see [Timezones](../../../configuring-metabase/timezones.md#data-types). If your timestamps are stored as strings or numbers in your database, an admin can [cast them to timestamps](../../../data-modeling/metadata-editing.md#casting-to-a-specific-data-type) from the Data Model page. @@ -150,6 +141,4 @@ datetimeDiff([Aging Start], [Aging End], "month") - [Custom expressions documentation](../expressions.md) - [Custom expressions tutorial](https://www.metabase.com/learn/questions/custom-expressions) -- [Time series comparisons](https://www.metabase.com/learn/questions/time-series-comparisons) -- [How to compare one time period to another](https://www.metabase.com/learn/dashboards/compare-times) -- [Working with dates in SQL](https://www.metabase.com/learn/sql-questions/dates-in-sql) +- [Time series analysis](https://www.metabase.com/learn/time-series/start) diff --git a/docs/questions/query-builder/expressions/datetimesubtract.md b/docs/questions/query-builder/expressions/datetimesubtract.md index 8ef899238711f..41e31a44aefb3 100644 --- a/docs/questions/query-builder/expressions/datetimesubtract.md +++ b/docs/questions/query-builder/expressions/datetimesubtract.md @@ -24,6 +24,7 @@ title: DatetimeSubtract - "month" - "day" - "hour" +- "minute" - "second" - "millisecond" @@ -47,24 +48,27 @@ Here, **Depart At** is a custom column with the expression: datetimeSubtract([Arrive By], 30, "minute") ``` -## Comparing a date to a window of time +## Checking if the current datetime is within an interval -To check if an existing datetime falls between your start and end datetimes, use [`between`](../expressions-list.md#between). +Say you want to check if the current datetime falls between a [start date](#calculating-a-start-date) and an end date. Assume the "current" datetime is November 12, 7:45 PM. -Unfortunately, Metabase doesn't currently support datetime functions like `today`. What if you want to check if today's date falls between **Arrive By** and **Depart At** in our [events example](#calculating-a-start-date)? +| Event | Arrive By | Depart At | On My Way | +|---------|----------------------------|-----------------------------|---------------| +| Drinks | November 12, 2022 6:30 PM | November 12, 2022 6:00 PM | No | +| Dinner | November 12, 2022 8:00 PM | November 12, 2022 7:30 PM | Yes | +| Dancing | November 13, 2022 12:00 AM | November 12, 2022 11:30 PM | No | -1. Ask your database admin if there's table in your database that stores datetimes for reporting (sometimes called a date dimension table). -2. Create a new question using the date dimension table, with a filter for "Today". -3. Turn the "Today" question into a [model](../../../data-modeling/models.md). -4. Create a [left join](../../query-builder/join.md) between **Events** and the "Today" model on `[Arrive By] <= [Today]` and `[Depart At] >= [Today]`. +**Depart At** is a custom column with the expression: -The result should give you an **Today** column that's non-empty for events that are happening while the night is still young: +``` +datetimeSubtract([Arrive By], 30, "minute") +``` -| Event | Arrive By | Depart At | Today | -|---------|----------------------------|-----------------------------|-----------------------------| -| Drinks | November 12, 2022 6:30 PM | November 12, 2022 6:00 PM | November 12, 2022 12:00 AM | -| Dinner | November 12, 2022 8:00 PM | November 12, 2022 7:30 PM | November 12, 2022 12:00 AM | -| Dancing | November 13, 2022 12:00 AM | November 12, 2022 11:30 PM | | +**On My Way** uses [case](../expressions/case.md) to check if the current datetime ([now](../expressions/now.md)) is [between](../expressions-list.md#between) the datetimes in **Arrive By** and **Depart At**: + +``` +case(between(now, [Depart At], [Arrive By]), "Yes", "No") +``` ## Accepted data types @@ -76,7 +80,7 @@ The result should give you an **Today** column that's non-empty for events that | Boolean | ❌ | | JSON | ❌ | -We use "timestamp" and "datetime" to talk about any temporal data type that's supported by Metabase. +We use "timestamp" and "datetime" to talk about any temporal data type that's supported by Metabase. For more info about these data types in Metabase, see [Timezones](../../../configuring-metabase/timezones.md#data-types). If your timestamps are stored as strings or numbers in your database, an admin can [cast them to timestamps](../../../data-modeling/metadata-editing.md#casting-to-a-specific-data-type) from the Data Model page. @@ -163,6 +167,4 @@ datetimeSubtract([Arrive By], 30, "minute") - [Custom expressions documentation](../expressions.md) - [Custom expressions tutorial](https://www.metabase.com/learn/questions/custom-expressions) -- [Time series comparisons](https://www.metabase.com/learn/questions/time-series-comparisons) -- [How to compare one time period to another](https://www.metabase.com/learn/dashboards/compare-times) -- [Working with dates in SQL](https://www.metabase.com/learn/sql-questions/dates-in-sql) \ No newline at end of file +- [Time series analysis](https://www.metabase.com/learn/time-series/start) diff --git a/docs/questions/query-builder/expressions/now.md b/docs/questions/query-builder/expressions/now.md new file mode 100644 index 0000000000000..d53908b893d51 --- /dev/null +++ b/docs/questions/query-builder/expressions/now.md @@ -0,0 +1,107 @@ +--- +title: Now +--- + +# Now + +`now` returns the current datetime using your Metabase [report timezone](../../../configuring-metabase/localization.md#report-timezone). + +## Creating conditional logic using the current date or time + +Let's say you have some project data, and you want to add a status column for each task. We'll assume today's date and time is November 22, 2022, 12:00:00. + +| Task | Start | Deadline | Status | +|----------|-----------------------------|-----------------------------|-----------------| +| Draft | November 1, 2022, 12:00:00 | November 30, 2022, 12:00:00 | In progress | +| Review | November 15, 2022, 12:00:00 | November 19, 2022, 12:00:00 | Needs extension | +| Edit | November 22, 2022, 12:00:00 | November 22, 2022, 12:00:00 | DUE RIGHT NOW! | + +To mark a task in progress, you'd use the expression: + +``` +now >= [Start] AND now < [Deadline] +``` + +To check if you need to ask for an extension: + +``` +now >= [Start] AND now >= [Deadline] +``` + +If you're looking for an adrenaline rush (and you have real-time data), you can flag the tasks that are due _right this second_: + +``` +now = [Deadline] +``` + +To set up the **Status** column that combines all three situations above, you'd wrap everything in a `case` expression: + +``` +case(now >= [Start] AND now < [Deadline], "In progress", + now >= [Start] AND now >= [Deadline], "Needs extension", + now = [Deadline], "DUE RIGHT NOW!") +``` + +## Data types + +| [Data type](https://www.metabase.com/learn/databases/data-types-overview#examples-of-data-types) | Returned by `now` | +| ----------------------- | -------------------- | +| String | ❌ | +| Number | ❌ | +| Timestamp | ✅ | +| Boolean | ❌ | +| JSON | ❌ | + +`now` returns a `timestamp with time zone` if time zones are supported by your database, otherwise `now` returns a `timestamp without time zone`. + +For more info about the way these data types behave in Metabase, see [Timezones](../../../configuring-metabase/timezones.md#data-types). + +## Limitations + +`now` might not actually be _now_ (in your local time) if you don't live in the same timezone as your Metabase [report time zone](../../../configuring-metabase/localization.md#report-timezone). + +If you need to compare `now` to a column in a different time zone, use [convertTimezone](./converttimezone.md) to shift both columns into the same time zone. For example: + +``` +convertTimezone(now, 'UTC', ) >= convertTimezone([Deadline], 'UTC', ) +``` + +## Related functions + +Different ways to do the same thing, because while you'd love to use custom expressions more, now's just not the time. + +- [SQL](#sql) +- [Spreadsheets](#spreadsheets) +- [Python](#python) + +### SQL + +When you run a question using the [query builder](https://www.metabase.com/glossary/query_builder), Metabase will convert your query builder settings (filters, summaries, etc.) into a SQL query, and run that query against your database to get your results. + +By default, `now` uses your Metabase's [report time zone](../../../configuring-metabase/localization.md#report-timezone). If your admin hasn't set a report time zone, `now` will use your database's time zone. + +Say you're using a Postgres database. If your Metabase report time zone is set to EST, you'll get `now` in EST: + +```sql +SELECT CURRENT_TIMESTAMP AT TIME ZONE 'EST' +``` + +If you don't have a report time zone, you'll get `now` in the Postgres database's time zone (typically UTC): + +```sql +SELECT CURRENT_TIME +``` + +### Spreadsheets + +The spreadsheet function `NOW()` gets the current date and time in your operating system's time zone (the time that's on your computer or mobile device). + +### Python + +You can use `pd.Timestamp.now()` using the `pandas` module. This will give you a `Timestamp` object with the current date and time in your operating system's time zone. + +## Further reading + +- [Custom expressions documentation](../expressions.md) +- [Custom expressions tutorial](https://www.metabase.com/learn/questions/custom-expressions) +- [Time series analysis](https://www.metabase.com/learn/time-series/start) diff --git a/docs/questions/query-builder/expressions/sumif.md b/docs/questions/query-builder/expressions/sumif.md index 93a24dd0d0aee..8378145ce7549 100644 --- a/docs/questions/query-builder/expressions/sumif.md +++ b/docs/questions/query-builder/expressions/sumif.md @@ -22,11 +22,13 @@ Example: in the table below, `SumIf([Payment], [Plan] = "Basic")` would return 2 ## Parameters -- `column` can be the name of a numeric column, or an expression that returns a numeric column. -- `condition` is an expression that returns a boolean value (`true` or `false`), like the expression `[Payment] > 100`. +- `column` can be the name of a numeric column, or a [function](../expressions-list.md#functions) that returns a numeric column. +- `condition` is a [function](../expressions-list.md#functions) or [conditional statement](../expressions.md#conditional-operators) that returns a boolean value (`true` or `false`), like the conditional statement `[Payment] > 100`. ## Multiple conditions +We'll use the following sample data to show you `SumIf` with [required](#required-conditions), [optional](#optional-conditions), and [mixed](#some-required-and-some-optional-conditions) conditions. + | Payment | Plan | Date Received | |----------|-------------| ------------------| | 100 | Basic | October 1, 2020 | @@ -35,15 +37,19 @@ Example: in the table below, `SumIf([Payment], [Plan] = "Basic")` would return 2 | 200 | Business | November 1, 2020 | | 400 | Premium | November 1, 2020 | -To sum a column based on multiple _mandatory_ conditions, combine the conditions using the `AND` operator: +### Required conditions + +To sum a column based on multiple required conditions, combine the conditions using the `AND` operator: ``` SumIf([Payment], ([Plan] = "Basic" AND month([Date Received]) = 10)) ``` -This expression would return 200 on the sample data above, as it sums all of the payments received for Basic Plans in October. +This expression would return 200 on the sample data above: the sum of all of the payments received for Basic Plans in October. -To sum a column with multiple _optional_ conditions, combine the conditions using the `OR` operator: +### Optional conditions + +To sum a column with multiple optional conditions, combine the conditions using the `OR` operator: ``` SumIf([Payment], ([Plan] = "Basic" OR [Plan] = "Business")) @@ -51,7 +57,9 @@ SumIf([Payment], ([Plan] = "Basic" OR [Plan] = "Business")) Returns 600 on the sample data. -To combine mandatory and optional conditions, group the conditions using parentheses: +### Some required and some optional conditions + +To combine required and optional conditions, group the conditions using parentheses: ``` SumIf([Payment], ([Plan] = "Basic" OR [Plan] = "Business") AND month([Date Received]) = 10) @@ -59,7 +67,7 @@ SumIf([Payment], ([Plan] = "Basic" OR [Plan] = "Business") AND month([Date Recei Returns 400 on the sample data. -> Tip: make it a habit to put parentheses around your `AND` and `OR` groups to avoid making mandatory conditions optional (or vice versa). +> Tip: make it a habit to put parentheses around your `AND` and `OR` groups to avoid making required conditions optional (or vice versa). ## Conditional subtotals by group @@ -85,10 +93,10 @@ SumIf([Payment], [Plan] = "Business" OR [Plan] = "Premium") Or, sum payments for all plans that aren't "Basic": ``` -{% raw %}SumIf([Payment], [Plan] != "Basic"){% endraw %} +SumIf([Payment], [Plan] != "Basic") ``` -> The "not equal" operator `!=` should be written as "!=". +> The "not equal" operator `!=` should be written as !=. To view those payments by month, set the **Group by** column to "Date Received: Month". @@ -106,11 +114,15 @@ To view those payments by month, set the **Group by** column to "Date Received: | String | ❌ | | Number | ✅ | | Timestamp | ❌ | -| Boolean | ❌ | +| Boolean | ✅ | | JSON | ❌ | +See [parameters](#parameters). + ## Related functions +Different ways to do the same thing, because CSV files still make up 40% of the world's data. + **Metabase** - [case](#case) - [CumulativeSum](#cumulativesum) @@ -122,13 +134,13 @@ To view those payments by month, set the **Group by** column to "Date Received: ### case -You can combine the `Sum` and [`case`](./case.md) formulas +You can combine [`Sum`](../expressions-list.md#sum) and [`case`](./case.md): ``` Sum(case([Plan] = "Basic", [Payment])) ``` -to do the same thing as the `SumIf` formula: +to do the same thing as `SumIf`: ``` SumIf([Payment], [Plan] = "Basic") @@ -164,9 +176,9 @@ Don't forget to set the **Group by** column to "Date Received: Month". ### SQL -When you run a question using the [query builder](https://www.metabase.com/glossary/query_builder), Metabase will convert your graphical query settings (filters, summaries, etc.) into a query, and run that query against your database to get your results. +When you run a question using the [query builder](https://www.metabase.com/glossary/query_builder), Metabase will convert your query builder settings (filters, summaries, etc.) into a SQL query, and run that query against your database to get your results. -If our [payment sample data](#sumif) is stored in a PostgreSQL database: +If our [payment sample data](#sumif) is stored in a PostgreSQL database, the SQL query: ```sql SELECT @@ -174,13 +186,13 @@ SELECT FROM invoices ``` -is equivalent to the Metabase `SumIf` expression: +is equivalent to the Metabase expression: ``` SumIf([Payment], [Plan] = "Basic") ``` -To add [multiple conditions with a grouping column](#conditional-subtotals-by-group): +To add [multiple conditions with a grouping column](#conditional-subtotals-by-group), use the SQL query: ```sql SELECT @@ -191,23 +203,23 @@ GROUP BY DATE_TRUNC("month", date_received) ``` -The SQL `SELECT` statement matches the Metabase `SumIf` expression: +The `SELECT` part of the SQl query matches the Metabase `SumIf` expression: ``` SumIf([Payment], [Plan] = "Business" OR [Plan] = "Premium") ``` -The SQL `GROUP BY` statement maps to a Metabase [**Group by**](../../query-builder/introduction.md#summarizing-and-grouping-by) column set to "Date Received: Month". +The `GROUP BY` part of the SQL query maps to a Metabase [**Group by**](../../query-builder/introduction.md#summarizing-and-grouping-by) column set to "Date Received: Month". ### Spreadsheets -If our [payment sample data](#sumif) is in a spreadsheet where "Payment" is in column A and "Date Received" is in column B: +If our [payment sample data](#sumif) is in a spreadsheet where "Payment" is in column A and "Date Received" is in column B, the spreadsheet formula: ``` =SUMIF(B:B, "Basic", A:A) ``` -produces the same result as: +produces the same result as the Metabase expression: ``` SumIf([Payment], [Plan] = "Basic") @@ -217,13 +229,13 @@ To add additional conditions, you'll need to switch to a spreadsheet **array for ### Python -If our [payment sample data](#sumif) is in a `pandas` dataframe column called `df`: +If our [payment sample data](#sumif) is in a `pandas` dataframe column called `df`, the Python code: ```python df.loc[df['Plan'] == "Basic", 'Payment'].sum() ``` -is equivalent to +is equivalent to the Metabase expression: ``` SumIf([Payment], [Plan] = "Basic") diff --git a/docs/questions/query-builder/introduction.md b/docs/questions/query-builder/introduction.md index f0628e1c5b8ec..938025ea0e219 100644 --- a/docs/questions/query-builder/introduction.md +++ b/docs/questions/query-builder/introduction.md @@ -49,7 +49,7 @@ To the right of completed step is a **Preview** button (looks like a Play button ## Picking data -The data section is where you select the data you want to work with. Here you'll pick a [model][model], a table from a database, or a saved question. You can click on a table to select which columns you want to include in your results. +The data section is where you select the data you want to work with. Here you'll pick a [model](../../data-modeling/models.md), a table from a database, or a saved question. You can click on a table to select which columns you want to include in your results. ## Joining data @@ -85,7 +85,7 @@ When viewing a table or chart, clicking on the **Filter** will bring up the filt ![Bulk filter modal](../images/bulk-filter-modal.png) -Here you can add multiple filters to your question in one go. Filter options will differ depending on the [field type](../../data-modeling/field-types.md). Any tables linked by foreign keys will be displayed in the left tab of the modal. When you're done adding filters, hit **Apply filters** to rerun the query and update its results. To remove all the filters you've applied, click on **Clear all filters** in the bottom right of the filter modal. Any filters you apply here will show up in the notebook editor, and vice versa. +Here you can add multiple filters to your question in one go. Filter options will differ depending on the [field type](../../data-modeling/field-types.md). Any tables linked by foreign keys will be displayed in the left tab of the modal. When you're done adding filters, hit **Apply filters** to rerun the query and update its results. To remove all the filters you've applied, click on **Clear all filters** in the bottom left of the filter modal. Any filters you apply here will show up in the notebook editor, and vice versa. ### Filtering by date @@ -217,7 +217,7 @@ Each time you start modifying a saved question, Metabase will create a new quest Feel free to play around with any saved question, as you won't have any effect on the existing question. When you hit **Save** on the question, you can choose either to save as a new question (the default), or you can overwrite the existing question you started from. -If you find yourself using the same saved question as a starting point for multiple questions, you may want to turn it into a [Model][model] to let others know it's a good starting place. +If you find yourself using the same saved question as a starting point for multiple questions, you may want to turn it into a [model](../../data-modeling/models.md) to let others know it's a good starting place. ## Question version history diff --git a/docs/questions/sharing/alerts.md b/docs/questions/sharing/alerts.md index 9fb48cba846d8..996e1c6546fb0 100644 --- a/docs/questions/sharing/alerts.md +++ b/docs/questions/sharing/alerts.md @@ -6,29 +6,31 @@ redirect_from: # Alerts -Whether you're keeping track of revenue, users, or negative reviews, there are often times when you want to be alerted about something. Metabase has a few different kinds of alerts you can set up, and you can choose to be notified via email or Slack. +Set up an alert on a question to send the results of questions to people via email or Slack. -## Getting alerts +## Prerequisite for alerts -To start using alerts, someone on your team who's an administrator will need to make sure that [email integration](../../configuring-metabase/email.md) or Slack is set up first. +To start using alerts, an administrator will need to make sure that either [email](../../configuring-metabase/email.md) or [Slack](../../configuring-metabase/slack.md) is set up for your Metabase. + +## Setting up an alert + +Go to a question and click on the **bell** icon in the bottom right of the screen. ## Types of alerts There are three kinds of things you can get alerted about in Metabase: -1. When a time series crosses a goal line. -2. When a progress bar reaches or goes below its goal. -3. When any other kind of question returns a result. - -We'll go through these one by one. +- [Goal line alerts](#goal-line-alerts) when a time series crosses a goal line. +- [Progress bar alerts](#progress-bar-alerts): when a progress bar reaches or goes below its goal. +- [Results alerts](#results-alerts): when a question returns any result. ## Goal line alerts -This kind of alert is useful when you're doing things like tracking daily active users and you want to know when you reach a certain number of them, or when you're tracking orders per week and you want to know whenever that number ever goes below a certain threshold. +Goal line alerts are useful when you're doing things like tracking daily active users and you want to know when you reach a certain number of DAU, or when you're tracking orders per week and you want to know whenever the number of orders ever goes below a certain threshold. -To start, you'll need a line, area, or bar chart displaying a number over time. (If you need help with that, check out the page on [asking questions](../query-builder/introduction.md).) +To start, you'll need a line, area, or bar chart displaying a number over time. -Now we need to set up a goal line. To do that, open up the visualization settings by clicking the Settings button in the bottom-left. Then click on the Display tab, and turn on the "Show goal" setting. Choose a value for your goal and click Done. +Next, you need to set up a goal line on your chart. Open up the visualization settings by clicking the **gear** icon in the bottom-left. Then click on the **Display** tab, and turn on the **Show goal** setting. Choose a value for your goal (and optionally a label) and click Done. Save your question, then click on the bell icon in the bottom-right of the screen. @@ -60,31 +62,36 @@ You probably don't want to be alerted about all the bad reviews you've _ever_ go Save the question, the click on the bell icon in the bottom-right of the screen, and select how often you want Metabase to check this question for results. That's it! -## Adding additional recipients to your alerts +## Editing and deleting alerts -If you're an administrator of your Metabase instance, you'll be able to see and edit every alert on all saved questions. You'll also see some additional options to add recipients to alerts, which look like this: +Admins get special privileges with alerts. -![Recipients](../images/recipients.png) +### Admins -You can add any Metabase user, email address, or even a Slack channel as a recipient of an alert. Admins can add or remove recipients on any alert, even ones that they did not create themselves. +- Admins can edit and delete any alert. This can't be undone, so be careful! +- Admins can add or remove recipients on any alert, even ones that they did not create themselves. +- Admins on some [paid plans](https://www.metabase.com/pricing) can view, edit, and delete all dashboard subscriptions and alerts in the [Audit tab](../../usage-and-performance-tools/audit.md#subscriptions-and-alerts). -Here's more information about [setting up email integration](../../configuring-metabase/email.md) and [setting up Slack integration](../../configuring-metabase/slack.md). +### Everyone -## Stopping alerts +- Everyone can edit alerts that they've set up (but not alerts set up by other people). +- Everyone can add any Metabase user account, email address, or even a Slack channel as a recipient of an alert that they created (but not alerts created by others). +- Everyone can view and unsubscribe from all alerts they receive by clicking on the **gear** icon in the upper right and navigating to **Account settings** > **Notifications**. -There are a few ways alerts can be stopped: +## Alert expiration -- Non-admins can unsubscribe from any alert that they're a recipient of. -- Admins can edit and delete any alert. This can't be undone, so be careful! -- Admins on some paid plans can view, edit, and delete all dashboard subscriptions and alerts in the [Audit tab](../../usage-and-performance-tools/audit.md#subscriptions-and-alerts). -- If a saved question that has an alert gets edited in such a way that the alert doesn't make sense anymore, the alert will get deleted. For example, if a saved question with a goal line alert on it gets edited, and the goal line is removed entirely, that alert will get deleted. -- If a question gets archived, any alerts on it will be deleted. +Some circumstances will automatically delete alerts: + +- If a saved question that has an alert gets edited in such a way that the alert doesn't make sense anymore, the alert will get deleted. For example, if a saved question with a goal line alert on it gets edited, and the goal line is removed entirely, Metabase will delete the alert. +- If a question gets archived, Metabase will delete any alerts set up for that question. + +Alerts will continue to work even if the person who set up the alert no longer has an active account. For example, if an alert with multiple recipients (or to a Slack channel) was set up by someone whose account has since been deactivated, that alert will continue to work (though Metabase will stop sending the alerts to the deactivated account). -## Viewing existing alerts +## Admins can audit alerts {% include plans-blockquote.html feature="Audit logs" %} -To view a list of all alerts and dashboard subscriptions that people have set up in your Metabase, click on the **gear** icon in the upper right and select **Admin settings** > **Audit** > **Subscriptions & Alerts**. See [Audit](../../usage-and-performance-tools/audit.md#subscriptions-and-alerts). +Admins can view a list of all alerts and dashboard subscriptions that people have set up in your Metabase. Click on the **gear** icon in the upper right and select **Admin settings** > **Audit** > **Subscriptions & Alerts**. See [Audit](../../usage-and-performance-tools/audit.md#subscriptions-and-alerts). ## How permissions work with alerts @@ -93,7 +100,6 @@ See [Notification permissions](../../permissions/notifications.md). ## Further reading - [Dashboard subscriptions](../../dashboards/subscriptions.md) -- [Notification permissions](../../permissions/notifications.md) - [Setting up email](../../configuring-metabase/email.md) - [Setting up Slack](../../configuring-metabase/slack.md) - [Auditing Metabase](../../usage-and-performance-tools/audit.md) diff --git a/docs/questions/sharing/answers.md b/docs/questions/sharing/answers.md index 46b4f1dd2b276..2f1a2b10ecdce 100644 --- a/docs/questions/sharing/answers.md +++ b/docs/questions/sharing/answers.md @@ -12,39 +12,69 @@ Whenever you’ve arrived at an answer that you want to save for later, click th ![Save button](../images/save-button.png) -A modal will appear, prompting you to give your question a name and description, and to pick which [collection][collections] to save it in. Note that your administrator might have set things up so that you're only allowed to [save questions in certain collection][collection-permissions], but you can always save things in your Personal Collection. After saving your question, you'll be asked if you want to add it to a new or existing dashboard. +A modal will appear, prompting you to give your question a name and description, and to pick which [collection](../../exploration-and-organization/collections.md) to save the question in. Note that your administrator might have set things up so that you're only allowed to [save questions in certain collection](../../permissions/collections.md), but you can always save items in your Personal Collection. After saving your question, you'll be asked if you want to add the question to a new or existing dashboard. Now, whenever you want to refer to your question again you can find it by searching for it in the search bar at the top of Metabase, or by navigating to the collection where you saved it. -You can also convert a question to a [model][model]. +## Downloading your question's results -## Downloading Your Results +You can export the results of a question by clicking on the **download arrow** (a down arrow in a cloud) in the lower right of the screen, or from a chart on a dashboard by clicking on the **three dot** (...) menu in the upper right or a dashboard card. -You can export the results of a question by clicking on the __Download arrow__ (a down arrow in a cloud) in the lower right of the screen. Results can be downloaded into .csv, .xlsx, or .json files. The maximum download size is 1 million rows. Exported .xlsx files preserve the formatting defined in the question: date and currency formats are kept throughout, as well as column ordering and visibility. Files names for the exported question will include a slug of the question title, so you can easily distinguish files when exporting multiple questions. +Results can be downloaded as: + +- .csv +- .xlsx +- .json + +The maximum download size is 1 million rows. Exported .xlsx files preserve the formatting defined in the question: date and currency formats are kept throughout, as well as column ordering and visibility. Files names for the exported question will include a slug of the question title, so you can easily distinguish files when exporting multiple questions. + +## Exporting charts as images + +You can download most charts (excluding table and number charts) as images in .png format. + +- From a question: click on the **download arrow** (a down arrow in a cloud in the bottom right) and select .png. +- From a dashboard card: click on the **three dot** (...) menu in the upper right of the card and select .png. + +You can't download the image of a dashboard, but you can set up a [dashboard subscription](../../dashboards/subscriptions.md). ## Editing your question -Once you save your question, a down arrow will appear to the right of the question's title. Clicking on the down arrow will bring up the **Question detail sidebar**, which gives you some options: +Click into the question's title to edit the name of your question. + +Open the **three dot** (...) menu to: -![Question detail sidebar](../images/question-details-sidebar.png) +- [Verify](../../exploration-and-organization/exploration.md#verified-items) this question +- Add to [dashboard](../../dashboards/start.md) +- Move to another [collection](../../exploration-and-organization/collections.md) +- Turn into a [model](../../data-modeling/models.md) +- Duplicate +- [Archive](../../exploration-and-organization/history.md) -- **Edit details** (Pencil icon). Change the title of the question, and add some description for context. Adding a description will also make the question easier to find using the search bar. You can also select more options to [cache the results of the question](#caching-results). -- **Add to dashbboard** (Dashboard icon with plus symbol). See [dashboards][dashboards]. -- **Move** (Document icon with right arrow). Relocate the question to a different [collection][collections]. -- **Turn this into a model**. See [Models][model]. -- **Duplicate** (Square with little square). Create a copy of the question. Keep in mind that whenever you start editing a saved question, Metabase will create a copy of the question. You can either save your edits as a new question, or overwrite the original saved question. -- **Archive** (Folder with down arrow). See [Archiving items][archiving-items]. -- **Bookmark** Save the question as a favorite, which will show up in the bookmarks section of your navigation sidebar. See [Bookmarks](../../exploration-and-organization/exploration.md#bookmarks). +Click the **info** icon to: -## Caching results +- Add a description +- Edit the [cache duration](../../configuring-metabase/caching.md#caching-per-question)\* +- View [revision history](../../exploration-and-organization/history.md) -{% include plans-blockquote.html feature="Question-specific caching" %} +\* Available on [paid plans](https://www.metabase.com/pricing/). -If your results don't change frequently, you may want to cache your results, that is: store your results in Metabase so that the next time you visit the question, Metabase can retrieve the stored results rather than query the database again. For example, if your data only updates once a day, there's no point in querying the database more than once a day, as they data won't have changed. Returning cached results can be significantly faster, as the database won't have to redo the work to answer your query. +## Bookmark a question -Administrators can set global caching controls, but if you're using a paid version of Metabase you can set caching per question. To cache results, click on the down arrow next to the question's title to open the __Question detail sidebar__, then click on the __Pencil icon__ to __Edit details__. In the Modal that pops up, in the bottom left, select __More options__. There you'll be able to tell Metabase how long it should cache the question's results. This caching will only apply to this specific question; admins can [configure database-wide caching settings][caching] in the __Admin panel__. +Click the **bookmark** icon to pin a question to your Metabase sidebar. See [Bookmarks](../../exploration-and-organization/exploration.md#bookmarks). -Admins can still set global caching, but setting a cache duration on a specific question will override that global setting–useful for when a particular question has a different natural cadence. +## Building on saved questions + +To use a saved question as the basis for another question, you can: + +- Open the **three dot** (...) menu > **Turn into a [model](../../data-modeling/models.md)**. +- [Create a new question](../query-builder/introduction.md#creating-a-new-question-with-the-query-builder) and search for your saved question under **Pick your starting data**. +- [Refer to the question in a SQL query](../native-editor/referencing-saved-questions-in-queries.md). + +## Caching question results + +{% include plans-blockquote.html feature="Caching question results" %} + +See [Caching per question](../../configuring-metabase/caching.md#caching-per-question). ## Sharing questions with public links @@ -56,12 +86,4 @@ To share a question, click on the arrow pointing up and to the right in the bott ## Setting up alerts -You can set up questions to run periodically and notify you if the results are interesting. Check out [Alerts][alerts]. - -[alerts]: ./alerts.md -[archiving-items]: ../../exploration-and-organization/history.md#archiving-items -[caching]: ../../configuring-metabase/caching.md -[collections]: ../../exploration-and-organization/collections.md -[collection-permissions]: ../../permissions/collections.md -[dashboards]: ../../dashboards/start.md -[model]: ../../data-modeling/models.md +You can set up questions to run periodically and notify you if the results are interesting. Check out [Alerts](./alerts.md). diff --git a/docs/questions/sharing/public-links.md b/docs/questions/sharing/public-links.md index 56e5f37264b4c..9f6997cc2fe14 100644 --- a/docs/questions/sharing/public-links.md +++ b/docs/questions/sharing/public-links.md @@ -34,9 +34,7 @@ For more information about the option to **Embed this item in an application**, Once you've [enabled sharing on your question or dashboard](#enable-sharing-on-your-saved-question-or-dashboard), you can copy and share the public link URL with whomever you please. The public link URL will display static (view-only) results of your question or dashboard, so visitors won't be able to drill-down into the underlying data on their own. -If you want to create a drill-down pathway on your question or dashboard, you can set up a [custom destination](../../dashboards/interactive.md) that goes to the public link of another question or dashboard. - -### Public link to export question results in CSV, XLSX, JSON +## Public link to export question results in CSV, XLSX, JSON The export option is only available for questions, not dashboards. @@ -49,6 +47,26 @@ To create a public link that people can use to download the results of a questio ![Public export](../images/public-export.png) +## Simulating drill-through with public links + +Metabase's automatic [drill-through](https://www.metabase.com/learn/questions/drill-through) won't work on public dashboards because public links don't give people access to your raw data. + +You can simulate drill-through on a public dashboard by setting up a [custom click behaviour](../../dashboards/interactive.md) that sends people from one public link to another public link. + +1. Create a second dashboard to act as the destination dashboard. +2. [Enable sharing](#enable-sharing-on-your-saved-question-or-dashboard) on the destination dashboard. +3. Copy the destination dashboard's public link. +4. On your primary dashboard, create a [custom destination](../../dashboards/interactive.md#custom-destinations) with type "URL". +5. Set the custom destination to the destination dashboard's public link. +6. Optional: pass a filter value from the primary dashboard to the destination dashboard by adding a query parameter to the end of the destination URL: + ``` + /public/dashboard/?child_filter_name={%raw%}{{parent_column_name}}{%endraw%} + ``` + +For example, if you have a primary public dashboard that displays **Invoices** data, you can pass the **Plan** name (on click) to a destination public dashboard that displays **Accounts** data: + +![Public link with custom destination](../images/public-link-custom-destination.png) + ## Public embeds If you want to embed your question or dashboard in a simple web page or blog post: diff --git a/docs/questions/sharing/visualizations/combo-chart.md b/docs/questions/sharing/visualizations/combo-chart.md new file mode 100644 index 0000000000000..f9bd89546c4ad --- /dev/null +++ b/docs/questions/sharing/visualizations/combo-chart.md @@ -0,0 +1,21 @@ +--- +title: Combo charts +--- + +# Combo charts + +Combo charts let you combine bars and lines (or areas) on the same chart. + +![Line + bar](../../images/combo-chart.png) + +Metabase will pick one of your series to display as a line, and another to display as a bar by default. Open up the visualization settings to change which series are lines, bars, or areas, as well as to change per-series settings like colors. Click the down arrow icon on the right of a series to see additional options: + +![Line + bar](../../images/combo-chart-settings.png) + +To use a Combo chart you'll either need to have two or more metrics selected in the Summarize By section of your question, with one or two grouping columns, like this: + +![Data for Line + Bar chart](../../images/combo-chart-data-1.png) + +Or you'll need a question with a single metric and two grouping columns, like this: + +![Data for Line + Bar chart](../../images/combo-chart-data-2.png) \ No newline at end of file diff --git a/docs/questions/sharing/visualizations/detail.md b/docs/questions/sharing/visualizations/detail.md new file mode 100644 index 0000000000000..3914fdbc75564 --- /dev/null +++ b/docs/questions/sharing/visualizations/detail.md @@ -0,0 +1,12 @@ +--- +title: Detail +--- + +# Detail + +The **Detail** visualization shows a single result record (row) in an easy-to-read, two-column display. + +![Detail of a record in the account table](../../images/detail.png) + +You can cycle through records using the arrow buttons. + diff --git a/docs/questions/sharing/visualizations/funnel.md b/docs/questions/sharing/visualizations/funnel.md new file mode 100644 index 0000000000000..c61d8b8ad84e2 --- /dev/null +++ b/docs/questions/sharing/visualizations/funnel.md @@ -0,0 +1,11 @@ +--- +title: Funnel charts +--- + +# Funnel charts + +**Funnels** are commonly used in e-commerce or sales to visualize how many customers are present within each step of a checkout flow or sales cycle. At their most general, funnels show you values broken out by steps, and the percent decrease between each successive step. To create a funnel in Metabase, you'll need to have a table with at least two columns: one column that contains the metric you're interested in, and another that contains the funnel's steps. + +For example, I might have an Opportunities table, and I could create a question that gives me the number of sales leads broken out by a field that contains stages such as `Prospecting`, `Qualification`, `Proposal`, `Negotiation`, and `Closed`. In this example, the percentages shown along the x-axis tell you what percent of the total starting opportunities are still present at each subsequent step; so 18.89% of our total opportunities have made it all the way to being closed deals. The number below each percent is the actual value of the count at that step — in our example, the actual number of opportunities that are currently at each step. Together, these numbers help you figure out where you're losing your customers or users. + +![Funnel](../../images/funnel.png) \ No newline at end of file diff --git a/docs/questions/sharing/visualizations/gauge.md b/docs/questions/sharing/visualizations/gauge.md new file mode 100644 index 0000000000000..5edef92471168 --- /dev/null +++ b/docs/questions/sharing/visualizations/gauge.md @@ -0,0 +1,13 @@ +--- +title: Gauge chart +--- + +# Gauge chart + +Ah, **gauges**: you either love 'em or you hate 'em. …Or you feel "meh" about them, I guess. Whatever the case, gauges allow you to show a single number in the context of a set of colored ranges that you can specify. By default, when you choose the Gauge visualization, Metabase will create red, yellow, and green ranges for you. + +![Gauge](../../images/gauge.png) + +Open up the visualization settings to define your own ranges, choose colors for them, and optionally add labels to some or all of your ranges: + +![Gauge settings](../../images/gauge-settings.png) \ No newline at end of file diff --git a/docs/questions/sharing/visualizations/line-bar-and-area-charts.md b/docs/questions/sharing/visualizations/line-bar-and-area-charts.md new file mode 100644 index 0000000000000..3270cb61488e0 --- /dev/null +++ b/docs/questions/sharing/visualizations/line-bar-and-area-charts.md @@ -0,0 +1,82 @@ +--- +title: Line, bar, and area charts +--- + +# Line, bar, and area charts + +They're pretty useful. + +## Line charts + +**Line charts** are best for displaying the trend of a number over time, especially when you have lots of x-axis values. For more, check out our [Guide to line charts](https://www.metabase.com/learn/basics/visualizing-data/line-charts.html) and [Time series analysis](https://www.metabase.com/learn/time-series) tutorials. + +![Trend lines](../../images/trend-lines.png) + +## Bar charts + +![Bar chart](../../images/bar.png) + +If you're trying to group a number by a column that has a lot of possible values, like a Vendor or Product Title field, try visualizing it as a **row chart**. Metabase will show you the bars in descending order of size, with a final bar at the bottom for items that didn't fit. + +![Row chart](../../images/row.png) + +If you have a bar chart like Count of Users by Age, where the x-axis is a number, you'll get a special kind of chart called a **[histogram](https://www.metabase.com/learn/basics/visualizing-data/histograms.html)**, where each bar represents a range of values (called a "bin"). Note that Metabase will automatically bin your results any time you use a number as a grouping, even if you aren't viewing a bar chart. Questions that use latitude and longitude will also get binned automatically. + +## Histograms + +![Histogram](../../images/histogram.png) + +By default, Metabase will automatically choose a good way to bin your results. But you can change how many bins your result has, or turn the binning off entirely, by clicking on the area to the right of the column you're grouping by: + +![Binning options](../../images/histogram-bins.png) + +## Area charts + +**Area charts** are useful when comparing the proportions of two metrics over time. Both bar and area charts can be stacked. + +![Stacked area chart](../../images/area.png) + + + +## Options for line, bar, and area charts + +These three charting types have very similar options, which are broken up into the following: + +### Trend lines + +**Trend lines** are another useful option for line, area, bar, and scatter charts. If you have a question where you're grouping by a time field, open up the visualization settings and turn the **Show trend line** toggle on to display a trend line. Metabase will choose the best type of line to fit to the trend of your series. Trend lines will even work if you have multiple metrics selected in your summary. But trend lines won't work if you have any groupings beyond the one time field. + +![Trend lines](../../images/trend-lines.png) + +### Data + +Here's where you can choose the columns you want to plot on your x and y axes. This is mostly useful if your table or result set contains more than two columns, like if you're trying to graph fields from an unaggregated table. You can also add additional metrics to your chart by clicking the **Add another series** link below the y-axis dropdown, or break your current metric out by an additional dimension by clicking the **Add a series breakout** link below the x-axis dropdown (note that you can't add an additional series breakout if you have more than one metric/series). + +### Display + +There's quite a bit you can do in this tab, but the options available will depend on the data in your chart. + +- **Set the colors and labels** for the series on your chart. +- **Change the style of your lines** for Line and Area charts, and choose whether to display dots on the lines. +- **Specify how to handle missing values**. Use the "Replace missing values with…" setting to change how your chart deals with missing values. You can use linear interpolation, or display those points as zero or as nothing. +- **Add a goal line**. Goal lines can be used in conjunction with [alerts](../alerts.md) to send an email or a Slack message when your metric cross this line. +- **Add a trend line**. If you're looking at a time series chart, you can turn on a trend line to show where things are heading. +- **Show values on data points**. The default setting will try and fit as many values on your chart as will fit nicely, but you can also force Metabase to show the values for each and every data point, which it will do begrudgingly. Showing values also works with multi-series charts, but be aware that the more data points you add, the more crowded with values the charts will become. + +### Axes + +There are three main ways to configure axes: + +- **Change the scale for your axes**. If you're looking at a time series chart, your x-axis can use a time series scale or an ordinal one. When using "Timeseries", it will always be displayed in ascending order, so oldest to newest, while "Ordinal" will display in the order the data is returned. Your y-axis can use a linear, power, or logarithmic scale. +- **Hide or show the tick marks on your axes**. You can also choose to rotate the tick marks on the x-axis to help them fit better. +- **Edit the range of your y-axis**. Metabase sets an automatic range by default, but you can toggle that off and input a custom minimum and maximum value for the y-axis if you'd like. + +### Labels + +Here's where you can choose to hide the **label** for your x- or y-axis. You can also customize the text for your axes labels here. + +## Further reading + +- [Guide to line charts](https://www.metabase.com/learn/visualization/line-charts) +- [Master the bar chart](https://www.metabase.com/learn/visualization/bar-charts) +- [Visualize your data as a histogram](https://www.metabase.com/learn/visualization/histograms) \ No newline at end of file diff --git a/docs/questions/sharing/visualizations/map.md b/docs/questions/sharing/visualizations/map.md new file mode 100644 index 0000000000000..e93eb699ec63c --- /dev/null +++ b/docs/questions/sharing/visualizations/map.md @@ -0,0 +1,24 @@ +--- +title: Maps +--- + +# Maps + +When you select the **Map** visualization setting, Metabase will automatically try and pick the best kind of map to use based on the table or result set. Here are the maps that Metabase uses: + +- **United States Map**. Creating a map of the United States from your data requires your results to contain a column that contains names of states or two-letter state codes. This lets you do things like visualize the count of your users broken out by state, with darker states representing more users. +- **World Map**. To visualize your results in the format of a map of the world broken out by country, your result must contain a column with two-letter country codes. (E.g., count of users by country.) + +![Region map](../../images/map.png) + +- **Pin Map**. If your results contains a latitude and longitude field, Metabase will try to display the results as a pin map of the world. Metabase will put one pin on the map for each row in your table, based on the latitude and longitude fields. You can try this with the Sample Database that's included in Metabase: start a new question and select the People table, use `raw data` for your view, and choose the Map option for your visualization. You'll see a map of the world, with each dot representing the latitude and longitude coordinates of a single person from the People table. + +![Pin map](../../images/pin-map.png) + +When you open up the Map options, you can manually switch between a region map (e.g., United States) and a pin map. If you're using a region map, you can also choose which field to use as the measurement, and which field to use as the region (e.g., State or Country). + +Metabase also allows administrators to add custom region maps via GeoJSON files through the Metabase **Admin Panel**. + +## Further reading + +- [Visualizing data with maps](https://www.metabase.com/learn/basics/visualizing-data/maps). \ No newline at end of file diff --git a/docs/questions/sharing/visualizations/numbers.md b/docs/questions/sharing/visualizations/numbers.md new file mode 100644 index 0000000000000..3be6f7bd185c1 --- /dev/null +++ b/docs/questions/sharing/visualizations/numbers.md @@ -0,0 +1,13 @@ +--- +title: Numbers +--- + +# Numbers + +The **Numbers** option is for displaying a single number, nice and big. The options for numbers include: + +![Number](../../images/number.png) + +- **Adding character prefixes or suffixes** to it (so you can do things like put a currency symbol in front or a percent at the end), +- **Setting the number of decimal places** you want to include, and +- **Multiplying your result by a number** (like if you want to multiply a decimal by 100 to make it look like a percent). If you want to _divide_ by a number, then just multiply it by a decimal (e.g, if your result is `100`, but you want it to display as `1`, simply multiply it by 0.01). diff --git a/docs/questions/sharing/visualizations/pie-or-donut-chart.md b/docs/questions/sharing/visualizations/pie-or-donut-chart.md new file mode 100644 index 0000000000000..dfad91a6bd0e5 --- /dev/null +++ b/docs/questions/sharing/visualizations/pie-or-donut-chart.md @@ -0,0 +1,11 @@ +--- +title: Pie or donut charts +--- + +# Pie or donut charts + +A **pie or donut chart** can be used when breaking out a metric by a single dimension, especially when the number of possible breakouts is small, like users by gender. If you have more than a few breakouts, like users per country, it's usually better to use a bar chart so that your users can more easily compare the relative sizes of each bar. + +The options for pie charts let you choose which field to use as your measurement, and which one to use for the dimension (i.e. the pie slices). You can also customize the color of each slice, the pie chart's legend, whether or not to show each slice's percent of the whole in the legend, and the minimum size a slice needs to be in order for Metabase to display it. + +![Inedible donut chart](../../images/donut.png) diff --git a/docs/questions/sharing/visualizations/pivot-table.md b/docs/questions/sharing/visualizations/pivot-table.md new file mode 100644 index 0000000000000..d2428f6b099a3 --- /dev/null +++ b/docs/questions/sharing/visualizations/pivot-table.md @@ -0,0 +1,39 @@ +--- +title: Pivot table +--- + +# Pivot table + +Pivot tables allow you swap rows and columns, group data, and include subtotals in your table. You can group one or more metrics by one or more dimensions. + +Pivot tables are not currently available for the following databases in Metabase: + +- Druid +- Google Analytics +- MongoDB + +Pivot tables work for simple and custom questions with summarized data for all other [officially supported databases](../../../databases/connecting.md). They don't work for questions that lack aggregate data, and they don't work for questions written in SQL, as Metabase would need to modify your SQL code in order to calculate subtotals. If you really need to use SQL, the workaround here is to create your question in two steps: first do all the complex things you need to do in SQL, save the results as a question, then use that saved SQL question as the starting point for a new GUI question which summarizes that data. + +In the settings for the Pivot Table visualization, you can assign fields to one of three "buckets": + +- Fields to use for the table **rows** +- Fields to use for the table **columns** +- Fields to use for the table **values** + +Let's say we ask the following question in the notebook editor: + +![Pivot table notebook](../../images/pivot-table-notebook.png) + +From the `Orders` table, we've summarized by the count of orders and the average order total, and grouped by `User → State`, `Product → Category`, and `Created At` binned by year. Here's our question visualized as a pivot table: + +![Pivot table options](../../images/pivot-table-options.png) + +We've assigned the fields `User → State` and `Created At` to table rows, and assigned the `Product -> Category` field to generate our columns: Doohickey, Gadget, and so on. We can drag and drop dimensions between the row and column buckets, and add aggregations to the table values bucket. For example, if we assign a field to the columns bucket, Metabase will pivot that field and render each unique value of that field as a column heading. + +You can put multiple fields in the "rows" and "columns" buckets, but note that the order of the fields changes how Metabase displays the table: each additional field will nest within the previous field. + +Where it makes sense, Metabase will automatically include subtotals for grouped rows. For example, as in the image above, because we've grouped our rows first by `State`, then by `Created At`, Metabase will list each year for each `State`, and aggregate the metric(s) for that subgroup. For orders placed in Wisconsin, Metabase would sum the count of orders for each category, and find the average annual order total in each product category in Wisconsin. + +To collapse a group on a pivot table, you can click on the minus (–) button next to the group's heading (or the plus (+) button to expand it). When you save a pivot table, Metabase will remember which groups were expanded and which were collapsed. + +For more, check out [How to create a pivot table to summarize your data](https://www.metabase.com/learn/basics/visualizing-data/how-to-create-pivot-tables.html). \ No newline at end of file diff --git a/docs/questions/sharing/visualizations/progress-bar.md b/docs/questions/sharing/visualizations/progress-bar.md new file mode 100644 index 0000000000000..d45f7d1526f2b --- /dev/null +++ b/docs/questions/sharing/visualizations/progress-bar.md @@ -0,0 +1,9 @@ +--- +title: Progress bars +--- + +# Progress bars + +**Progress bars** are for comparing a single number to a goal value that you set. Open up the settings for your progress bar to choose a value for your goal, and Metabase will show you how far away your question's current result is from the goal. + +![Progress bar](../../images/progress.png) diff --git a/docs/questions/sharing/visualizations/scatterplot-or-bubble-chart.md b/docs/questions/sharing/visualizations/scatterplot-or-bubble-chart.md new file mode 100644 index 0000000000000..db0991da01d6a --- /dev/null +++ b/docs/questions/sharing/visualizations/scatterplot-or-bubble-chart.md @@ -0,0 +1,13 @@ +--- +title: Scatterplots and bubble charts +--- + +# Scatterplots and bubble charts + +**Scatterplots** are useful for visualizing the correlation between two variables, like comparing the age of your users vs. how many dollars they've spent on your products. To use a scatterplot, you'll need to ask a question that results in two numeric columns, like `Count of Orders grouped by Customer Age`. Alternatively, you can use a raw data table and select the two numeric fields you want to use in the chart options. + +If you have a third numeric field, you can also create a **bubble chart**. Select the Scatter visualization, then open up the chart settings and select a field in the **bubble size** dropdown. This field will be used to determine the size of each bubble on your chart. For example, you could use a field that contains the total dollar amount for each x-y pair — i.e. larger bubbles for larger total dollar amounts spent on orders. + +Scatterplots and bubble charts also have similar chart options as line, bar, and area charts, including the option to display trend or goal lines. + +![Scatter](../../images/scatter.png) \ No newline at end of file diff --git a/docs/questions/sharing/visualizations/table.md b/docs/questions/sharing/visualizations/table.md new file mode 100644 index 0000000000000..147efdbf7aafa --- /dev/null +++ b/docs/questions/sharing/visualizations/table.md @@ -0,0 +1,66 @@ +--- +title: Tables +--- + +# Tables + +The **Table** option is good for looking at tabular data (duh), or for lists of things like users or orders. The visualization options for tables allow you to add, hide, or rearrange fields in the table you're looking at, as well as modify their formatting. Check out [Everything you can do with the table visualization](https://www.metabase.com/learn/basics/visualizing-data/table.html). + +## Rearranging, adding, and removing columns + +Open up the settings for your table and you'll see the Columns tab, which displays all the columns currently being shown in the table. Below that you'll see a list of more columns from linked tables, if any, that you can add to the current table view. + +To hide a column, click the X icon on it; that'll send it down to the **More columns** area in case you want to bring it back. To add a linked column, just click the + icon on it, which will bring it to the **Visible columns** section. Click and drag any of the columns listed there to rearrange the order in which they appear. Another super easy way to rearrange columns without having to open up the visualization settings is to simply click and drag on a column's heading to move it where you'd like it to go. + +> Changing these options doesn't change the actual table itself; these changes create a custom view of the table that you can save as a **question** in Metabase and refer to later, share with others, or add to a [dashboard](../../../dashboards/start.md). + +## Column formatting options + +To format the display of any column in a table, click on the column heading and choose the `Formatting` option (you can also get there by clicking on the gear on any column when in the `Columns` tab of the visualization settings). + +![Column formatting](../../images/column-header-formatting.png) + +The options you see will differ depending on the type of column you're viewing: + +### Dates + +- **Date style** gives you a bunch of different choices for how to display the date. +- **Abbreviate names of days and months**, when turned on, will turn things like `January` to `Jan`, and `Monday` to `Mon`. +- **Show the time** lets you decide whether or not to display the time, and if so, how. You can include hours and minutes, and additionally seconds and milliseconds. + +### Numbers + +- **Show a mini bar chart** will display a small horizontal bar next to each number in this column to show its size relative to the other values in the column. +- **Style** lets you choose to display the number as a plain number, a percent, in scientific notation, or as a currency. +- **Separator style** gives you various options for how commas and periods are used to separate the number. +- **Minimum number of decimal places** forces the number to be displayed with exactly this many decimal places. +- **Multiply by a number** multiplies each number in this column by whatever you type here. Just don't type an emoji here; it almost always causes a temporal vortex to manifest. +- **Add a prefix/suffix** lets you put a symbol, word, or whatever before or after each cell's value. + +### Currency +Currency columns have all the same options as numbers, plus the following: + +- **Unit of Currency** lets you change the unit of currency from whatever the system default is. +- **Currency label style** allows you to switch between displaying the currency label as a symbol, a code like (USD), or the full name of the currency. +- **Where to display the unit of currency** lets you toggle between showing the currency label in the column heading or in every cell in the column. + +### Conditional table formatting + +Sometimes it's helpful to highlight certain rows or columns in your tables when they meet a specific condition. You can set up conditional formatting rules by going to the visualization settings while looking at any table, then clicking on the **Conditional Formatting** tab. + +![Conditional formatting](../../images/conditional-formatting.png) + +When you add a new rule, you'll first need to pick which column(s) should be affected. Your columns can be formatted in one of two ways: + +- **Single color**. Pick single color if you want to highlight cells in the column if they're greater, less than, or equal to a specific number, or if they match or contain a certain word or phrase. You can optionally highlight the whole row of a cell that matches the condition you pick so that it's easier to spot as you scroll down your table. +- **Color range**. Choose color range if you want to tint all the cells in the column from smallest to largest or vice a versa. This option is only available for numeric columns. + +You can set as many rules on a table as you want. If two or more rules disagree with each other, the rule that's on the top of your list of rules will win. You can click and drag your rules to reorder them, and click on a rule to edit it. + +### Pivoted tables + +If your table is a result that contains one numeric column and two grouping columns, Metabase will also automatically "pivot" your table, like in the example below. Pivoting takes one of your columns and rotates it 90 degrees ("pivots" it) so that each of its values becomes a column heading. If you open up the visualization settings by clicking the gear icon, you can choose which column to pivot in case Metabase got it wrong; or you can also turn the pivoting behavior off entirely. + +![Pivot table](../../images/pivot.png) + +This auto-pivoting is distinct from the [pivot table](./pivot-table.md) visualization. diff --git a/docs/questions/sharing/visualizations/trend.md b/docs/questions/sharing/visualizations/trend.md new file mode 100644 index 0000000000000..2a9938c037563 --- /dev/null +++ b/docs/questions/sharing/visualizations/trend.md @@ -0,0 +1,11 @@ +--- +title: Trend +--- + +# Trend + +![Trend settings](../../images/trend-settings.png) + +The **Trend** visualization is great for displaying how a single number has changed over time. To use this visualization, you'll need to have a single number grouped by a Time field, like the Count of Orders by Created At. The Trend will show you the value of the number during the most recent period, as well as how much the number has increased or decreased compared to its value in the previous period. The period is determined by your group-by field; if you're grouping by Day, the Trend will show you the most recent day compared to the day before that. + +By default, Trends will display increases as green (i.e. "good") and decreases as red ("bad"). If your number is something where an increase is bad and a decrease is good (such as Bounce Rate, or Costs), you can reverse this behavior in the visualization settings. \ No newline at end of file diff --git a/docs/questions/sharing/visualizations/waterfall-chart.md b/docs/questions/sharing/visualizations/waterfall-chart.md new file mode 100644 index 0000000000000..fa77671c6dd4a --- /dev/null +++ b/docs/questions/sharing/visualizations/waterfall-chart.md @@ -0,0 +1,13 @@ +--- +title: Waterfall charts +--- + +# Waterfall charts + +Waterfall charts are a kind of bar chart useful for visualizing results that contain both positive and negative values. Each bar on a waterfall chart shows either an increase or decrease, with a final bar on the right of the chart that represents the total value. + +![Waterfall chart](../../images/waterfall-chart.png) + +In the example above, the waterfall chart displays "Profit" for each "Product:" apples, bananas, oranges, peaches, and mangos. From left to right, each bar indicates the change in total. The products with green bars indicate positive values (they made a profit). Peaches, however, lost money, indicated by a red bar, which signals a negative value. The bar at the end shows the total profit of all products combined. You can show values on each bar, and change the colors for increases and decreases. + +For waterfall charts, you'll want a query that is a single metric grouped by a single dimension: by time or category. \ No newline at end of file diff --git a/docs/questions/sharing/visualizing-results.md b/docs/questions/sharing/visualizing-results.md index 909e662ebbf6d..f0d7cbfff6bb0 100644 --- a/docs/questions/sharing/visualizing-results.md +++ b/docs/questions/sharing/visualizing-results.md @@ -8,320 +8,149 @@ redirect_from: While tables are useful for looking up information or finding specific numbers, it's usually easier to see trends and make sense of data using charts. -In Metabase, an answer to a question can be visualized in a number of ways: - -- [Numbers](#numbers) -- [Trend](#trends) -- [Progress bar](#progress-bars) -- [Gauge](#gauges) -- [Table](#tables) -- [Pivot table](#pivot-table) -- [Line chart](#line-bar-and-area-charts) -- [Bar chart](#line-bar-and-area-charts) -- [Combo chart](#combo-charts) -- [Waterfall chart](#waterfall-charts) -- [Row chart](#row-charts) -- [Area chart](#line-bar-and-area-charts) -- [Scatterplot or bubble chart](#scatterplots-and-bubble-charts) -- [Pie/donut chart](#pie-or-donut-charts) -- [Funnel](#funnel) -- [Map](#maps) - To change how the answer to your question is displayed, click on the **Visualization** button in the bottom-left of the screen to open the visualization sidebar. ![Visualization options](../images/VisualizeChoices.png) -If a particular visualization doesn’t really make sense for your answer, that option will appear grayed out in the sidebar. You can still select a grayed-out option, though you might need to open the chart options to make your selection work with your data. - -Once a question returns results, you can save the question, download the results, or add the question to a [dashboard](../../dashboards/start.md). - -## Visualization types and options - -Each visualization type has its own advanced options. Click the **Settings** button next to the Visualization button to see your options. The options panel also automatically opens up whenever you pick a new visualization type. +If a particular visualization doesn’t really make sense for your answer, that option will appear in the "Other charts" section. You can still select one of these other charts, though you might need to fiddle with the chart options to make the chart work with your data. Not sure which visualization type to use? Check out [Which chart should you use?](https://www.metabase.com/learn/visualization/chart-guide) -### Numbers - -The **Numbers** option is for displaying a single number, nice and big. The options for numbers include: - -- **Adding character prefixes or suffixes** to it (so you can do things like put a currency symbol in front or a percent at the end), -- **Setting the number of decimal places** you want to include, and -- **Multiplying your result by a number** (like if you want to multiply a decimal by 100 to make it look like a percent). If you want to _divide_ by a number, then just multiply it by a decimal (e.g, if your result is `100`, but you want it to display as `1`, simply multiply it by 0.01). - -![Number](../images/number.png) - -### Trends - -![Trend settings](../images/trend-settings.png) - -The **Trend** visualization is great for displaying how a single number has changed over time. To use this visualization, you'll need to have a single number grouped by a Time field, like the Count of Orders by Created At. The Trend will show you the value of the number during the most recent period, as well as how much the number has increased or decreased compared to its value in the previous period. The period is determined by your group-by field; if you're grouping by Day, the Trend will show you the most recent day compared to the day before that. - -By default, Trends will display increases as green (i.e. "good") and decreases as red ("bad"). If your number is something where an increase is bad and a decrease is good (such as Bounce Rate, or Costs), you can reverse this behavior in the visualization settings. - -### Progress bars - -**Progress bars** are for comparing a single number to a goal value that you set. Open up the settings for your progress bar to choose a value for your goal, and Metabase will show you how far away your question's current result is from the goal. - -![Progress bar](../images/progress.png) - -### Gauges - -Ah, **gauges**: you either love 'em or you hate 'em. …Or you feel "meh" about them, I guess. Whatever the case, gauges allow you to show a single number in the context of a set of colored ranges that you can specify. By default, when you choose the Gauge visualization, Metabase will create red, yellow, and green ranges for you. - -![Gauge](../images/gauge.png) - -Open up the visualization settings to define your own ranges, choose colors for them, and optionally add labels to some or all of your ranges: - -![Gauge settings](../images/gauge-settings.png) +## Visualization options -### Tables +![Options for a chart](../images/viz-options.png) -The **Table** option is good for looking at tabular data (duh), or for lists of things like users or orders. The visualization options for tables allow you to add, hide, or rearrange fields in the table you're looking at, as well as modify their formatting. Check out [Everything you can do with the table visualization](https://www.metabase.com/learn/basics/visualizing-data/table.html). +Each visualization type has its own advanced options. -#### Rearranging, adding, and removing columns +To change the settings for a specific chart, for example a row chart, you could either: -Open up the settings for your table and you'll see the Columns tab, which displays all the columns currently being shown in the table. Below that you'll see a list of more columns from linked tables, if any, that you can add to the current table view. +- Click on the gear icon in the bottom left of the chart (next to the **Visualization** button, or +- Click on **Visualization** in the bottom left of the chart, then hover over the currently selected chart and click on the **gear** icon that pops up. -To hide a column, click the X icon on it; that'll send it down to the **More columns** area in case you want to bring it back. To add a linked column, just click the + icon on it, which will bring it to the **Visible columns** section. Click and drag any of the columns listed there to rearrange the order in which they appear. Another super easy way to rearrange columns without having to open up the visualization settings is to simply click and drag on a column's heading to move it where you'd like it to go. +## Visualization types -> Changing these options doesn't change the actual table itself; these changes create a custom view of the table that you can save as a **question** in Metabase and refer to later, share with others, or add to a [dashboard](../../dashboards/start.md). +Metabase ships with a bunch of different visualizations types: -#### Column formatting options +## Numbers -To format the display of any column in a table, click on the column heading and choose the `Formatting` option (you can also get there by clicking on the gear on any column when in the `Columns` tab of the visualization settings). +The [Numbers](./visualizations/numbers.md) option is for displaying a single number, nice and big. -![Column formatting](../images/column-header-formatting.png) +![Number](../images/number.png) -The options you see will differ depending on the type of column you're viewing: +## Trends -**Dates** +The [Trend](./visualizations/trend.md) visualization is great for displaying how a single number has changed between two time periods. -- **Date style** gives you a bunch of different choices for how to display the date. -- **Abbreviate names of days and months**, when turned on, will turn things like `January` to `Jan`, and `Monday` to `Mon`. -- **Show the time** lets you decide whether or not to display the time, and if so, how. You can include hours and minutes, and additionally seconds and milliseconds. +![Trend settings](../images/trend-settings.png) -**Numbers** +## Detail -- **Show a mini bar chart** will display a small horizontal bar next to each number in this column to show its size relative to the other values in the column. -- **Style** lets you choose to display the number as a plain number, a percent, in scientific notation, or as a currency. -- **Separator style** gives you various options for how commas and periods are used to separate the number. -- **Minimum number of decimal places** forces the number to be displayed with exactly this many decimal places. -- **Multiply by a number** multiplies each number in this column by whatever you type here. Just don't type an emoji here; it almost always causes a temporal vortex to manifest. -- **Add a prefix/suffix** lets you put a symbol, word, or whatever before or after each cell's value. +The [Detail](./visualizations/detail.md) visualization shows a single result record (row) in an easy-to-read, two-column display. -**Currency** -Currency columns have all the same options as numbers, plus the following: +![Detail of a record in the account table](../images/detail.png) -- **Unit of Currency** lets you change the unit of currency from whatever the system default is. -- **Currency label style** allows you to switch between displaying the currency label as a symbol, a code like (USD), or the full name of the currency. -- **Where to display the unit of currency** lets you toggle between showing the currency label in the column heading or in every cell in the column. +## Progress bars -#### Formatting data in charts +[Progress bars](./visualizations/progress-bar.md) are for comparing a single number to a goal value that you set. -While we're talking about formatting, we thought you should also know that you can access formatting options for the columns used in a chart. Just open the visualization settings and select the `Data` tab: +![Progress bar](../images/progress.png) -![Chart formatting](../images/chart-formatting.png) +## Gauges -Then click on the gear icon next to the column that you want to format. Dates, numbers, and currencies tend to have the most useful formatting options. +[Gauges](./visualizations/gauge.md) allow you to show a single number in the context of a set of colored ranges that you can specify. -![Chart formatting options](../images/chart-formatting-options.png) +![Gauge](../images/gauge.png) -#### Conditional table formatting +## Tables -Sometimes it's helpful to highlight certain rows or columns in your tables when they meet a specific condition. You can set up conditional formatting rules by going to the visualization settings while looking at any table, then clicking on the **Conditional Formatting** tab. +The [Table](./visualizations/table.md) option is good for looking at tabular data (duh), or for lists of things like users or orders. ![Conditional formatting](../images/conditional-formatting.png) -When you add a new rule, you'll first need to pick which column(s) should be affected. Your columns can be formatted in one of two ways: - -- **Single color**. Pick single color if you want to highlight cells in the column if they're greater, less than, or equal to a specific number, or if they match or contain a certain word or phrase. You can optionally highlight the whole row of a cell that matches the condition you pick so that it's easier to spot as you scroll down your table. -- **Color range**. Choose color range if you want to tint all the cells in the column from smallest to largest or vice a versa. This option is only available for numeric columns. - -You can set as many rules on a table as you want. If two or more rules disagree with each other, the rule that's on the top of your list of rules will win. You can click and drag your rules to reorder them, and click on a rule to edit it. - -#### Pivoted tables - -If your table is a result that contains one numeric column and two grouping columns, Metabase will also automatically "pivot" your table, like in the example below. Pivoting takes one of your columns and rotates it 90 degrees ("pivots" it) so that each of its values becomes a column heading. If you open up the visualization settings by clicking the gear icon, you can choose which column to pivot in case Metabase got it wrong; or you can also turn the pivoting behavior off entirely. - -![Pivot table](../images/pivot.png) - -This auto-pivoting is distinct from the pivot table visualization, which we cover next. - -### Pivot table - -Pivot tables allow you swap rows and columns, group data, and include subtotals in your table. You can group one or more metrics by one or more dimensions. - -Pivot tables are not currently available for the following databases in Metabase: - -- Druid -- Google Analytics -- MongoDB - -Pivot tables work for simple and custom questions with summarized data for all other [officially supported databases](../../databases/connecting.md#connecting-to-supported-databases). They don't work for questions that lack aggregate data, and they don't work for questions written in SQL, as Metabase would need to modify your SQL code in order to calculate subtotals. If you really need to use SQL, the workaround here is to create your question in two steps: first do all the complex things you need to do in SQL, save the results as a question, then use that saved SQL question as the starting point for a new GUI question which summarizes that data. - -In the settings for the Pivot Table visualization, you can assign fields to one of three "buckets": - -- Fields to use for the table **rows** -- Fields to use for the table **columns** -- Fields to use for the table **values** - -Let's say we ask the following question in the notebook editor: +## Pivot tables -![Pivot table notebook](../images/pivot-table-notebook.png) - -From the `Orders` table, we've summarized by the count of orders and the average order total, and grouped by `User → State`, `Product → Category`, and `Created At` binned by year. Here's our question visualized as a pivot table: +[Pivot tables](./visualizations/pivot-table.md) allow you swap rows and columns, group data, and include subtotals in your table. You can group one or more metrics by one or more dimensions. ![Pivot table options](../images/pivot-table-options.png) -We've assigned the fields `User → State` and `Created At` to table rows, and assigned the `Product -> Category` field to generate our columns: Doohickey, Gadget, and so on. We can drag and drop dimensions between the row and column buckets, and add aggregations to the table values bucket. For example, if we assign a field to the columns bucket, Metabase will pivot that field and render each unique value of that field as a column heading. - -You can put multiple fields in the "rows" and "columns" buckets, but note that the order of the fields changes how Metabase displays the table: each additional field will nest within the previous field. - -Where it makes sense, Metabase will automatically include subtotals for grouped rows. For example, as in the image above, because we've grouped our rows first by `State`, then by `Created At`, Metabase will list each year for each `State`, and aggregate the metric(s) for that subgroup. For orders placed in Wisconsin, Metabase would sum the count of orders for each category, and find the average annual order total in each product category in Wisconsin. +## Line charts -To collapse a group on a pivot table, you can click on the minus (–) button next to the group's heading (or the plus (+) button to expand it). When you save a pivot table, Metabase will remember which groups were expanded and which were collapsed. +[Line charts](./visualizations/line-bar-and-area-charts.md) are best for displaying the trend of a number over time, especially when you have lots of x-axis values. For more, check out our [Guide to line charts](https://www.metabase.com/learn/basics/visualizing-data/line-charts.html) and [Time series analysis](https://www.metabase.com/learn/time-series) tutorials. -For more, check out [How to create a pivot table to summarize your data](https://www.metabase.com/learn/basics/visualizing-data/how-to-create-pivot-tables.html). - -### Line, bar, and area charts +![Trend lines](../images/trend-lines.png) -**Line charts** are best for displaying the trend of a number over time, especially when you have lots of x-axis values. For more, check out our [Guide to line charts](https://www.metabase.com/learn/basics/visualizing-data/line-charts.html). +## Bar charts -Bar charts are great for displaying a number grouped by a category (e.g., the number of users you have by country). Bar charts can also be useful for showing a number over time if you have a smaller number of x-axis values (like orders per month this year). +[Bar charts](./visualizations/line-bar-and-area-charts.md) are great for displaying a number grouped by a category (e.g., the number of users you have by country). ![Bar chart](../images/bar.png) -Learn more about [Bar charts](https://www.metabase.com/learn/basics/visualizing-data/bar-charts.html). +## Area charts -**Area charts** are useful when comparing the proportions of two metrics over time. Both bar and area charts can be stacked. +[Area charts](./visualizations/line-bar-and-area-charts.md) are useful when comparing the proportions of two metrics over time. Both bar and area charts can be stacked. ![Stacked area chart](../images/area.png) -**Trend lines** - -**Trend lines** are another useful option for line, area, bar, and scatter charts. If you have a question where you're grouping by a time field, open up the visualization settings and turn the `Show trend line` toggle on to display a trend line. Metabase will choose the best type of line to fit to the trend of your series. Trend lines will even work if you have multiple metrics selected in your summary. But trend lines won't work if you have any groupings beyond the one time field. +## Combo charts -![Trend lines](../images/trend-lines.png) - -### Combo charts - -Combo charts let you combine bars and lines (or areas) on the same chart. +[Combo charts](./visualizations/combo-chart.md) let you combine bars and lines (or areas) on the same chart. ![Line + bar](../images/combo-chart.png) -Metabase will pick one of your series to display as a line, and another to display as a bar by default. Open up the visualization settings to change which series are lines, bars, or areas, as well as to change per-series settings like colors. Click the down arrow icon on the right of a series to see additional options: - -![Line + bar](../images/combo-chart-settings.png) - -To use a Combo chart you'll either need to have two or more metrics selected in the Summarize By section of your question, with one or two grouping columns, like this: - -![Data for Line + Bar chart](../images/combo-chart-data-1.png) +## Histograms -Or you'll need a question with a single metric and two grouping columns, like this: - -![Data for Line + Bar chart](../images/combo-chart-data-2.png) - -### Row charts - -If you're trying to group a number by a column that has a lot of possible values, like a Vendor or Product Title field, try visualizing it as a **row chart**. Metabase will show you the bars in descending order of size, with a final bar at the bottom for items that didn't fit. - -![Row chart](../images/row.png) - -#### Histograms - -If you have a bar chart like Count of Users by Age, where the x-axis is a number, you'll get a special kind of chart called a **[histogram](https://www.metabase.com/learn/basics/visualizing-data/histograms.html)**, where each bar represents a range of values (called a "bin"). Note that Metabase will automatically bin your results any time you use a number as a grouping, even if you aren't viewing a bar chart. Questions that use latitude and longitude will also get binned automatically. +If you have a bar chart like Count of Users by Age, where the x-axis is a number, you'll get a special kind of bar chart called a [histogram](./visualizations/line-bar-and-area-charts.md) where each bar represents a range of values (called a "bin"). ![Histogram](../images/histogram.png) -By default, Metabase will automatically choose a good way to bin your results. But you can change how many bins your result has, or turn the binning off entirely, by clicking on the area to the right of the column you're grouping by: - -![Binning options](../images/histogram-bins.png) - -[Learn more about histograms](https://www.metabase.com/learn/basics/visualizing-data/histograms.html). - -#### Options for line, bar, and area charts - -These three charting types have very similar options, which are broken up into the following: +## Row charts -**Data** +[Row charts](./visualizations/line-bar-and-area-charts.md) are good for visualizing data grouped by a column that has a lot of possible values, like a Vendor or Product Title field. -Here's where you can choose the columns you want to plot on your x and y axes. This is mostly useful if your table or result set contains more than two columns, like if you're trying to graph fields from an unaggregated table. You can also add additional metrics to your chart by clicking the `Add another series` link below the y-axis dropdown, or break your current metric out by an additional dimension by clicking the `Add a series breakout` link below the x-axis dropdown (note that you can't add an additional series breakout if you have more than one metric/series). - -**Display** - -There's quite a bit you can do in this tab, but the options available will depend on the data in your chart. - -- **Set the colors and labels** for the series on your chart. -- **Change the style of your lines** for Line and Area charts, and choose whether to display dots on the lines. -- **Specify how to handle missing values**. Use the "Replace missing values with…" setting to change how your chart deals with missing values. You can use linear interpolation, or display those points as zero or as nothing. -- **Add a goal line**. Goal lines can be used in conjunction with [alerts](./alerts.md) to send an email or a Slack message when your metric cross this line. -- **Add a trend line**. If you're looking at a time series chart, you can turn on a trend line to show where things are heading. -- **Show values on data points**. The default setting will try and fit as many values on your chart as will fit nicely, but you can also force Metabase to show the values for each and every data point, which it will do begrudgingly. Showing values also works with multi-series charts, but be aware that the more data points you add, the more crowded with values the charts will become. - -**Axes** - -There are three main ways to configure axes: - -- **Change the scale for your axes**. If you're looking at a time series chart, your x-axis can use a time series scale or an ordinal one. When using "Timeseries", it will always be displayed in ascending order, so oldest to newest, while "Ordinal" will display in the order the data is returned. Your y-axis can use a linear, power, or logarithmic scale. -- **Hide or show the tick marks on your axes**. You can also choose to rotate the tick marks on the x-axis to help them fit better. -- **Edit the range of your y-axis**. Metabase sets an automatic range by default, but you can toggle that off and input a custom minimum and maximum value for the y-axis if you'd like. - -**Labels** - -Here's where you can choose to hide the **label** for your x- or y-axis. You can also customize the text for your axes labels here. +![Row chart](../images/row.png) -### Waterfall charts +## Waterfall charts -Waterfall charts are a kind of bar chart useful for visualizing results that contain both positive and negative values. Each bar on a waterfall chart shows either an increase or decrease, with a final bar on the right of the chart that represents the total value. +[Waterfall charts](./visualizations/waterfall-chart.md) are a kind of bar chart useful for visualizing results that contain both positive and negative values. ![Waterfall chart](../images/waterfall-chart.png) -In the example above, the waterfall chart displays "Profit" for each "Product:" apples, bananas, oranges, peaches, and mangos. From left to right, each bar indicates the change in total. The products with green bars indicate positive values (they made a profit). Peaches, however, lost money, indicated by a red bar, which signals a negative value. The bar at the end shows the total profit of all products combined. You can show values on each bar, and change the colors for increases and decreases. - -For waterfall charts, you'll want a query that is a single metric grouped by a single dimension: by time or category. - -### Scatterplots and bubble charts +## Scatterplots and bubble charts -**Scatterplots** are useful for visualizing the correlation between two variables, like comparing the age of your users vs. how many dollars they've spent on your products. To use a scatterplot, you'll need to ask a question that results in two numeric columns, like `Count of Orders grouped by Customer Age`. Alternatively, you can use a raw data table and select the two numeric fields you want to use in the chart options. - -If you have a third numeric field, you can also create a **bubble chart**. Select the Scatter visualization, then open up the chart settings and select a field in the **bubble size** dropdown. This field will be used to determine the size of each bubble on your chart. For example, you could use a field that contains the total dollar amount for each x-y pair — i.e. larger bubbles for larger total dollar amounts spent on orders. - -Scatterplots and bubble charts also have similar chart options as line, bar, and area charts, including the option to display trend or goal lines. +[Scatterplots](./visualizations/scatterplot-or-bubble-chart.md) are useful for visualizing the correlation between two variables, like comparing the age of your people using your app vs. how many dollars they've spent on your products. ![Scatter](../images/scatter.png) -### Pie or donut charts - -A **pie or donut chart** can be used when breaking out a metric by a single dimension, especially when the number of possible breakouts is small, like users by gender. If you have more than a few breakouts, like users per country, it's usually better to use a bar chart so that your users can more easily compare the relative sizes of each bar. +## Pie chart or donut charts -The options for pie charts let you choose which field to use as your measurement, and which one to use for the dimension (i.e. the pie slices). You can also customize the color of each slice, the pie chart's legend, whether or not to show each slice's percent of the whole in the legend, and the minimum size a slice needs to be in order for Metabase to display it. +A [pie chart or donut chart](./visualizations/pie-or-donut-chart.md) can be used when breaking out a metric by a single dimension, especially when the number of possible breakouts is small, like users by gender. ![Donut](../images/donut.png) -### Funnel +## Funnel charts -**Funnels** are commonly used in e-commerce or sales to visualize how many customers are present within each step of a checkout flow or sales cycle. At their most general, funnels show you values broken out by steps, and the percent decrease between each successive step. To create a funnel in Metabase, you'll need to have a table with at least two columns: one column that contains the metric you're interested in, and another that contains the funnel's steps. - -For example, I might have an Opportunities table, and I could create a question that gives me the number of sales leads broken out by a field that contains stages such as `Prospecting`, `Qualification`, `Proposal`, `Negotiation`, and `Closed`. In this example, the percentages shown along the x-axis tell you what percent of the total starting opportunities are still present at each subsequent step; so 18.89% of our total opportunities have made it all the way to being closed deals. The number below each percent is the actual value of the count at that step — in our example, the actual number of opportunities that are currently at each step. Together, these numbers help you figure out where you're losing your customers or users. +[Funnels](./visualizations/funnel.md) are commonly used in e-commerce or sales to visualize how many customers are present within each step of a checkout flow or sales cycle. At their most general, funnels show you values broken out by steps, and the percent decrease between each successive step. ![Funnel](../images/funnel.png) -### Maps - -When you select the **Map** visualization setting, Metabase will automatically try and pick the best kind of map to use based on the table or result set. Here are the maps that Metabase uses: +## Maps -- **United States Map**. Creating a map of the United States from your data requires your results to include a `State` column that contains [two-letter state codes](https://about.usps.com/who/profile/history/state-abbreviations.htm) (e.g., "AK" for Alaska, "VT" for Vermont, and so on). This column with state codes lets you do things like visualize the count of people broken out by state, with darker states representing more people. -- **World Map**. To visualize your results in the format of a map of the world broken out by country, your result must contain a column with two-letter country codes. (E.g., count of users by country.) +When you select the [Map](./visualizations/map.md) visualization, Metabase will automatically try and pick the best kind of map to use based on the table or result set. ![Region map](../images/map.png) -- **Pin Map**. If your results contains a latitude and longitude field, Metabase will try to display the results as a pin map of the world. Metabase will put one pin on the map for each row in your table, based on the latitude and longitude fields. You can try this with the Sample Database that's included in Metabase: start a new question and select the People table, use `raw data` for your view, and choose the Map option for your visualization. You'll see a map of the world, with each dot representing the latitude and longitude coordinates of a single person from the People table. +## Formatting data in charts -![Pin map](../images/pin-map.png) +While we're talking about formatting, we thought you should also know that you can access formatting options for the columns used in a chart. Just open the visualization settings and select the `Data` tab: -When you open up the Map options, you can manually switch between a region map (e.g., United States) and a pin map. If you're using a region map, you can also choose which field to use as the measurement, and which field to use as the region (e.g., State or Country). +![Chart formatting](../images/chart-formatting.png) + +Then click on the gear icon next to the column that you want to format. Dates, numbers, and currencies tend to have the most useful formatting options. + +![Chart formatting options](../images/chart-formatting-options.png) -Metabase also allows administrators to add custom region maps via GeoJSON files through the Metabase **Admin Panel**. +## Further reading -Learn more about [visualizing data with maps](https://www.metabase.com/learn/basics/visualizing-data/maps.html). +- [Charts with multiple series](../../dashboards/multiple-series.md) +- [Appearance](../../configuring-metabase/appearance.md) +- [BI dashboard best practices](https://www.metabase.com/learn/dashboards/bi-dashboard-best-practices.html) diff --git a/docs/troubleshooting-guide/cant-see-tables.md b/docs/troubleshooting-guide/cant-see-tables.md index b4e773237426c..c01b6c2f8e758 100644 --- a/docs/troubleshooting-guide/cant-see-tables.md +++ b/docs/troubleshooting-guide/cant-see-tables.md @@ -4,76 +4,79 @@ title: I can't see my tables # I can't see my tables -You have connected Metabase to a database, but: +You've connected Metabase to a database, but: - you don't see the tables in the [Data Model](../data-modeling/metadata-editing.md) section of the Admin Panel, - the tables don't appear in the [Data Browser](https://www.metabase.com/learn/getting-started/data-browser), - the tables don't show up as possible data sources when you create a query using the Notebook Editor, or - you can no longer see tables that you used to be able to see. -## Is your browser showing you a cached list of tables? +## Check for browser issues -**Root cause:** Sometimes browsers will show an old cached list of tables. +1. Clear your browser cache. +2. Check if a browser extension or plugin is interfering with Metabase: + - Disable all extensions and plugins, + - Open Metabase in an incognito browser session, or + - Open Metabase in a different browser. -**Steps to take:** Refresh your browser tab and check for your table or tables again. +**Explanation** -## Does the database exist? +Sometimes your browser will show an old cached list of tables. Browser extensions can also prevent pages from loading correctly. -**Root cause:** The database doesn't exist. For example, you may have connected to a test database while doing an evaluation but are now in a production environment. +## Test the database connection -**Steps to take:** +1. Go to the Metabase [SQL editor](../questions/native-editor/writing-sql.md). +2. Test the connection to your database by running: + ``` + SELECT 1 + ``` -1. Go to Admin > Databases. -2. Check that the database you're trying to query is listed. -3. Click on the database name and examine the settings. +If you get an error, see [Troubleshooting database connections](./db-connection.md). -Exactly what settings you need will depend on your environment. To test that the settings are correct: +**Explanation** -1. Try to connect to the database using some other application (e.g., `psql` for PostgreSQL). +Something may have changed on the database side (if you were previously connected). For example, you may have connected to a test database while doing an evaluation but are now in a production environment. -If you can't connect to the database with another application, the problem is probably not with Metabase. Please check that the database server is running and that you have the correct host, port, username, password, and other settings. For more help, see [Troubleshooting database connections](./db-connection.md). +## Check table access -## Does the table exist? +To make sure that your table is actually queryable by Metabase: -**Root cause:** The table you think you should be able to see does not exist (e.g., it has a different name than you expect). +1. Go to the Metabase [SQL editor](../questions/native-editor/writing-sql.md). +2. Look for your table: + ``` + SELECT * + FROM your_table + ``` -**Steps to take:** To test that the table you are trying to query actually exists and that you have permission to access it, use the SQL Editor to create and run a query like: +If there's a problem with your table name or database permissions, you'll get an error message like: -``` -select * from SOMEWHERE -``` +- [Table not found](https://www.metabase.com/learn/debugging-sql/sql-syntax#column-or-table-name-is-not-found-or-not-recognized) +- [Permission denied](./data-permissions.md#getting-a-permission-denied-error-message) -where `SOMEWHERE` is the table you think you should be able to see. Metabase should display an error message like: +For less common errors, try searching or asking the [Metabase community](https://discourse.metabase.com/). -``` -Table "SOMEWHERE" not found -``` +**Explanation** -If you see this message, use another application (e.g., `psql` for PostreSQL) to send the same query to the database. If it also produces a "table not found" message, check the database schema and the spelling of the table name. +Something might have changed on database side: your table could've been renamed or dropped, or the permissions revoked. -Be sure to log in to the database using the same credentials that Metabase uses. A common problem is that the account Metabase uses to connect to the database lacks the same privileges as a member of IT staff or a developer, so tables that are visible to the latter when they use external applications are invisible to Metabase. For more help, see [Troubleshooting syncs, scans, and fingerprinting](./sync-fingerprint-scan.md). +## Metabase permissions -## Does the person who cannot see the table have permission to view it? +If there are only a few people who can't view tables, see [A user group has the wrong access to a table or schema](./data-permissions.md#a-user-group-has-the-wrong-access-to-a-table-or-schema). -**Root cause:** Metabase uses a group-based permission model: people belong to groups, and administrators can set permissions so that some groups cannot see all of the tables. +**Explanation** -**Steps to take:** - -1. Log into Metabase using the ID of the person who cannot see the expected tables. -2. Confirm that the tables are not visible. -3. Log out, then log in using the administrator's credentials. - -If the administrator's account can see the tables but an individual person cannot, see [Troubleshooting data permissions](./data-permissions.md). +Metabase uses a group-based permission model: people belong to groups, and admins can set permissions to hide tables from groups. ## MongoDB -MongoDB lets you "successfully connect" to any collection name, even the collection doesn't exist. If you don't see a MongoDB collection in Metabase, make sure that: +MongoDB lets you "successfully connect" to any collection name, even if the collection doesn't exist. If you don't see a MongoDB collection in Metabase, make sure that: - you have the correct collection name, and - the collection is non-empty. -## Related problems +## Related topics +- [Table visibility](../data-modeling/metadata-editing.md#table-visibility). - [My data sandboxes aren't working](./sandboxing.md). - [I can't view or edit a question or dashboard](./cant-view-or-edit.md). - [My visualizations are wrong](./visualization.md). diff --git a/docs/troubleshooting-guide/data-permissions.md b/docs/troubleshooting-guide/data-permissions.md index ac70997667262..5f33fc1f7414e 100644 --- a/docs/troubleshooting-guide/data-permissions.md +++ b/docs/troubleshooting-guide/data-permissions.md @@ -79,7 +79,7 @@ If you get an error message that says something like "permission denied to \; -GRANT ALL ON IN SCHEMA TO ; -``` - -To allow Metabase to query all tables in a specific schema: - -```sql -USE ; -GRANT ALL ON
TO ; -``` - ## Do you have a different problem? - [I can't view or edit my question or dashboard][view-edit]. diff --git a/docs/troubleshooting-guide/db-connection.md b/docs/troubleshooting-guide/db-connection.md index d9b16035b72a8..9bb3384ad8729 100644 --- a/docs/troubleshooting-guide/db-connection.md +++ b/docs/troubleshooting-guide/db-connection.md @@ -34,6 +34,8 @@ If you don't have access to the Metabase Admin panel, you'll need to ask the per - If you're running Metabase Cloud, check that you've [whitelisted our IP addresses](https://www.metabase.com/cloud/docs/ip-addresses-to-whitelist). +3. Make sure that Metabase is using a role with the necessary privileges to connect to your data warehouse. See [Granting database privileges](../databases/connecting.md#database-roles-users-and-privileges). + The steps above will help you detect whether the problem is occurring outside of Metabase. To _fix_ problems with your database server, you'll need to refer to the docs for your database or cloud service. Remember to [test your database connection](#testing-the-connection-status) after you make changes. If you don't have access to the data warehouse server, you’ll need to ask the person who manages your database or data warehouse. diff --git a/docs/troubleshooting-guide/db-performance.md b/docs/troubleshooting-guide/db-performance.md index 5d7299fa6414a..e37a72442959f 100644 --- a/docs/troubleshooting-guide/db-performance.md +++ b/docs/troubleshooting-guide/db-performance.md @@ -54,7 +54,7 @@ If someone or something creates 100 queries at the same time, this stampede of q ## Managing resource-intensive queries -1. [Reschedule or disable Metabase syncs and scans](../databases/connecting.md#syncing-and-scanning-databases). +1. [Reschedule or disable Metabase syncs and scans](../databases/sync-scan.md). **Explanation** @@ -63,7 +63,7 @@ By default, Metabase makes regular sync and scan queries against your database t ## Questions that use number, date, or timestamp columns 1. Update your database schema so that the columns are typed correctly. -2. [Sync the updated columns](../databases/connecting.md#manually-syncing-tables-and-columns) to bring the changes into Metabase. +2. [Sync the updated columns](../databases/sync-scan.md#manually-syncing-tables-and-columns) to bring the changes into Metabase. **Explanation** diff --git a/docs/troubleshooting-guide/filters.md b/docs/troubleshooting-guide/filters.md index 66c9895e0ee51..65ae888cbb830 100644 --- a/docs/troubleshooting-guide/filters.md +++ b/docs/troubleshooting-guide/filters.md @@ -2,7 +2,6 @@ title: Troubleshooting filters --- - # Troubleshooting filters It's always a good idea to start with a quick sanity check: @@ -13,6 +12,8 @@ It's always a good idea to start with a quick sanity check: ## Dashboard filters +If a dashboard filter is giving you no results or the wrong results: + 1. Click the **pencil** icon to go into edit mode. 2. Click the **gear** icon beside your filter widget. 3. Make sure you've selected a column for your filter under **Column to filter on**. @@ -22,13 +23,15 @@ It's always a good idea to start with a quick sanity check: ## Question filters +If a question filter is giving you no results or the wrong results: + 1. Make sure the question includes the column you want to filter on. 2. Check that the column actually contains the value(s) you're filtering on. You can do this by: - sorting number or date columns, - creating a "contains" filter for string columns, or - asking your database admin. 3. Ask your Metabase admin to help you check if: - - Metabase is [up to date](../databases/connecting.md#manually-syncing-tables-and-columns) with your database, + - Metabase is [up to date](../databases/sync-scan.md) with your database, - the column is [visible](../data-modeling/metadata-editing.md#column-visibility) in Metabase, - you have the correct [data permissions](../permissions/data.md) to access the column. @@ -41,7 +44,7 @@ If you're having trouble filtering on a: **Explanation** -When we first set up a filter, we need to link the filter to a column. If we make the wrong assumptions about a column's values or data type, the filter won't work at all. If a column changes, the filter might suddenly stop working. +When we first set up a filter, we need to link the filter to a column. If we make the wrong assumptions about a column's values or data type, the filter won't work at all. If a column changes on the database side, the filter might suddenly stop working. For example, let's say we want to create a filter named "Select Product ID" linked to a column named **Product ID**. The filter won't work if any of these things happen: @@ -55,7 +58,7 @@ For example, let's say we want to create a filter named "Select Product ID" link ## Time, ID, and number filters -If you're not a Metabase admin, you might have to ask your admin to help you with this. +To debug dashboard and question filters that involve timestamps, UUIDs, or numeric data: 1. Find the [data type](https://www.metabase.com/learn/databases/data-types-overview) of the column that you want to filter on. You can find this info from: - the [Data reference](../exploration-and-organization/data-model-reference.md), @@ -63,7 +66,9 @@ If you're not a Metabase admin, you might have to ask your admin to help you wit - directly from the database. 2. Cast the column to a data type that matches the desired [filter type](../questions/query-builder/introduction.md#filter-types). You can: - [cast strings or numbers to dates](../data-modeling/metadata-editing.md#casting-to-a-specific-data-type) from the Data Model page, or - - change the data type of the column in your database, and [re-sync](../databases/connecting.md#manually-syncing-tables-and-columns) the database schema. + - change the data type of the column in your database, and [re-sync](../databases/sync-scan.md#manually-syncing-tables-and-columns) the database schema. + +If you're not a Metabase admin, you might have to ask your admin to help you with some of these steps. **Explanation** @@ -71,6 +76,21 @@ Metabase needs to know the data type of a column in order to present you with a Timestamps, in particular, are the root of all evil, so please be patient with your Metabase admin (or yourself!) when trying to get the data type right. +## Missing or incorrect filter values + +If your filter dropdown menu displays the wrong values for a column: + +1. Go to **Admin settings** > **Data model**. +2. Find your database, table, and column. +3. Click the **gear** icon at the right of a column’s settings box. +4. Scroll to **Cached field values**. +5. Optional: click **Discard cached field values**. +6. Click **Re-scan this field**. + +**Explanation** + +Metabase [scans](../databases/sync-scan.md#how-database-scans-work) get the values for your filter dropdown menus by querying and caching the first 1,000 distinct records from a table. You might see outdated filter values if your tables are getting updated more frequently compared to your [scan schedule](../databases/sync-scan.md#scheduling-database-scans). + ## Related topics - [Troubleshooting linked filters](./linked-filters.md) @@ -79,7 +99,6 @@ Timestamps, in particular, are the root of all evil, so please be patient with y - [Creating dropdown filters](../data-modeling/metadata-editing.md#changing-a-search-box-filter-to-a-dropdown-filter) - [Creating SQL filters](../questions/native-editor/sql-parameters.md) - ## Are you still stuck? If you can’t solve your problem using the troubleshooting guides: diff --git a/docs/troubleshooting-guide/sync-fingerprint-scan.md b/docs/troubleshooting-guide/sync-fingerprint-scan.md index 56a1cf104430d..c0f2e56816def 100644 --- a/docs/troubleshooting-guide/sync-fingerprint-scan.md +++ b/docs/troubleshooting-guide/sync-fingerprint-scan.md @@ -22,12 +22,14 @@ Once you've confirmed that you're looking at a non-cached view of your tables an 2. Go to **Admin** > **Troubleshooting** > **Logs** to check the status of the sync. 3. Run a query against your database from the Metabase SQL editor to check for database connection or database privilege errors that aren't listed in the logs: - ```sql - SELECT * - FROM "your_schema"."your_table_or_view" - LIMIT 1 - ``` -5. [Manually re-sync](../databases/connecting.md#manually-syncing-tables-and-columns) the table or view if needed. + ```sql + SELECT + * + FROM + "your_schema"."your_table_or_view" + LIMIT 1 + ``` +4. [Manually re-sync](../databases/sync-scan.md#manually-syncing-tables-and-columns) the table or view if needed. ### Special cases @@ -35,12 +37,15 @@ If you’ve just set up a new database in Metabase, the initial sync query needs **Explanation** -A sync query should show up like this in your database's query execution table (using the privileges for the database user in the database connection details): +A sync query should show up like this in your database's query execution table (using the [privileges](../databases/users-roles-privileges.md) for the database user in the database connection details): ```sql -SELECT TRUE -FROM "your_schema"."your_table_or_view" -WHERE 1 <> 1 +SELECT + TRUE +FROM + "your_schema"."your_table_or_view" +WHERE + 1 <> 1 LIMIT 0 ``` @@ -80,10 +85,14 @@ If you're waiting for the initial scan to run after connecting a database, make Scan queries are run against your database to sample column values from the first 1,000 rows in a table or view: ```sql -SELECT "your_table_or_view"."column" AS "column" -FROM "your_schema"."your_table_or_view" -GROUP BY "your_table_or_view"."column" -ORDER BY "your_table_or_view"."column" ASC +SELECT + "your_table_or_view"."column" AS "column" +FROM + "your_schema"."your_table_or_view" +GROUP BY + "your_table_or_view"."column" +ORDER BY + "your_table_or_view"."column" ASC LIMIT 1000 ``` @@ -118,8 +127,10 @@ If you're using MongoDB, Metabase fingerprints the first 10,000 documents per co The initial fingerprinting query looks at the first 10,000 rows from a given table or view in your database: ```sql -SELECT * -FROM "your_schema"."your_table_or_view" +SELECT + * +FROM + "your_schema"."your_table_or_view" LIMIT 10000 ``` @@ -134,10 +145,10 @@ Metabase doesn't have a built-in option to trigger manual fingerprinting queries To speed up **syncs**: - Restrict the privileges used to connect to the database so that Metabase only syncs a limited subset of schemas or tables. - - [Reduce the frequency of sync queries](../databases/connecting.md#scheduling-database-scans). + - [Reduce the frequency of sync queries](../databases/sync-scan.md#scheduling-database-syncs). To speed up **scans**: - - [Reduce the frequency of scans, or disable scans entirely](../databases/connecting.md#scheduling-database-scans). + - [Reduce the frequency of scans, or disable scans entirely](../databases/sync-scan.md#scheduling-database-scans). - Reduce the number of columns being scanned by going to **Admin** > **Data Model** and setting **Filtering on this field** to **Search box** or **Plain input box**. **Explanation** @@ -148,7 +159,7 @@ Syncs and scans are ultimately just two kinds of queries that are run against yo - [Troubleshooting database connections](./db-connection.md). - [Troubleshooting filters](./filters.md). -- [How syncs and scans work](../databases/connecting.md#syncing-and-scanning-databases). +- [How syncs and scans work](../databases/sync-scan.md#how-database-syncs-work). ## Are you still stuck? diff --git a/docs/troubleshooting-guide/timeout.md b/docs/troubleshooting-guide/timeout.md index 733fe62f435e8..d577e9276de9d 100644 --- a/docs/troubleshooting-guide/timeout.md +++ b/docs/troubleshooting-guide/timeout.md @@ -29,7 +29,7 @@ If you can’t solve your problem using the troubleshooting guides: - Search for [known bugs or limitations][known-issues]. [app-engine-timeout]: https://cloud.google.com/appengine/articles/deadlineexceedederrors -[configuring-jetty]: https://www.eclipse.org/jetty/documentation/current/configuring-connectors.html +[configuring-jetty]: https://www.eclipse.org/jetty/documentation/current/#configuring-connectors [discourse]: https://discourse.metabase.com/ [ec2-troubleshooting]: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/TroubleshootingInstancesConnecting.html [elb-timeout]: https://aws.amazon.com/blogs/aws/elb-idle-timeout-control/ diff --git a/docs/usage-and-performance-tools/audit.md b/docs/usage-and-performance-tools/audit.md index 168046979d399..bdb477ec2a146 100644 --- a/docs/usage-and-performance-tools/audit.md +++ b/docs/usage-and-performance-tools/audit.md @@ -106,7 +106,7 @@ Admins can add and remove people from a subscription or alert by clicking on the Everyone can view all of their subscriptions and alerts by clicking on the **gear** icon in the upper right and navigating to **Account settings** > **Notifications**. -For more, see [how permissions work with dashboard subscriptions and alerts](../dashboards/subscriptions.md#how-permissions-work-with-dashboard-subscriptions). +For more, see [how permissions work with alerts and subscriptions](../permissions/notifications.md). [alerts]: ../questions/sharing/alerts.md [dashboard-subscriptions]: ../dashboards/subscriptions.md diff --git a/e2e/.eslintrc b/e2e/.eslintrc new file mode 100644 index 0000000000000..d8f7fcc87438b --- /dev/null +++ b/e2e/.eslintrc @@ -0,0 +1,11 @@ +{ + "rules": { + "import/no-commonjs": 0, + "no-color-literals": 0 + }, + "env": { + "cypress/globals": true, + "node": true + }, + "plugins": ["cypress"] +} diff --git a/frontend/test/__runner__/cypress-runner-backend.js b/e2e/runner/cypress-runner-backend.js similarity index 97% rename from frontend/test/__runner__/cypress-runner-backend.js rename to e2e/runner/cypress-runner-backend.js index 9b8d9ced8dcb2..4528fc07cc3c9 100644 --- a/frontend/test/__runner__/cypress-runner-backend.js +++ b/e2e/runner/cypress-runner-backend.js @@ -27,7 +27,7 @@ const CypressBackend = { "-Dh2.bindAddress=localhost", // fix H2 randomly not working (?) "-Djava.awt.headless=true", // when running on macOS prevent little Java icon from popping up in Dock "-Duser.timezone=US/Pacific", - `-Dlog4j.configurationFile=file:${__dirname}/log4j2.xml`, + `-Dlog4j.configurationFile=file:${__dirname}/../../frontend/test/__runner__/log4j2.xml`, ]; const metabaseConfig = { diff --git a/frontend/test/__runner__/cypress-runner-generate-snapshots.js b/e2e/runner/cypress-runner-generate-snapshots.js similarity index 60% rename from frontend/test/__runner__/cypress-runner-generate-snapshots.js rename to e2e/runner/cypress-runner-generate-snapshots.js index 32ad61198e4c9..9c9eddcb50077 100644 --- a/frontend/test/__runner__/cypress-runner-generate-snapshots.js +++ b/e2e/runner/cypress-runner-generate-snapshots.js @@ -1,9 +1,11 @@ const cypress = require("cypress"); +const { parseArguments, args } = require("./cypress-runner-utils"); + const getConfig = baseUrl => { return { browser: "chrome", - configFile: "frontend/test/__support__/e2e/cypress-snapshots.config.js", + configFile: "e2e/support/cypress-snapshots.config.js", config: { baseUrl, }, @@ -11,7 +13,14 @@ const getConfig = baseUrl => { }; const generateSnapshots = async (baseUrl, exitFunction) => { - const snapshotConfig = getConfig(baseUrl); + // We only ever care about a broswer out of all possible user arguments, + // when it comes to the snapshot generation. + // Anything else could result either in a failure or in a wrong database snapshot! + const { browser } = await parseArguments(args); + const customBrowser = browser ? { browser } : null; + + const baseConfig = getConfig(baseUrl); + const snapshotConfig = Object.assign({}, baseConfig, customBrowser); try { const { status, message, totalFailed, failures } = await cypress.run( diff --git a/frontend/test/__runner__/cypress-runner-get-version.js b/e2e/runner/cypress-runner-get-version.js similarity index 61% rename from frontend/test/__runner__/cypress-runner-get-version.js rename to e2e/runner/cypress-runner-get-version.js index 86712523f2092..19b136958316b 100644 --- a/frontend/test/__runner__/cypress-runner-get-version.js +++ b/e2e/runner/cypress-runner-get-version.js @@ -4,18 +4,18 @@ const { printBold, printCyan } = require("./cypress-runner-utils.js"); const getVersion = async () => { try { const version = fs.readFileSync( - __dirname + "/../../../resources/version.properties", + __dirname + "/../../resources/version.properties", ); printBold("Running e2e test runner with this build:"); printCyan(version); printBold( - "If that version seems too old, please run `./bin/build version uberjar`.", + "If that version seems too old, please run `./bin/build.sh :steps '[:version :uberjar]'`.", ); } catch (e) { printBold( - "No version file found. Please run `./bin/build version uberjar`.", + "No version file found. Please run `./bin/build.sh :steps '[:version :uberjar]'`.", ); process.exit(1); diff --git a/frontend/test/__runner__/cypress-runner-run-tests.js b/e2e/runner/cypress-runner-run-tests.js similarity index 60% rename from frontend/test/__runner__/cypress-runner-run-tests.js rename to e2e/runner/cypress-runner-run-tests.js index bd7d491c6e688..dc1e9c8773d73 100644 --- a/frontend/test/__runner__/cypress-runner-run-tests.js +++ b/e2e/runner/cypress-runner-run-tests.js @@ -1,38 +1,18 @@ const cypress = require("cypress"); -const arg = require("arg"); -const { executeYarnCommand } = require("./cypress-runner-utils"); - -const args = arg( - { - "--folder": String, // The name of the folder to run files from - "--open": [Boolean], // Run Cypress in open mode or not? Doesn't accept additional arguments - }, - { permissive: true }, // Passes all other flags and args to the Cypress parser -); +const { + executeYarnCommand, + parseArguments, + args, +} = require("./cypress-runner-utils"); const folder = args["--folder"]; const isFolder = !!folder; const isOpenMode = args["--open"]; -const parseArguments = async () => { - const cliArgs = args._; - - // cypress.cli.parseArguments requires `cypress run` as the first two arguments - if (cliArgs[0] !== "cypress") { - cliArgs.unshift("cypress"); - } - - if (cliArgs[1] !== "run") { - cliArgs.splice(1, 0, "run"); - } - - return await cypress.cli.parseRunArguments(cliArgs); -}; - const getSourceFolder = folder => { - return `./frontend/test/metabase/scenarios/${folder}/**/*.cy.spec.js`; + return `./e2e/test/scenarios/${folder}/**/*.cy.spec.js`; }; const runCypress = async (baseUrl, exitFunction) => { @@ -43,14 +23,14 @@ const runCypress = async (baseUrl, exitFunction) => { const defaultConfig = { browser: "chrome", - configFile: "frontend/test/__support__/e2e/cypress.config.js", + configFile: "e2e/support/cypress.config.js", config: { baseUrl, }, spec: isFolder && getSourceFolder(folder), }; - const userArgs = await parseArguments(); + const userArgs = await parseArguments(args); const finalConfig = Object.assign({}, defaultConfig, userArgs); diff --git a/e2e/runner/cypress-runner-utils.js b/e2e/runner/cypress-runner-utils.js new file mode 100644 index 0000000000000..4e4b00747b722 --- /dev/null +++ b/e2e/runner/cypress-runner-utils.js @@ -0,0 +1,65 @@ +const { exec } = require("child_process"); +const arg = require("arg"); +const chalk = require("chalk"); +const cypress = require("cypress"); + +function printBold(message) { + console.log(chalk.bold(message)); +} + +function printYellow(message) { + console.log(chalk.yellow(message)); +} + +function printCyan(message) { + console.log(chalk.cyan(message)); +} + +function executeYarnCommand({ command, message } = {}) { + return new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + console.error(stderr); + + reject(error); + return; + } + + printBold(message); + + resolve(stdout); + }); + }); +} + +const args = arg( + { + "--folder": String, // The name of the folder to run files from + "--open": [Boolean], // Run Cypress in open mode or not? Doesn't accept additional arguments + }, + { permissive: true }, // Passes all other flags and args to the Cypress parser +); + +async function parseArguments(args) { + const cliArgs = args._; + + // cypress.cli.parseArguments requires `cypress run` as the first two arguments + if (cliArgs[0] !== "cypress") { + cliArgs.unshift("cypress"); + } + + if (cliArgs[1] !== "run") { + cliArgs.splice(1, 0, "run"); + } + + return await cypress.cli.parseRunArguments(cliArgs); +} + +module.exports = { + printBold, + printYellow, + printCyan, + executeYarnCommand, + parseArguments, + args, +}; diff --git a/frontend/test/__runner__/empty.db.mv.db b/e2e/runner/empty.db.mv.db similarity index 100% rename from frontend/test/__runner__/empty.db.mv.db rename to e2e/runner/empty.db.mv.db diff --git a/frontend/test/__runner__/run_cypress_tests.js b/e2e/runner/run_cypress_tests.js similarity index 100% rename from frontend/test/__runner__/run_cypress_tests.js rename to e2e/runner/run_cypress_tests.js diff --git a/frontend/test/snapshot-creators/default.cy.snap.js b/e2e/snapshot-creators/default.cy.snap.js similarity index 97% rename from frontend/test/snapshot-creators/default.cy.snap.js rename to e2e/snapshot-creators/default.cy.snap.js index d5b3304e863b6..628bd99578b74 100644 --- a/frontend/test/snapshot-creators/default.cy.snap.js +++ b/e2e/snapshot-creators/default.cy.snap.js @@ -1,12 +1,12 @@ import _ from "underscore"; -import { snapshot, restore, withSampleDatabase } from "__support__/e2e/helpers"; +import { snapshot, restore, withSampleDatabase } from "e2e/support/helpers"; import { USERS, USER_GROUPS, SAMPLE_DB_ID, SAMPLE_DB_TABLES, METABASE_SECRET_KEY, -} from "__support__/e2e/cypress_data"; +} from "e2e/support/cypress_data"; const { STATIC_ORDERS_ID, @@ -42,7 +42,7 @@ describe("snapshots", () => { hideNewSampleTables(SAMPLE_DATABASE); createQuestionsAndDashboards(SAMPLE_DATABASE); cy.writeFile( - "frontend/test/__support__/e2e/cypress_sample_database.json", + "e2e/support/cypress_sample_database.json", SAMPLE_DATABASE, ); }); diff --git a/frontend/test/snapshot-creators/qa-db.cy.snap.js b/e2e/snapshot-creators/qa-db.cy.snap.js similarity index 97% rename from frontend/test/snapshot-creators/qa-db.cy.snap.js rename to e2e/snapshot-creators/qa-db.cy.snap.js index 8c791126de4cf..17f92608aca3b 100644 --- a/frontend/test/snapshot-creators/qa-db.cy.snap.js +++ b/e2e/snapshot-creators/qa-db.cy.snap.js @@ -5,7 +5,7 @@ import { addMongoDatabase, addMySQLDatabase, setupWritableDB, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("qa databases snapshots", { tags: "@external" }, () => { beforeEach(() => { diff --git a/frontend/test/__support__/e2e/assets/logo.jpeg b/e2e/support/assets/logo.jpeg similarity index 100% rename from frontend/test/__support__/e2e/assets/logo.jpeg rename to e2e/support/assets/logo.jpeg diff --git a/frontend/test/__support__/e2e/commands.js b/e2e/support/commands.js similarity index 96% rename from frontend/test/__support__/e2e/commands.js rename to e2e/support/commands.js index 5ee491fdd5b8a..1799e7de275a8 100644 --- a/frontend/test/__support__/e2e/commands.js +++ b/e2e/support/commands.js @@ -5,7 +5,6 @@ import "./commands/api/alert"; import "./commands/api/question"; import "./commands/api/dashboard"; import "./commands/api/dashboardCard"; -import "./commands/api/dashboardFilters"; import "./commands/api/collection"; import "./commands/api/moderation"; import "./commands/api/pulse"; diff --git a/frontend/test/__support__/e2e/commands/api/alert.js b/e2e/support/commands/api/alert.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/alert.js rename to e2e/support/commands/api/alert.js diff --git a/frontend/test/__support__/e2e/commands/api/collection.js b/e2e/support/commands/api/collection.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/collection.js rename to e2e/support/commands/api/collection.js diff --git a/frontend/test/__support__/e2e/commands/api/composite/createDashboardWithQuestions.js b/e2e/support/commands/api/composite/createDashboardWithQuestions.js similarity index 89% rename from frontend/test/__support__/e2e/commands/api/composite/createDashboardWithQuestions.js rename to e2e/support/commands/api/composite/createDashboardWithQuestions.js index c0ed0a1c8c849..72bb6f9024860 100644 --- a/frontend/test/__support__/e2e/commands/api/composite/createDashboardWithQuestions.js +++ b/e2e/support/commands/api/composite/createDashboardWithQuestions.js @@ -1,4 +1,4 @@ -import { cypressWaitAll } from "__support__/e2e/helpers"; +import { cypressWaitAll } from "e2e/support/helpers"; Cypress.Commands.add( "createDashboardWithQuestions", diff --git a/frontend/test/__support__/e2e/commands/api/composite/createNativeQuestionAndDashboard.js b/e2e/support/commands/api/composite/createNativeQuestionAndDashboard.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/composite/createNativeQuestionAndDashboard.js rename to e2e/support/commands/api/composite/createNativeQuestionAndDashboard.js diff --git a/frontend/test/__support__/e2e/commands/api/composite/createQuestionAndAddToDashboard.js b/e2e/support/commands/api/composite/createQuestionAndAddToDashboard.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/composite/createQuestionAndAddToDashboard.js rename to e2e/support/commands/api/composite/createQuestionAndAddToDashboard.js diff --git a/frontend/test/__support__/e2e/commands/api/composite/createQuestionAndDashboard.js b/e2e/support/commands/api/composite/createQuestionAndDashboard.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/composite/createQuestionAndDashboard.js rename to e2e/support/commands/api/composite/createQuestionAndDashboard.js diff --git a/frontend/test/__support__/e2e/commands/api/composite/createTimelineWithEvents.js b/e2e/support/commands/api/composite/createTimelineWithEvents.js similarity index 86% rename from frontend/test/__support__/e2e/commands/api/composite/createTimelineWithEvents.js rename to e2e/support/commands/api/composite/createTimelineWithEvents.js index 3eb6cf64ca3d3..30e26d8291437 100644 --- a/frontend/test/__support__/e2e/commands/api/composite/createTimelineWithEvents.js +++ b/e2e/support/commands/api/composite/createTimelineWithEvents.js @@ -1,4 +1,4 @@ -import { cypressWaitAll } from "__support__/e2e/helpers"; +import { cypressWaitAll } from "e2e/support/helpers"; Cypress.Commands.add("createTimelineWithEvents", ({ timeline, events }) => { return cy.createTimeline(timeline).then(({ body: timeline }) => { diff --git a/frontend/test/__support__/e2e/commands/api/dashboard.js b/e2e/support/commands/api/dashboard.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/dashboard.js rename to e2e/support/commands/api/dashboard.js diff --git a/frontend/test/__support__/e2e/commands/api/dashboardCard.js b/e2e/support/commands/api/dashboardCard.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/dashboardCard.js rename to e2e/support/commands/api/dashboardCard.js diff --git a/frontend/test/__support__/e2e/commands/api/moderation.js b/e2e/support/commands/api/moderation.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/moderation.js rename to e2e/support/commands/api/moderation.js diff --git a/frontend/test/__support__/e2e/commands/api/pulse.js b/e2e/support/commands/api/pulse.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/pulse.js rename to e2e/support/commands/api/pulse.js diff --git a/frontend/test/__support__/e2e/commands/api/question.js b/e2e/support/commands/api/question.js similarity index 95% rename from frontend/test/__support__/e2e/commands/api/question.js rename to e2e/support/commands/api/question.js index 4a968eea94a01..189c5ada4b1c7 100644 --- a/frontend/test/__support__/e2e/commands/api/question.js +++ b/e2e/support/commands/api/question.js @@ -1,4 +1,4 @@ -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; Cypress.Commands.add("createQuestion", (questionDetails, customOptions) => { const { name, query } = questionDetails; @@ -106,7 +106,7 @@ function question( if (loadMetadata || visitQuestion) { dataset ? cy.intercept("POST", `/api/dataset`).as("dataset") - : // We need to use the wildcard becase endpoint for pivot tables has the following format: `/api/card/pivot/${id}/query` + : // We need to use the wildcard because endpoint for pivot tables has the following format: `/api/card/pivot/${id}/query` cy .intercept("POST", `/api/card/**/${body.id}/query`) .as(interceptAlias); diff --git a/frontend/test/__support__/e2e/commands/api/timeline.js b/e2e/support/commands/api/timeline.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/timeline.js rename to e2e/support/commands/api/timeline.js diff --git a/frontend/test/__support__/e2e/commands/api/user.js b/e2e/support/commands/api/user.js similarity index 100% rename from frontend/test/__support__/e2e/commands/api/user.js rename to e2e/support/commands/api/user.js diff --git a/frontend/test/__support__/e2e/commands/database/addH2SampleDatabase.js b/e2e/support/commands/database/addH2SampleDatabase.js similarity index 100% rename from frontend/test/__support__/e2e/commands/database/addH2SampleDatabase.js rename to e2e/support/commands/database/addH2SampleDatabase.js diff --git a/frontend/test/__support__/e2e/commands/overwrites/log.js b/e2e/support/commands/overwrites/log.js similarity index 100% rename from frontend/test/__support__/e2e/commands/overwrites/log.js rename to e2e/support/commands/overwrites/log.js diff --git a/frontend/test/__support__/e2e/commands/percy/createPercySnapshot.js b/e2e/support/commands/percy/createPercySnapshot.js similarity index 100% rename from frontend/test/__support__/e2e/commands/percy/createPercySnapshot.js rename to e2e/support/commands/percy/createPercySnapshot.js diff --git a/frontend/test/__support__/e2e/commands/permissions/sandboxTable.js b/e2e/support/commands/permissions/sandboxTable.js similarity index 93% rename from frontend/test/__support__/e2e/commands/permissions/sandboxTable.js rename to e2e/support/commands/permissions/sandboxTable.js index 61731b9160abe..037e23e284b80 100644 --- a/frontend/test/__support__/e2e/commands/permissions/sandboxTable.js +++ b/e2e/support/commands/permissions/sandboxTable.js @@ -1,4 +1,4 @@ -import { USER_GROUPS, SAMPLE_DB_TABLES } from "__support__/e2e/cypress_data"; +import { USER_GROUPS, SAMPLE_DB_TABLES } from "e2e/support/cypress_data"; const { STATIC_ORDERS_ID } = SAMPLE_DB_TABLES; diff --git a/frontend/test/__support__/e2e/commands/permissions/updatePermissions.js b/e2e/support/commands/permissions/updatePermissions.js similarity index 97% rename from frontend/test/__support__/e2e/commands/permissions/updatePermissions.js rename to e2e/support/commands/permissions/updatePermissions.js index 23a2b008666b3..d1adbc2d9829d 100644 --- a/frontend/test/__support__/e2e/commands/permissions/updatePermissions.js +++ b/e2e/support/commands/permissions/updatePermissions.js @@ -1,4 +1,4 @@ -import { SAMPLE_DB_ID, USER_GROUPS } from "__support__/e2e/cypress_data"; +import { SAMPLE_DB_ID, USER_GROUPS } from "e2e/support/cypress_data"; const { COLLECTION_GROUP } = USER_GROUPS; diff --git a/frontend/test/__support__/e2e/commands/ui/button.js b/e2e/support/commands/ui/button.js similarity index 100% rename from frontend/test/__support__/e2e/commands/ui/button.js rename to e2e/support/commands/ui/button.js diff --git a/frontend/test/__support__/e2e/commands/ui/icon.js b/e2e/support/commands/ui/icon.js similarity index 100% rename from frontend/test/__support__/e2e/commands/ui/icon.js rename to e2e/support/commands/ui/icon.js diff --git a/frontend/test/__support__/e2e/commands/user/authentication.js b/e2e/support/commands/user/authentication.js similarity index 91% rename from frontend/test/__support__/e2e/commands/user/authentication.js rename to e2e/support/commands/user/authentication.js index dd667311ae46a..90ef7b60e9d05 100644 --- a/frontend/test/__support__/e2e/commands/user/authentication.js +++ b/e2e/support/commands/user/authentication.js @@ -1,4 +1,4 @@ -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; Cypress.Commands.add("signIn", (user = "admin") => { const { email: username, password } = USERS[user]; diff --git a/frontend/test/__support__/e2e/commands/user/createUser.js b/e2e/support/commands/user/createUser.js similarity index 87% rename from frontend/test/__support__/e2e/commands/user/createUser.js rename to e2e/support/commands/user/createUser.js index 0d00729cb3928..14d52b2ea61e4 100644 --- a/frontend/test/__support__/e2e/commands/user/createUser.js +++ b/e2e/support/commands/user/createUser.js @@ -1,4 +1,4 @@ -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; Cypress.Commands.add("createUserFromRawData", user => { return cy.request("POST", "/api/user", user).then(({ body }) => { diff --git a/frontend/test/__support__/e2e/commands/visibility/findByTextEnsureVisible.js b/e2e/support/commands/visibility/findByTextEnsureVisible.js similarity index 100% rename from frontend/test/__support__/e2e/commands/visibility/findByTextEnsureVisible.js rename to e2e/support/commands/visibility/findByTextEnsureVisible.js diff --git a/frontend/test/__support__/e2e/commands/visibility/isRenderedWithinViewport.js b/e2e/support/commands/visibility/isRenderedWithinViewport.js similarity index 100% rename from frontend/test/__support__/e2e/commands/visibility/isRenderedWithinViewport.js rename to e2e/support/commands/visibility/isRenderedWithinViewport.js diff --git a/frontend/test/__support__/e2e/commands/visibility/isVisibleInPopover.js b/e2e/support/commands/visibility/isVisibleInPopover.js similarity index 100% rename from frontend/test/__support__/e2e/commands/visibility/isVisibleInPopover.js rename to e2e/support/commands/visibility/isVisibleInPopover.js diff --git a/frontend/test/__support__/e2e/config.js b/e2e/support/config.js similarity index 89% rename from frontend/test/__support__/e2e/config.js rename to e2e/support/config.js index 69283c113d058..bb3b75f6964c8 100644 --- a/frontend/test/__support__/e2e/config.js +++ b/e2e/support/config.js @@ -83,13 +83,12 @@ const defaultConfig = { ********************************************************************/ if (!isQaDatabase) { - config.excludeSpecPattern = - "frontend/test/snapshot-creators/qa-db.cy.snap.js"; + config.excludeSpecPattern = "e2e/snapshot-creators/qa-db.cy.snap.js"; } // `grepIntegrationFolder` needs to point to the root! // See: https://github.com/cypress-io/cypress/issues/24452#issuecomment-1295377775 - config.env.grepIntegrationFolder = "../../../../"; + config.env.grepIntegrationFolder = "../../"; config.env.grepFilterSpecs = true; config.env.HAS_ENTERPRISE_TOKEN = hasEnterpriseToken; @@ -102,7 +101,7 @@ const defaultConfig = { return config; }, - supportFile: "frontend/test/__support__/e2e/cypress.js", + supportFile: "e2e/support/cypress.js", videoUploadOnPasses: false, chromeWebSecurity: false, modifyObstructiveCode: false, @@ -113,7 +112,7 @@ const mainConfig = { // New `specPattern` is the combination of the old: // 1. testFiles and // 2. integrationFolder - specPattern: "frontend/test/**/*.cy.spec.js", + specPattern: "e2e/test/**/*.cy.spec.js", projectId: "KetpiS", viewportHeight: 800, viewportWidth: 1280, @@ -126,28 +125,26 @@ const mainConfig = { json: true, }, retries: { - runMode: 2, + runMode: 5, openMode: 0, }, }; const snapshotsConfig = { ...defaultConfig, - specPattern: "frontend/test/snapshot-creators/**/*.cy.snap.js", + specPattern: "e2e/snapshot-creators/**/*.cy.snap.js", }; const crossVersionSourceConfig = { ...defaultConfig, baseUrl: "http://localhost:3000", - specPattern: - "frontend/test/metabase/scenarios/cross-version/source/**/*.cy.spec.js", + specPattern: "e2e/test/scenarios/cross-version/source/**/*.cy.spec.js", }; const crossVersionTargetConfig = { ...defaultConfig, baseUrl: "http://localhost:3001", - specPattern: - "frontend/test/metabase/scenarios/cross-version/target/**/*.cy.spec.js", + specPattern: "e2e/test/scenarios/cross-version/target/**/*.cy.spec.js", }; module.exports = { diff --git a/frontend/test/__support__/e2e/cypress-snapshots.config.js b/e2e/support/cypress-snapshots.config.js similarity index 100% rename from frontend/test/__support__/e2e/cypress-snapshots.config.js rename to e2e/support/cypress-snapshots.config.js diff --git a/frontend/test/__support__/e2e/cypress.config.js b/e2e/support/cypress.config.js similarity index 100% rename from frontend/test/__support__/e2e/cypress.config.js rename to e2e/support/cypress.config.js diff --git a/frontend/test/__support__/e2e/cypress.js b/e2e/support/cypress.js similarity index 100% rename from frontend/test/__support__/e2e/cypress.js rename to e2e/support/cypress.js diff --git a/frontend/test/__support__/e2e/cypress_data.js b/e2e/support/cypress_data.js similarity index 100% rename from frontend/test/__support__/e2e/cypress_data.js rename to e2e/support/cypress_data.js diff --git a/frontend/test/__support__/e2e/cypress_sample_database.js b/e2e/support/cypress_sample_database.js similarity index 83% rename from frontend/test/__support__/e2e/cypress_sample_database.js rename to e2e/support/cypress_sample_database.js index afb460056a388..bc966cf7797d3 100644 --- a/frontend/test/__support__/e2e/cypress_sample_database.js +++ b/e2e/support/cypress_sample_database.js @@ -1,6 +1,6 @@ /** * This JSON file gets recreated every time Cypress starts. - * See: `frontend/test/snapshot-creators/default.cy.snap.js:19` + * See: `e2e/snapshot-creators/default.cy.snap.js:19` * * - It had to be added to `.gitignore`. * - It contains extracted metadata from sample database (table ids and field ids) diff --git a/frontend/test/__support__/e2e/db_tasks.js b/e2e/support/db_tasks.js similarity index 100% rename from frontend/test/__support__/e2e/db_tasks.js rename to e2e/support/db_tasks.js diff --git a/frontend/test/__support__/e2e/external/e2e-jwt-sign.js b/e2e/support/external/e2e-jwt-sign.js similarity index 100% rename from frontend/test/__support__/e2e/external/e2e-jwt-sign.js rename to e2e/support/external/e2e-jwt-sign.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-action-helpers.js b/e2e/support/helpers/e2e-action-helpers.js similarity index 82% rename from frontend/test/__support__/e2e/helpers/e2e-action-helpers.js rename to e2e/support/helpers/e2e-action-helpers.js index 93a805d8b9e59..43fb0a1e1fff3 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-action-helpers.js +++ b/e2e/support/helpers/e2e-action-helpers.js @@ -1,15 +1,17 @@ import { capitalize } from "inflection"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -export function enableActionsForDB(dbId = SAMPLE_DB_ID) { + +export function setActionsEnabledForDB(dbId, enabled = true) { return cy.request("PUT", `/api/database/${dbId}`, { settings: { - "database-enable-actions": true, + "database-enable-actions": enabled, }, }); } export function fillActionQuery(query) { - cy.get(".ace_content").type(query, { parseSpecialCharSequences: false }); + cy.get(".ace_content:visible").type(query, { + parseSpecialCharSequences: false, + }); } /** * diff --git a/frontend/test/__support__/e2e/helpers/e2e-ad-hoc-question-helpers.js b/e2e/support/helpers/e2e-ad-hoc-question-helpers.js similarity index 96% rename from frontend/test/__support__/e2e/helpers/e2e-ad-hoc-question-helpers.js rename to e2e/support/helpers/e2e-ad-hoc-question-helpers.js index 2ff5a272efe43..d76f17a4c0b3a 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-ad-hoc-question-helpers.js +++ b/e2e/support/helpers/e2e-ad-hoc-question-helpers.js @@ -1,5 +1,5 @@ -import { SAMPLE_DB_ID, SAMPLE_DB_TABLES } from "__support__/e2e/cypress_data"; -import { runNativeQuery } from "__support__/e2e/helpers/e2e-misc-helpers"; +import { SAMPLE_DB_ID, SAMPLE_DB_TABLES } from "e2e/support/cypress_data"; +import { runNativeQuery } from "e2e/support/helpers/e2e-misc-helpers"; const { STATIC_ORDERS_ID, diff --git a/frontend/test/__support__/e2e/helpers/e2e-bi-basics-helpers.js b/e2e/support/helpers/e2e-bi-basics-helpers.js similarity index 98% rename from frontend/test/__support__/e2e/helpers/e2e-bi-basics-helpers.js rename to e2e/support/helpers/e2e-bi-basics-helpers.js index cede8a5f99749..ade02a5fa567a 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-bi-basics-helpers.js +++ b/e2e/support/helpers/e2e-bi-basics-helpers.js @@ -1,4 +1,4 @@ -import { popover } from "__support__/e2e/helpers"; +import { popover } from "e2e/support/helpers"; /** * Initiate Summarize action diff --git a/frontend/test/__support__/e2e/helpers/e2e-boolean-helpers.js b/e2e/support/helpers/e2e-boolean-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-boolean-helpers.js rename to e2e/support/helpers/e2e-boolean-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-cloud-helpers.js b/e2e/support/helpers/e2e-cloud-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-cloud-helpers.js rename to e2e/support/helpers/e2e-cloud-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-collection-helpers.js b/e2e/support/helpers/e2e-collection-helpers.js similarity index 94% rename from frontend/test/__support__/e2e/helpers/e2e-collection-helpers.js rename to e2e/support/helpers/e2e-collection-helpers.js index a8b1c791dd324..bee5dcca16b90 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-collection-helpers.js +++ b/e2e/support/helpers/e2e-collection-helpers.js @@ -1,4 +1,4 @@ -import { getFullName, popover } from "__support__/e2e/helpers"; +import { getFullName, popover } from "e2e/support/helpers"; /** * Clicks the "+" icon on the collection page and selects one of the menu options diff --git a/frontend/test/__support__/e2e/helpers/e2e-custom-column-helpers.js b/e2e/support/helpers/e2e-custom-column-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-custom-column-helpers.js rename to e2e/support/helpers/e2e-custom-column-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-dashboard-helpers.js b/e2e/support/helpers/e2e-dashboard-helpers.js similarity index 86% rename from frontend/test/__support__/e2e/helpers/e2e-dashboard-helpers.js rename to e2e/support/helpers/e2e-dashboard-helpers.js index 3d38b3904da57..14f5243923557 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-dashboard-helpers.js +++ b/e2e/support/helpers/e2e-dashboard-helpers.js @@ -19,9 +19,12 @@ export function editDashboard() { cy.findByText("You're editing this dashboard."); } -export function saveDashboard() { - cy.findByText("Save").click(); - cy.findByText("You're editing this dashboard.").should("not.exist"); +export function saveDashboard({ + buttonLabel = "Save", + editBarText = "You're editing this dashboard.", +} = {}) { + cy.findByText(buttonLabel).click(); + cy.findByText(editBarText).should("not.exist"); cy.wait(1); // this is stupid but necessary to due to the dashboard resizing and detaching elements } diff --git a/frontend/test/__support__/e2e/helpers/e2e-data-model-helpers.js b/e2e/support/helpers/e2e-data-model-helpers.js similarity index 77% rename from frontend/test/__support__/e2e/helpers/e2e-data-model-helpers.js rename to e2e/support/helpers/e2e-data-model-helpers.js index a275744a36727..7505b1539da3b 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-data-model-helpers.js +++ b/e2e/support/helpers/e2e-data-model-helpers.js @@ -1,6 +1,6 @@ export function remapDisplayValueToFK({ display_value, name, fk } = {}) { // Both display_value and fk are expected to be field IDs - // You can get them from frontend/test/__support__/e2e/cypress_sample_database.json + // You can get them from e2e/support/cypress_sample_database.json cy.request("POST", `/api/field/${display_value}/dimension`, { field_id: display_value, name, diff --git a/frontend/test/__support__/e2e/helpers/e2e-database-metadata-helpers.js b/e2e/support/helpers/e2e-database-metadata-helpers.js similarity index 90% rename from frontend/test/__support__/e2e/helpers/e2e-database-metadata-helpers.js rename to e2e/support/helpers/e2e-database-metadata-helpers.js index edd2dc92c9d4c..2c5d051ca3918 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-database-metadata-helpers.js +++ b/e2e/support/helpers/e2e-database-metadata-helpers.js @@ -1,4 +1,4 @@ -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; export function withDatabase(databaseId, f) { cy.request( diff --git a/frontend/test/__support__/e2e/helpers/e2e-dimension-list-helpers.js b/e2e/support/helpers/e2e-dimension-list-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-dimension-list-helpers.js rename to e2e/support/helpers/e2e-dimension-list-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-downloads-helpers.js b/e2e/support/helpers/e2e-downloads-helpers.js similarity index 86% rename from frontend/test/__support__/e2e/helpers/e2e-downloads-helpers.js rename to e2e/support/helpers/e2e-downloads-helpers.js index 99cb45ed7074b..1e8f218629106 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-downloads-helpers.js +++ b/e2e/support/helpers/e2e-downloads-helpers.js @@ -12,11 +12,25 @@ const xlsx = require("xlsx"); * @param {function} callback */ export function downloadAndAssert( - { fileType, questionId, raw, logResults, publicUid } = {}, + { + fileType, + questionId, + raw, + logResults, + publicUid, + dashcardId, + dashboardId, + } = {}, callback, ) { const downloadClassName = `.Icon-${fileType}`; - const endpoint = getEndpoint(fileType, questionId, publicUid); + const endpoint = getEndpoint( + fileType, + questionId, + publicUid, + dashcardId, + dashboardId, + ); const isPublicDownload = !!publicUid; const method = isPublicDownload ? "GET" : "POST"; @@ -78,7 +92,11 @@ export function assertSheetRowsCount(expectedCount) { }; } -function getEndpoint(fileType, questionId, publicUid) { +function getEndpoint(fileType, questionId, publicUid, dashcardId, dashboardId) { + if (dashcardId != null && dashboardId != null) { + return `api/dashboard/${dashboardId}/dashcard/${dashcardId}/card/${questionId}/query/${fileType}`; + } + if (publicUid) { return `/public/question/${publicUid}.${fileType}**`; } diff --git a/e2e/support/helpers/e2e-dragndrop-helpers.js b/e2e/support/helpers/e2e-dragndrop-helpers.js new file mode 100644 index 0000000000000..9870436bf1848 --- /dev/null +++ b/e2e/support/helpers/e2e-dragndrop-helpers.js @@ -0,0 +1,39 @@ +// Rely on native drag events, rather than on the coordinates +// We have 3 "drag-handles" in this test. Their indexes are 0-based. +export function dragField(startIndex, dropIndex) { + cy.get(".Icon-grabber2").should("be.visible").as("dragHandle"); + + const BUTTON_INDEX = 0; + const SLOPPY_CLICK_THRESHOLD = 10; + cy.get("@dragHandle") + .eq(dropIndex) + .then($target => { + const coordsDrop = $target[0].getBoundingClientRect(); + cy.get("@dragHandle") + .eq(startIndex) + .then(subject => { + const coordsDrag = subject[0].getBoundingClientRect(); + cy.wrap(subject) + .trigger("mousedown", { + button: BUTTON_INDEX, + clientX: coordsDrag.x, + clientY: coordsDrag.y, + force: true, + }) + .trigger("mousemove", { + button: BUTTON_INDEX, + clientX: coordsDrag.x + SLOPPY_CLICK_THRESHOLD, + clientY: coordsDrag.y, + force: true, + }); + cy.get("body") + .trigger("mousemove", { + button: BUTTON_INDEX, + clientX: coordsDrop.x, + clientY: coordsDrop.y, + force: true, + }) + .trigger("mouseup"); + }); + }); +} diff --git a/frontend/test/__support__/e2e/helpers/e2e-email-helpers.js b/e2e/support/helpers/e2e-email-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-email-helpers.js rename to e2e/support/helpers/e2e-email-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-embedding-helpers.js b/e2e/support/helpers/e2e-embedding-helpers.js similarity index 93% rename from frontend/test/__support__/e2e/helpers/e2e-embedding-helpers.js rename to e2e/support/helpers/e2e-embedding-helpers.js index 87d88a65dfb1e..053a5aa47fd4a 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-embedding-helpers.js +++ b/e2e/support/helpers/e2e-embedding-helpers.js @@ -1,7 +1,6 @@ -import { METABASE_SECRET_KEY } from "__support__/e2e/cypress_data"; +import { METABASE_SECRET_KEY } from "e2e/support/cypress_data"; -const jwtSignLocation = - "frontend/test/__support__/e2e/external/e2e-jwt-sign.js"; +const jwtSignLocation = "e2e/support/external/e2e-jwt-sign.js"; /** * Programatically generate token and visit the embedded page for question or dashboard diff --git a/frontend/test/__support__/e2e/helpers/e2e-enterprise-helpers.js b/e2e/support/helpers/e2e-enterprise-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-enterprise-helpers.js rename to e2e/support/helpers/e2e-enterprise-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-filter-helpers.js b/e2e/support/helpers/e2e-filter-helpers.js similarity index 92% rename from frontend/test/__support__/e2e/helpers/e2e-filter-helpers.js rename to e2e/support/helpers/e2e-filter-helpers.js index 4b34ebd34c105..e43cf611846db 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-filter-helpers.js +++ b/e2e/support/helpers/e2e-filter-helpers.js @@ -1,7 +1,4 @@ -import { - modal, - popover, -} from "__support__/e2e/helpers/e2e-ui-elements-helpers"; +import { modal, popover } from "e2e/support/helpers/e2e-ui-elements-helpers"; export function setDropdownFilterType() { cy.findByText("Dropdown list").click(); diff --git a/frontend/test/__support__/e2e/helpers/e2e-ldap-helpers.js b/e2e/support/helpers/e2e-ldap-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-ldap-helpers.js rename to e2e/support/helpers/e2e-ldap-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-misc-helpers.js b/e2e/support/helpers/e2e-misc-helpers.js similarity index 96% rename from frontend/test/__support__/e2e/helpers/e2e-misc-helpers.js rename to e2e/support/helpers/e2e-misc-helpers.js index 07298563b8274..ba34de0911d24 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-misc-helpers.js +++ b/e2e/support/helpers/e2e-misc-helpers.js @@ -1,4 +1,4 @@ -import { modal } from "__support__/e2e/helpers/e2e-ui-elements-helpers"; +import { modal } from "e2e/support/helpers/e2e-ui-elements-helpers"; // Find a text field by label text, type it in, then blur the field. // Commonly used in our Admin section as we auto-save settings. @@ -36,7 +36,7 @@ export function openNativeEditor({ databaseName && cy.findByText(databaseName).click(); - return cy.get(".ace_content").as(alias).should("be.visible"); + return cy.findByTestId("native-query-editor").as(alias).should("be.visible"); } /** @@ -124,7 +124,7 @@ export function visitQuestion(id) { // In case we use this function multiple times in a test, make sure aliases are unique for each question const alias = "cardQuery" + id; - // We need to use the wildcard becase endpoint for pivot tables has the following format: `/api/card/pivot/${id}/query` + // We need to use the wildcard because endpoint for pivot tables has the following format: `/api/card/pivot/${id}/query` cy.intercept("POST", `/api/card/**/${id}/query`).as(alias); cy.visit(`/question/${id}`); diff --git a/frontend/test/__support__/e2e/helpers/e2e-mock-app-settings-helpers.js b/e2e/support/helpers/e2e-mock-app-settings-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-mock-app-settings-helpers.js rename to e2e/support/helpers/e2e-mock-app-settings-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-notebook-helpers.js b/e2e/support/helpers/e2e-notebook-helpers.js similarity index 95% rename from frontend/test/__support__/e2e/helpers/e2e-notebook-helpers.js rename to e2e/support/helpers/e2e-notebook-helpers.js index e8807d03be211..31f2cb794c52e 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-notebook-helpers.js +++ b/e2e/support/helpers/e2e-notebook-helpers.js @@ -1,4 +1,4 @@ -import { popover } from "__support__/e2e/helpers/e2e-ui-elements-helpers"; +import { popover } from "e2e/support/helpers/e2e-ui-elements-helpers"; export function openNotebook() { return cy.icon("notebook").click(); diff --git a/frontend/test/__support__/e2e/helpers/e2e-permissions-helpers.js b/e2e/support/helpers/e2e-permissions-helpers.js similarity index 97% rename from frontend/test/__support__/e2e/helpers/e2e-permissions-helpers.js rename to e2e/support/helpers/e2e-permissions-helpers.js index 9bc51578aff44..835264eb389df 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-permissions-helpers.js +++ b/e2e/support/helpers/e2e-permissions-helpers.js @@ -1,4 +1,4 @@ -import { popover } from "__support__/e2e/helpers"; +import { popover } from "e2e/support/helpers"; export function selectSidebarItem(item) { cy.findAllByRole("menuitem").contains(item).click(); diff --git a/frontend/test/__support__/e2e/helpers/e2e-qa-databases-helpers.js b/e2e/support/helpers/e2e-qa-databases-helpers.js similarity index 83% rename from frontend/test/__support__/e2e/helpers/e2e-qa-databases-helpers.js rename to e2e/support/helpers/e2e-qa-databases-helpers.js index 84500abc580f4..c77b405273e83 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-qa-databases-helpers.js +++ b/e2e/support/helpers/e2e-qa-databases-helpers.js @@ -6,7 +6,7 @@ import { WRITABLE_DB_CONFIG, WRITABLE_DB_ID, QA_DB_CONFIG, -} from "__support__/e2e/cypress_data"; +} from "e2e/support/cypress_data"; /***************************************** ** QA DATABASES ** @@ -185,25 +185,52 @@ export function getTableId({ databaseId = WRITABLE_DB_ID, name }) { }); } -export function waitForSyncToFinish(iteration = 0, databaseId = 2) { +export const createModelFromTableName = ({ + tableName, + modelName = "Test Action Model", + idAlias = 'modelId' +}) => { + getTableId({ name: tableName }).then(tableId => { + cy.createQuestion( + { + database: WRITABLE_DB_ID, + name: modelName, + query: { + "source-table": tableId, + }, + dataset: true, + }, + { + wrapId: true, + idAlias, + }, + ); + }); +}; + +export function waitForSyncToFinish({ iteration = 0, dbId = 2, tableName = '' }) { // 100 x 100ms should be plenty of time for the sync to finish. if (iteration === 100) { return; } - cy.request("GET", `/api/database/${databaseId}/metadata`).then(({ body }) => { + cy.wait(100); + + cy.request("GET", `/api/database/${dbId}/metadata`).then(({ body }) => { if (!body.tables.length) { - cy.wait(100); - waitForSyncToFinish(++iteration, databaseId); + waitForSyncToFinish({ iteration: ++iteration, dbId, tableName }); + } else if (tableName) { + const hasTable = body.tables.some(table => table.name === tableName); + if (!hasTable) { + waitForSyncToFinish({ iteration: ++iteration, dbId, tableName }); + } } - - return; }); } -export function resyncDatabase(DB_ID = 2) { +export function resyncDatabase({ dbId = 2, tableName = '' }) { // must be signed in as admin to sync - cy.request("POST", `/api/database/${DB_ID}/sync_schema`); - cy.request("POST", `/api/database/${DB_ID}/rescan_values`); - waitForSyncToFinish(0, DB_ID); + cy.request("POST", `/api/database/${dbId}/sync_schema`); + cy.request("POST", `/api/database/${dbId}/rescan_values`); + waitForSyncToFinish({ iteration: 0, dbId, tableName }); } diff --git a/frontend/test/__support__/e2e/helpers/e2e-setup-helpers.js b/e2e/support/helpers/e2e-setup-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-setup-helpers.js rename to e2e/support/helpers/e2e-setup-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-slack-helpers.js b/e2e/support/helpers/e2e-slack-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-slack-helpers.js rename to e2e/support/helpers/e2e-slack-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-snowplow-helpers.js b/e2e/support/helpers/e2e-snowplow-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-snowplow-helpers.js rename to e2e/support/helpers/e2e-snowplow-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js b/e2e/support/helpers/e2e-ui-elements-helpers.js similarity index 93% rename from frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js rename to e2e/support/helpers/e2e-ui-elements-helpers.js index 1da8687a9007c..b635a67ae253e 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js +++ b/e2e/support/helpers/e2e-ui-elements-helpers.js @@ -22,6 +22,10 @@ export function rightSidebar() { return cy.findAllByTestId("sidebar-right"); } +export function leftSidebar() { + return cy.findByTestId("sidebar-left"); +} + export function navigationSidebar() { return cy.get("#root aside").first(); } @@ -95,3 +99,7 @@ export const moveColumnDown = (column, distance) => { .trigger("mousemove", 0, distance * 50, { force: true }) .trigger("mouseup", 0, distance * 50, { force: true }); }; + +export const queryBuilderMain = () => { + return cy.findByTestId("query-builder-main"); +}; diff --git a/frontend/test/__support__/e2e/helpers/e2e-users-helpers.js b/e2e/support/helpers/e2e-users-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-users-helpers.js rename to e2e/support/helpers/e2e-users-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-visual-tests-helpers.js b/e2e/support/helpers/e2e-visual-tests-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-visual-tests-helpers.js rename to e2e/support/helpers/e2e-visual-tests-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/e2e-viz-settings-helpers.js b/e2e/support/helpers/e2e-viz-settings-helpers.js similarity index 100% rename from frontend/test/__support__/e2e/helpers/e2e-viz-settings-helpers.js rename to e2e/support/helpers/e2e-viz-settings-helpers.js diff --git a/frontend/test/__support__/e2e/helpers/index.js b/e2e/support/helpers/index.js similarity index 96% rename from frontend/test/__support__/e2e/helpers/index.js rename to e2e/support/helpers/index.js index ebd82cb8f4f41..0a96b38ee34b7 100644 --- a/frontend/test/__support__/e2e/helpers/index.js +++ b/e2e/support/helpers/index.js @@ -12,6 +12,7 @@ export * from "./e2e-notebook-helpers"; export * from "./e2e-cloud-helpers"; export * from "./e2e-collection-helpers"; export * from "./e2e-data-model-helpers"; +export * from "./e2e-dragndrop-helpers"; export * from "./e2e-misc-helpers"; export * from "./e2e-email-helpers"; export * from "./e2e-ldap-helpers"; diff --git a/frontend/test/__support__/e2e/integration/visit-dashboard.cy.spec.js b/e2e/support/integration/visit-dashboard.cy.spec.js similarity index 92% rename from frontend/test/__support__/e2e/integration/visit-dashboard.cy.spec.js rename to e2e/support/integration/visit-dashboard.cy.spec.js index b09d503524b93..33e01f156ab1e 100644 --- a/frontend/test/__support__/e2e/integration/visit-dashboard.cy.spec.js +++ b/e2e/support/integration/visit-dashboard.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitDashboard } from "../helpers"; -import { USERS } from "../cypress_data"; +import { restore, visitDashboard } from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; import { setup } from "./visit-dashboard"; diff --git a/frontend/test/__support__/e2e/integration/visit-dashboard.js b/e2e/support/integration/visit-dashboard.js similarity index 98% rename from frontend/test/__support__/e2e/integration/visit-dashboard.js rename to e2e/support/integration/visit-dashboard.js index 7245658d11518..3700f49f73194 100644 --- a/frontend/test/__support__/e2e/integration/visit-dashboard.js +++ b/e2e/support/integration/visit-dashboard.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE_ID, PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/e2e/support/test_tables.js b/e2e/support/test_tables.js new file mode 100644 index 0000000000000..117f0bf45bfa8 --- /dev/null +++ b/e2e/support/test_tables.js @@ -0,0 +1,150 @@ +// define test schema such that they can be used in multiple SQL dialects using +// knex's schema builder https://knexjs.org/guide/schema-builder.html + +// we cannot use knex to define multi-dialect schemas because we can only pass +// json-serializable data to cypress tasks (which run in node) +// https://docs.cypress.io/api/commands/task#Arguments + +import { many_data_types_rows } from "./test_tables_data"; + +export const colors27745 = async dbClient => { + const tableName = "colors27745"; + + await dbClient.schema.dropTableIfExists(tableName); + await dbClient.schema.createTable(tableName, table => { + table.increments("id").primary(); + table.string("name").unique().notNullable(); + }); + + await dbClient(tableName).insert([ + { name: "red" }, + { name: "green" }, + { name: "blue" }, + ]); + + return null; +}; + +export const scoreboard_actions = async dbClient => { + const tableName = "scoreboard_actions"; + + await dbClient.schema.dropTableIfExists(tableName); + await dbClient.schema.createTable(tableName, table => { + table.increments("id").primary(); + table.string("team_name").unique().notNullable(); + table.integer("score").notNullable().defaultTo(0); + table.string("status").notNullable().defaultTo("active"); + table.timestamps(false, true); + }); + + await dbClient(tableName).insert([ + { team_name: "Amorous Aardvarks", score: 0 }, + { team_name: "Bouncy Bears", score: 10 }, + { team_name: "Cuddly Cats", score: 20 }, + { team_name: "Dusty Ducks", score: 25 }, + { team_name: "Energetic Elephants", score: 30 }, + { team_name: "Funky Flamingos", score: 30, status: "suspended" }, + { team_name: "Generous Giraffes", score: 30 }, + { team_name: "Hilarious Hippos", score: 40 }, + { team_name: "Incredible Iguanas", score: 50, status: "retired" }, + { team_name: "Jolly Jellyfish", score: 60 }, + { team_name: "Kind Koalas", score: 70 }, + { team_name: "Lively Lemurs", score: 80 }, + { team_name: "Mighty Monkeys", score: 90, status: "inactive" }, + { team_name: "Nifty Narwhals", score: 100 }, + ]); + + return null; +}; + +export const many_data_types = async dbClient => { + const tableName = "many_data_types"; + + await dbClient.schema.dropTableIfExists(tableName); + + await dbClient.schema.createTable(tableName, table => { + table.increments("id").primary(); + table.uuid("uuid"); + + table.integer("integer"); + table.integer("integerUnsigned").unsigned(); + table.tinyint("tinyint"); + table.tinyint("tinyint1", 1); + table.smallint("smallint"); + table.mediumint("mediumint"); + table.bigInteger("bigint"); + + table.string("string"); + table.text("text"); + + table.float("float"); + table.double("double"); + table.decimal("decimal"); + + table.boolean("boolean"); + + table.date("date"); + table.dateTime("datetime", { useTz: false }); + table.dateTime("datetimeTZ", { useTz: true }); + table.time("time"); + table.timestamp("timestamp", { useTz: false }); + table.timestamp("timestampTZ", { useTz: true }); + + table.json("json"); + table.jsonb("jsonb"); + + table.enu("enum", ["alpha", "beta", "gamma", "delta"]); + + table.binary("binary"); + }); + + await dbClient(tableName).insert(many_data_types_rows); + return null; +}; + +export const composite_pk_table = async dbClient => { + const tableName = "composite_pk_table"; + + await dbClient.schema.dropTableIfExists(tableName); + + await dbClient.schema.createTable(tableName, table => { + table.integer("id1"); + table.string("id2"); + table.string("name"); + table.integer("score"); + table.primary(["id1", "id2"]); + }); + + await dbClient(tableName).insert([ + { id1: 1, id2: "alpha", name: "Duck", score: 10 }, + { id1: 1, id2: "beta", name: "Horse", score: 20 }, + { id1: 2, id2: "alpha", name: "Cow", score: 30 }, + { id1: 2, id2: "beta", name: "Pig", score: 40 }, + { id1: 3, id2: "alpha", name: "Chicken", score: 50 }, + { id1: 3, id2: "beta", name: "Rabbit", score: 60 }, + ]); + + return null; +} + +export const no_pk_table = async dbClient => { + const tableName = "no_pk_table"; + + await dbClient.schema.dropTableIfExists(tableName); + + await dbClient.schema.createTable(tableName, table => { + table.string("name"); + table.integer("score"); + }); + + await dbClient(tableName).insert([ + { name: "Duck", score: 10 }, + { name: "Horse", score: 20 }, + { name: "Cow", score: 30 }, + { name: "Pig", score: 40 }, + { name: "Chicken", score: 50 }, + { name: "Rabbit", score: 60 }, + ]); + + return null; +} diff --git a/e2e/support/test_tables_data.js b/e2e/support/test_tables_data.js new file mode 100644 index 0000000000000..e6bc021181d86 --- /dev/null +++ b/e2e/support/test_tables_data.js @@ -0,0 +1,54 @@ +export const many_data_types_rows = [ + { + uuid: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", + integer: 1, + integerUnsigned: 2, + tinyint: -128, + tinyint1: 1, + smallint: 100, + mediumint: 1000, + bigint: 100000, + string: "string", + text: "text", + float: 1.1, + double: 1.11, + decimal: 1.11, + boolean: true, + date: "2020-01-01", + datetime: "2020-01-01 08:35:55", + datetimeTZ: "2020-01-01 08:35:55", + time: "08:35:55", + timestamp: "2020-01-01 08:35:55", + timestampTZ: "2020-01-01 08:35:55", + json: { a: 10, b: 20, c: [6, 7, 8], d: "foobar" }, + jsonb: { a: 20, b: 30 }, + enum: "beta", + binary: "binary", + }, + { + uuid: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", + integer: 4, + integerUnsigned: 5, + tinyint: 127, + tinyint1: 1, + smallint: 100, + mediumint: 1002, + bigint: 100002, + string: "string of characters", + text: "text block", + float: 21.1, + double: 21.11, + decimal: 21.11, + boolean: false, + date: "2020-02-01", + datetime: "2020-02-01 12:30:30", + datetimeTZ: "2020-02-01 12:30:30", + time: "12:30:30", + timestamp: "2020-02-01 12:30:30", + timestampTZ: "2020-02-01 12:30:30", + json: { a: 10, b: 20, c: [9, 10, 11], d: "foobarbaz" }, + jsonb: { a: 20, b: 30 }, + enum: "beta", + binary: "binary", + }, +]; diff --git a/frontend/test/metabase/scenarios/README.md b/e2e/test/scenarios/README.md similarity index 100% rename from frontend/test/metabase/scenarios/README.md rename to e2e/test/scenarios/README.md diff --git a/frontend/test/metabase/scenarios/admin/databases/actions.cy.spec.js b/e2e/test/scenarios/admin/databases/actions.cy.spec.js similarity index 74% rename from frontend/test/metabase/scenarios/admin/databases/actions.cy.spec.js rename to e2e/test/scenarios/admin/databases/actions.cy.spec.js index d6584cbb6b60f..5790e3c53fb18 100644 --- a/frontend/test/metabase/scenarios/admin/databases/actions.cy.spec.js +++ b/e2e/test/scenarios/admin/databases/actions.cy.spec.js @@ -1,8 +1,7 @@ -import { restore } from "__support__/e2e/helpers"; -import { - WRITABLE_DB_ID, - WRITABLE_DB_CONFIG, -} from "__support__/e2e/cypress_data"; +import { restore } from "e2e/support/helpers"; +import { WRITABLE_DB_ID, WRITABLE_DB_CONFIG } from "e2e/support/cypress_data"; + +import { visitDatabase } from "./helpers/e2e-database-helpers"; describe( "admin > database > external databases > enable actions", @@ -13,7 +12,7 @@ describe( restore(`${dialect}-writable`); cy.signInAsAdmin(); - cy.request(`/api/database/${WRITABLE_DB_ID}`).then(({ body }) => { + visitDatabase(WRITABLE_DB_ID).then(({ response: { body } }) => { expect(body.name).to.include("Writable"); expect(body.name.toLowerCase()).to.include(dialect); @@ -23,7 +22,6 @@ describe( expect(body.settings["database-enable-actions"]).to.eq(true); }); - cy.visit(`/admin/databases/${WRITABLE_DB_ID}`); cy.get("#model-actions-toggle").should( "have.attr", "aria-checked", diff --git a/frontend/test/metabase/scenarios/admin/databases/add-external.cy.spec.js b/e2e/test/scenarios/admin/databases/add-new-database.cy.spec.js similarity index 50% rename from frontend/test/metabase/scenarios/admin/databases/add-external.cy.spec.js rename to e2e/test/scenarios/admin/databases/add-new-database.cy.spec.js index 584789f759830..a9d75305d5fc6 100644 --- a/frontend/test/metabase/scenarios/admin/databases/add-external.cy.spec.js +++ b/e2e/test/scenarios/admin/databases/add-new-database.cy.spec.js @@ -1,24 +1,60 @@ -import { restore, typeAndBlurUsingLabel } from "__support__/e2e/helpers"; +import { + restore, + popover, + typeAndBlurUsingLabel, + isEE, +} from "e2e/support/helpers"; import { QA_MONGO_PORT, QA_MYSQL_PORT, QA_POSTGRES_PORT, -} from "__support__/e2e/cypress_data"; +} from "e2e/support/cypress_data"; + +describe("admin > database > add", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + cy.intercept("POST", "/api/database").as("createDatabase"); + + cy.visit("/admin/databases/create"); + // should display a setup help card + cy.findByText("Need help connecting?"); + + cy.findByLabelText("Database type").click(); + }); + + it("should add a new database", () => { + popover().within(() => { + if (isEE) { + // EE should ship with Oracle and Vertica as options + cy.findByText("Oracle"); + cy.findByText("Vertica"); + } + cy.findByText("H2").click(); + }); -describe( - "admin > database > add > external databases", - { tags: "@external" }, - () => { - beforeEach(() => { - restore(); - cy.signInAsAdmin(); + typeAndBlurUsingLabel("Display name", "Test"); + typeAndBlurUsingLabel("Connection String", "invalid"); - cy.intercept("POST", "/api/database").as("createDatabase"); + // should surface an error if the connection string is invalid + cy.button("Save").click(); + cy.wait("@createDatabase"); + cy.findByText(": check your connection string"); + cy.findByText("Implicitly relative file paths are not allowed."); - cy.visit("/admin/databases/create"); - cy.findByLabelText("Database type").click(); - }); + // should be able to recover from an error and add database with the correct connection string + cy.findByDisplayValue("invalid") + .clear() + .type( + "zip:./target/uberjar/metabase.jar!/sample-database.db;USER=GUEST;PASSWORD=guest", + { delay: 0 }, + ); + cy.button("Save", { timeout: 10000 }).click(); + cy.wait("@createDatabase"); + }); + describe("external databases", { tags: "@external" }, () => { it("should add Postgres database and redirect to listing (metabase#12972, metabase#14334, metabase#17450)", () => { cy.contains("PostgreSQL").click({ force: true }); @@ -50,6 +86,53 @@ describe( typeAndBlurUsingLabel("Username", "metabase"); typeAndBlurUsingLabel("Password", "metasample123"); + const confirmSSLFields = (visible, hidden) => { + visible.forEach(field => cy.findByText(field)); + hidden.forEach(field => cy.findByText(field).should("not.exist")); + }; + + const ssl = "Use a secure connection (SSL)", + sslMode = "SSL Mode", + useClientCert = "Authenticate client certificate?", + clientPemCert = "SSL Client Certificate (PEM)", + clientPkcsCert = "SSL Client Key (PKCS-8/DER)", + sslRootCert = "SSL Root Certificate (PEM)"; + + // initially, all SSL sub-properties should be hidden + confirmSSLFields( + [ssl], + [sslMode, useClientCert, clientPemCert, clientPkcsCert, sslRootCert], + ); + + toggleFieldWithDisplayName(ssl); + // when ssl is enabled, the mode and "enable client cert" options should be shown + confirmSSLFields( + [ssl, sslMode, useClientCert], + [clientPemCert, clientPkcsCert, sslRootCert], + ); + + toggleFieldWithDisplayName(useClientCert); + // when the "enable client cert" option is enabled, its sub-properties should be shown + confirmSSLFields( + [ssl, sslMode, useClientCert, clientPemCert, clientPkcsCert], + [sslRootCert], + ); + + selectFieldOption(sslMode, "verify-ca"); + // when the ssl mode is set to "verify-ca", then the root cert option should be shown + confirmSSLFields( + [ + ssl, + sslMode, + useClientCert, + clientPemCert, + clientPkcsCert, + sslRootCert, + ], + [], + ); + toggleFieldWithDisplayName(ssl); + cy.button("Save").should("not.be.disabled").click(); cy.wait("@createDatabase").then(({ request }) => { @@ -123,7 +206,9 @@ describe( cy.findByLabelText("Port").should("not.exist"); cy.findByLabelText("Paste your connection string").type( connectionString, - { delay: 0 }, + { + delay: 0, + }, ); cy.findByText("Save").should("not.be.disabled").click(); @@ -176,5 +261,78 @@ describe( cy.findByText("Done!"); }); }); - }, -); + }); + + describe("Google service account JSON upload", () => { + const serviceAccountJSON = '{"foo": 123}'; + + it("should work for BigQuery", () => { + cy.visit("/admin/databases/create"); + + chooseDatabase("BigQuery"); + typeAndBlurUsingLabel("Display name", "BQ"); + selectFieldOption("Datasets", "Only these..."); + cy.findByPlaceholderText("E.x. public,auth*").type("some-dataset"); + + mockUploadServiceAccountJSON(serviceAccountJSON); + mockSuccessfulDatabaseSave().then(({ request: { body } }) => { + expect(body.details["service-account-json"]).to.equal( + serviceAccountJSON, + ); + }); + }); + + it("should work for Google Analytics", () => { + cy.visit("/admin/databases/create"); + + chooseDatabase("Google Analytics"); + typeAndBlurUsingLabel("Display name", "GA"); + typeAndBlurUsingLabel("Google Analytics Account ID", " 9 "); + + mockUploadServiceAccountJSON(serviceAccountJSON); + mockSuccessfulDatabaseSave().then(({ request: { body } }) => { + expect(body.details["service-account-json"]).to.equal( + serviceAccountJSON, + ); + }); + }); + }); +}); + +function toggleFieldWithDisplayName(displayName) { + cy.findByLabelText(displayName).click(); +} + +function selectFieldOption(fieldName, option) { + cy.findByLabelText(fieldName).click(); + popover().contains(option).click({ force: true }); +} + +function chooseDatabase(database) { + selectFieldOption("Database type", database); +} + +function mockUploadServiceAccountJSON(fileContents) { + // create blob to act as selected file + cy.get("input[type=file]") + .then(async input => { + const blob = await Cypress.Blob.binaryStringToBlob(fileContents); + const file = new File([blob], "service-account.json"); + const dataTransfer = new DataTransfer(); + + dataTransfer.items.add(file); + input[0].files = dataTransfer.files; + return input; + }) + .trigger("change", { force: true }) + .trigger("blur", { force: true }); +} + +function mockSuccessfulDatabaseSave() { + cy.intercept("POST", "/api/database", req => { + req.reply({ statusCode: 200, body: { id: 42 }, delay: 100 }); + }).as("createDatabase"); + + cy.button("Save").click(); + return cy.wait("@createDatabase"); +} diff --git a/e2e/test/scenarios/admin/databases/database-exceptions.cy.spec.js b/e2e/test/scenarios/admin/databases/database-exceptions.cy.spec.js new file mode 100644 index 0000000000000..f24424a50c9f7 --- /dev/null +++ b/e2e/test/scenarios/admin/databases/database-exceptions.cy.spec.js @@ -0,0 +1,92 @@ +import { restore, typeAndBlurUsingLabel, isEE } from "e2e/support/helpers"; + +describe("scenarios > admin > databases > exceptions", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + it("should handle malformed (null) database details (metabase#25715)", () => { + cy.intercept("GET", "/api/database/1", req => { + req.reply(res => { + res.body.details = null; + }); + }).as("loadDatabase"); + + cy.visit("/admin/databases/1"); + cy.wait("@loadDatabase"); + + // It is unclear how this issue will be handled, + // but at the very least it shouldn't render the blank page. + cy.get("nav").should("contain", "Metabase Admin"); + // The response still contains the database name, + // so there's no reason we can't display it. + cy.contains(/Sample Database/i); + // This seems like a reasonable CTA if the database is beyond repair. + cy.button("Remove this database").should("not.be.disabled"); + }); + + it("should show error upon a bad request", () => { + cy.intercept("POST", "/api/database", req => { + req.reply({ + statusCode: 400, + body: "DATABASE CONNECTION ERROR", + }); + }).as("createDatabase"); + + cy.visit("/admin/databases/create"); + + typeAndBlurUsingLabel("Display name", "Test"); + typeAndBlurUsingLabel("Database name", "db"); + typeAndBlurUsingLabel("Username", "admin"); + + cy.button("Save").click(); + cy.wait("@createDatabase"); + + cy.findByText("DATABASE CONNECTION ERROR").should("exist"); + }); + + it("should handle non-existing databases (metabase#11037)", () => { + cy.intercept("GET", "/api/database/999").as("loadDatabase"); + cy.visit("/admin/databases/999"); + cy.wait("@loadDatabase").then(({ response }) => { + expect(response.statusCode).to.eq(404); + }); + cy.findByText("Not found."); + cy.findByRole("table").should("not.exist"); + }); + + it("should handle a failure to `GET` the list of all databases (metabase#20471)", () => { + const errorMessage = "Lorem ipsum dolor sit amet, consectetur adip"; + + cy.intercept( + { + method: "GET", + pathname: "/api/database", + query: isEE + ? { + exclude_uneditable_details: "true", + } + : null, + }, + req => { + req.reply({ + statusCode: 500, + body: { message: errorMessage }, + }); + }, + ).as("failedGet"); + + cy.visit("/admin/databases"); + cy.wait("@failedGet"); + + cy.findByRole("heading", { name: "Something's gone wrong" }); + cy.findByText( + "We've run into an error. You can try refreshing the page, or just go back.", + ); + + cy.findByText(errorMessage).should("not.be.visible"); + cy.findByText("Show error details").click(); + cy.findByText(errorMessage).should("be.visible"); + }); +}); diff --git a/e2e/test/scenarios/admin/databases/default-sample-database.cy.spec.js b/e2e/test/scenarios/admin/databases/default-sample-database.cy.spec.js new file mode 100644 index 0000000000000..eb0662a3aa229 --- /dev/null +++ b/e2e/test/scenarios/admin/databases/default-sample-database.cy.spec.js @@ -0,0 +1,250 @@ +import { restore, popover, modal, describeEE } from "e2e/support/helpers"; + +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +import { visitDatabase } from "./helpers/e2e-database-helpers"; + +const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; + +describe("scenarios > admin > databases > sample database", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + cy.intercept("PUT", "/api/database/*").as("databaseUpdate"); + }); + + it("database settings", () => { + visitDatabase(SAMPLE_DB_ID); + // should not display a setup help card + cy.findByText("Need help connecting?").should("not.exist"); + + cy.log( + "should not be possible to change database type for the Sample Database (metabase#16382)", + ); + cy.findByLabelText("Database type") + .should("have.text", "H2") + .and("be.disabled"); + + cy.log("should correctly display connection settings"); + cy.findByLabelText("Display name").should("have.value", "Sample Database"); + cy.findByLabelText("Connection String") + .should("have.attr", "value") + .and("contain", "sample-database.db"); + + cy.log("should be possible to modify the connection settings"); + cy.findByText("Show advanced options").click(); + // `auto_run_queries` toggle should be ON by default + cy.findByLabelText("Rerun queries for simple explorations") + .should("have.attr", "aria-checked", "true") + .click(); + // Reported failing in v0.36.4 + cy.log( + "should respect the settings for automatic query running (metabase#13187)", + ); + cy.findByLabelText("Rerun queries for simple explorations").should( + "have.attr", + "aria-checked", + "false", + ); + + cy.log("change the metadata_sync period"); + cy.findByLabelText("Choose when syncs and scans happen").click(); + cy.findByText("Hourly").click(); + popover().within(() => { + cy.findByText("Daily").click({ force: true }); + }); + + // "lets you change the cache_field_values period" + cy.findByLabelText("Never, I'll do this manually if I need to").should( + "have.attr", + "aria-selected", + "true", + ); + + cy.findByLabelText("Regularly, on a schedule") + .click() + .within(() => { + cy.findByText("Daily").click(); + }); + popover().findByText("Weekly").click(); + + cy.button("Save changes").click(); + cy.wait("@databaseUpdate").then(({ response: { body } }) => { + expect(body.details["let-user-control-scheduling"]).to.equal(true); + expect(body.schedules.metadata_sync.schedule_type).to.equal("daily"); + expect(body.schedules.cache_field_values.schedule_type).to.equal( + "weekly", + ); + }); + cy.button("Success"); + + // "lets you change the cache_field_values to 'Only when adding a new filter widget'" + cy.findByLabelText("Only when adding a new filter widget").click(); + cy.button("Save changes", { timeout: 10000 }).click(); + cy.wait("@databaseUpdate").then(({ response: { body } }) => { + expect(body.is_full_sync).to.equal(false); + expect(body.is_on_demand).to.equal(true); + }); + + // and back to never + cy.findByLabelText("Never, I'll do this manually if I need to").click(); + cy.button("Save changes", { timeout: 10000 }).click(); + cy.wait("@databaseUpdate").then(({ response: { body } }) => { + expect(body.is_full_sync).to.equal(false); + expect(body.is_on_demand).to.equal(false); + }); + }); + + it("database actions sidebar", () => { + cy.intercept("POST", `/api/database/${SAMPLE_DB_ID}/sync_schema`).as( + "sync_schema", + ); + cy.intercept("POST", `/api/database/${SAMPLE_DB_ID}/rescan_values`).as( + "rescan_values", + ); + cy.intercept("POST", `/api/database/${SAMPLE_DB_ID}/discard_values`).as( + "discard_values", + ); + cy.intercept("GET", `/api/database/${SAMPLE_DB_ID}/usage_info`).as( + `usage_info`, + ); + cy.intercept("DELETE", `/api/database/${SAMPLE_DB_ID}`).as("delete"); + // model + cy.request("PUT", "/api/card/1", { dataset: true }); + // Create a segment through API + cy.request("POST", "/api/segment", { + name: "Small orders", + description: "All orders with a total under $100.", + table_id: ORDERS_ID, + definition: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + filter: ["<", ["field", ORDERS.TOTAL, null], 100], + }, + }); + // metric + cy.request("POST", "/api/metric", { + name: "Revenue", + description: "Sum of orders subtotal", + table_id: ORDERS_ID, + definition: { + "source-table": ORDERS_ID, + aggregation: [["sum", ["field", ORDERS.SUBTOTAL, null]]], + }, + }); + + visitDatabase(SAMPLE_DB_ID); + + // lets you trigger the manual database schema sync + cy.button("Sync database schema now").click(); + cy.wait("@sync_schema"); + cy.findByText("Sync triggered!"); + + // lets you trigger the manual rescan of field values + cy.findByText("Re-scan field values now").click(); + cy.wait("@rescan_values"); + cy.findByText("Scan triggered!"); + + // lets you discard saved field values + cy.findByText("Danger Zone") + .parent() + .as("danger") + .within(() => { + cy.button("Discard saved field values").click(); + }); + modal().within(() => { + cy.findByRole("heading").should( + "have.text", + "Discard saved field values", + ); + cy.findByText("Are you sure you want to do this?"); + cy.button("Yes").click(); + }); + cy.wait("@discard_values"); + + // lets you remove the Sample Database + cy.get("@danger").within(() => { + cy.button("Remove this database").click(); + cy.wait("@usage_info"); + }); + + modal().within(() => { + cy.button("Delete this content and the DB connection") + .as("deleteButton") + .should("be.disabled"); + cy.findByLabelText(/Delete [0-9]* saved questions?/) + .should("not.be.checked") + .click() + .should("be.checked"); + cy.findByLabelText(/Delete [0-9]* models?/) + .should("not.be.checked") + .click() + .should("be.checked"); + cy.findByLabelText(/Delete [0-9]* metrics?/) + .should("not.be.checked") + .click() + .should("be.checked"); + cy.findByLabelText(/Delete [0-9]* segments?/) + .should("not.be.checked") + .click() + .should("be.checked"); + cy.findByText( + "This will delete every saved question, model, metric, and segment you’ve made that uses this data, and can’t be undone!", + ); + + cy.get("@deleteButton").should("be.disabled"); + + cy.findByPlaceholderText("Are you completely sure?") + .type("Sample Database") + .blur(); + + cy.intercept("GET", "/api/database").as("fetchDatabases"); + cy.get("@deleteButton").should("be.enabled").click(); + cy.wait(["@delete", "@fetchDatabases"]); + }); + + cy.location("pathname").should("eq", "/admin/databases/"); // FIXME why the trailing slash? + cy.intercept("POST", "/api/database/sample_database").as("sample_database"); + cy.contains("Bring the sample database back", { + timeout: 10000, + }).click(); + cy.wait("@sample_database"); + + cy.findAllByRole("cell").contains("Sample Database").click(); + const newSampleDatabaseId = SAMPLE_DB_ID + 1; + cy.location("pathname").should( + "eq", + `/admin/databases/${newSampleDatabaseId}`, + ); + }); + + describeEE("custom caching", () => { + it("should set custom cache ttl", () => { + cy.request("PUT", "api/setting/enable-query-caching", { value: true }); + + visitDatabase(SAMPLE_DB_ID).then(({ response: { body } }) => { + expect(body.cache_ttl).to.be.null; + }); + + cy.findByText("Show advanced options").click(); + + setCustomCacheTTLValue("48"); + + cy.button("Save changes").click(); + cy.wait("@databaseUpdate").then(({ request, response }) => { + expect(request.body.cache_ttl).to.equal(48); + expect(response.body.cache_ttl).to.equal(48); + }); + + function setCustomCacheTTLValue(value) { + cy.findAllByTestId("select-button") + .contains("Use instance default (TTL)") + .click(); + + popover().findByText("Custom").click(); + cy.findByDisplayValue("24").clear().type(value).blur(); + } + }); + }); +}); diff --git a/e2e/test/scenarios/admin/databases/helpers/e2e-database-helpers.js b/e2e/test/scenarios/admin/databases/helpers/e2e-database-helpers.js new file mode 100644 index 0000000000000..b138ec39c073b --- /dev/null +++ b/e2e/test/scenarios/admin/databases/helpers/e2e-database-helpers.js @@ -0,0 +1,18 @@ +/** + * Visit a database and immediately wait for the related request. + * You can assert on the response of `GET /api/database/:id`. + * @param {number} id - Id of the database we're about to visit. + * + * @example + * visitDatabase(3) + * + * @example + * visitDatabase(42).then(({response: { body }})=> { + * expect(body.id).to.equal(42); + * }) + */ +export function visitDatabase(id) { + cy.intercept("GET", `/api/database/${id}`).as("loadDatabase"); + cy.visit(`/admin/databases/${id}`); + return cy.wait("@loadDatabase"); +} diff --git a/frontend/test/metabase/scenarios/admin/datamodel/editor.cy.spec.js b/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/admin/datamodel/editor.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/editor.cy.spec.js index 04a2fb8381e9b..286f70d77c339 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/editor.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js @@ -1,7 +1,7 @@ -import { restore, popover, visitAlias } from "__support__/e2e/helpers"; +import { restore, popover, visitAlias } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/field-type.cy.spec.js b/e2e/test/scenarios/admin/datamodel/field-type.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/admin/datamodel/field-type.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/field-type.cy.spec.js index 2044b64a0ce91..9518174a86736 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/field-type.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/field-type.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitAlias, popover } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitAlias, popover } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/field.cy.spec.js b/e2e/test/scenarios/admin/datamodel/field.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/admin/datamodel/field.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/field.cy.spec.js index c6b75e0789e20..8076868c03f9a 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/field.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/field.cy.spec.js @@ -4,9 +4,9 @@ import { visitAlias, popover, startNewQuestion, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/hide_tables.cy.spec.js b/e2e/test/scenarios/admin/datamodel/hide_tables.cy.spec.js similarity index 85% rename from frontend/test/metabase/scenarios/admin/datamodel/hide_tables.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/hide_tables.cy.spec.js index 108b7fb3dd23a..6d39b896fc388 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/hide_tables.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/hide_tables.cy.spec.js @@ -1,7 +1,7 @@ -import { restore, startNewQuestion } from "__support__/e2e/helpers"; +import { restore, startNewQuestion } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/metadata.cy.spec.js b/e2e/test/scenarios/admin/datamodel/metadata.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/admin/datamodel/metadata.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/metadata.cy.spec.js index 89b8c59170405..cffbeee945410 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/metadata.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/metadata.cy.spec.js @@ -4,9 +4,9 @@ import { openReviewsTable, popover, summarize, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/metrics.cy.spec.js b/e2e/test/scenarios/admin/datamodel/metrics.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/datamodel/metrics.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/metrics.cy.spec.js index 6c6c9b62fe05e..a989c7dab96e2 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/metrics.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/metrics.cy.spec.js @@ -7,8 +7,8 @@ import { summarize, filter, filterField, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/reproductions/17768-entity-key-showing-binning-options.cy.spec.js b/e2e/test/scenarios/admin/datamodel/reproductions/17768-entity-key-showing-binning-options.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/admin/datamodel/reproductions/17768-entity-key-showing-binning-options.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/reproductions/17768-entity-key-showing-binning-options.cy.spec.js index 6e6ab9a432d87..fc6b2954d850c 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/reproductions/17768-entity-key-showing-binning-options.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/reproductions/17768-entity-key-showing-binning-options.cy.spec.js @@ -3,9 +3,9 @@ import { openReviewsTable, popover, summarize, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/reproductions/18384-field-settings-breaks-ui.cy.spec.js b/e2e/test/scenarios/admin/datamodel/reproductions/18384-field-settings-breaks-ui.cy.spec.js similarity index 80% rename from frontend/test/metabase/scenarios/admin/datamodel/reproductions/18384-field-settings-breaks-ui.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/reproductions/18384-field-settings-breaks-ui.cy.spec.js index 5573d0509afbd..fe1806ced33e4 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/reproductions/18384-field-settings-breaks-ui.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/reproductions/18384-field-settings-breaks-ui.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE_ID, PEOPLE, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/reproductions/21984-data-model-registered-as-view.cy.spec.js b/e2e/test/scenarios/admin/datamodel/reproductions/21984-data-model-registered-as-view.cy.spec.js similarity index 81% rename from frontend/test/metabase/scenarios/admin/datamodel/reproductions/21984-data-model-registered-as-view.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/reproductions/21984-data-model-registered-as-view.cy.spec.js index 9d0571af341a2..30df5bfffba16 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/reproductions/21984-data-model-registered-as-view.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/reproductions/21984-data-model-registered-as-view.cy.spec.js @@ -1,7 +1,7 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/segments.cy.spec.js b/e2e/test/scenarios/admin/datamodel/segments.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/datamodel/segments.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/segments.cy.spec.js index a3bc08d4e9387..1395e518e9dbf 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/segments.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/segments.cy.spec.js @@ -5,9 +5,9 @@ import { modal, filter, filterField, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/datamodel/table.cy.spec.js b/e2e/test/scenarios/admin/datamodel/table.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/admin/datamodel/table.cy.spec.js rename to e2e/test/scenarios/admin/datamodel/table.cy.spec.js index 83b2a9b16f3ab..f5d3097ada602 100644 --- a/frontend/test/metabase/scenarios/admin/datamodel/table.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/table.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, filter, visitQuestion } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, filter, visitQuestion } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/people/group-managers.cy.spec.js b/e2e/test/scenarios/admin/people/group-managers.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/people/group-managers.cy.spec.js rename to e2e/test/scenarios/admin/people/group-managers.cy.spec.js index 3538e3b621528..9fc98f1ebd0a2 100644 --- a/frontend/test/metabase/scenarios/admin/people/group-managers.cy.spec.js +++ b/e2e/test/scenarios/admin/people/group-managers.cy.spec.js @@ -4,8 +4,8 @@ import { popover, describeEE, getFullName, -} from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const { normal, nocollection } = USERS; diff --git a/frontend/test/metabase/scenarios/admin/people/people.cy.spec.js b/e2e/test/scenarios/admin/people/people.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/people/people.cy.spec.js rename to e2e/test/scenarios/admin/people/people.cy.spec.js index 84098ecc8a25c..c4d014a8ac2ce 100644 --- a/frontend/test/metabase/scenarios/admin/people/people.cy.spec.js +++ b/e2e/test/scenarios/admin/people/people.cy.spec.js @@ -6,9 +6,9 @@ import { setupSMTP, describeEE, getFullName, -} from "__support__/e2e/helpers"; -import { USERS, USER_GROUPS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { USERS, USER_GROUPS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { normal, admin, nocollection } = USERS; const { ALL_USERS_GROUP, DATA_GROUP } = USER_GROUPS; diff --git a/frontend/test/metabase/scenarios/admin/people/reproductions/23689-sandboxed-group-manager.cy.spec.js b/e2e/test/scenarios/admin/people/reproductions/23689-sandboxed-group-manager.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/admin/people/reproductions/23689-sandboxed-group-manager.cy.spec.js rename to e2e/test/scenarios/admin/people/reproductions/23689-sandboxed-group-manager.cy.spec.js index 6022e10a59cb7..eca9f7b947402 100644 --- a/frontend/test/metabase/scenarios/admin/people/reproductions/23689-sandboxed-group-manager.cy.spec.js +++ b/e2e/test/scenarios/admin/people/reproductions/23689-sandboxed-group-manager.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, describeEE } from "__support__/e2e/helpers"; -import { USERS, USER_GROUPS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, describeEE } from "e2e/support/helpers"; +import { USERS, USER_GROUPS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { COLLECTION_GROUP } = USER_GROUPS; const { sandboxed, normal, nodata, nocollection } = USERS; diff --git a/frontend/test/metabase/scenarios/admin/settings/cache.cy.spec.js b/e2e/test/scenarios/admin/settings/cache.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/admin/settings/cache.cy.spec.js rename to e2e/test/scenarios/admin/settings/cache.cy.spec.js index b7b0993ee3287..844d2d10477ab 100644 --- a/frontend/test/metabase/scenarios/admin/settings/cache.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/cache.cy.spec.js @@ -1,8 +1,4 @@ -import { - restore, - openNativeEditor, - runNativeQuery, -} from "__support__/e2e/helpers"; +import { restore, openNativeEditor, runNativeQuery } from "e2e/support/helpers"; const nativeQuery = "select (random() * random() * random()), pg_sleep(2)"; diff --git a/frontend/test/metabase/scenarios/admin/settings/cloud.cy.spec.js b/e2e/test/scenarios/admin/settings/cloud.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/admin/settings/cloud.cy.spec.js rename to e2e/test/scenarios/admin/settings/cloud.cy.spec.js index bb7bdba7aa38f..6908fc846f0a9 100644 --- a/frontend/test/metabase/scenarios/admin/settings/cloud.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/cloud.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, setupMetabaseCloud } from "__support__/e2e/helpers"; +import { restore, setupMetabaseCloud } from "e2e/support/helpers"; // Unskip when mocking Cloud in Cypress is fixed (#18289) describe.skip("Cloud settings section", () => { diff --git a/frontend/test/metabase/scenarios/admin/settings/email.cy.spec.js b/e2e/test/scenarios/admin/settings/email.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/admin/settings/email.cy.spec.js rename to e2e/test/scenarios/admin/settings/email.cy.spec.js index 3aef59e78a030..b98516f092d79 100644 --- a/frontend/test/metabase/scenarios/admin/settings/email.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/email.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, setupSMTP } from "__support__/e2e/helpers"; -import { WEBMAIL_CONFIG } from "__support__/e2e/cypress_data"; +import { restore, setupSMTP } from "e2e/support/helpers"; +import { WEBMAIL_CONFIG } from "e2e/support/cypress_data"; const { SMTP_PORT, WEB_PORT } = WEBMAIL_CONFIG; diff --git a/frontend/test/metabase/scenarios/admin/settings/localization.cy.spec.js b/e2e/test/scenarios/admin/settings/localization.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/admin/settings/localization.cy.spec.js rename to e2e/test/scenarios/admin/settings/localization.cy.spec.js index b2d90de728dd0..69d51a62cda08 100644 --- a/frontend/test/metabase/scenarios/admin/settings/localization.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/localization.cy.spec.js @@ -2,10 +2,10 @@ import { restore, visitQuestionAdhoc, visitQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -108,7 +108,7 @@ describe("scenarios > admin > localization", () => { { visitQuestion: true }, ); - cy.get(".TableInteractive-header").next().as("resultTable"); + cy.findByTestId("TableInteractive-root").as("resultTable"); cy.get("@resultTable").within(() => { // The third cell in the first row (CREATED_AT_DAY) diff --git a/frontend/test/metabase/scenarios/admin/settings/maps.cy.spec.js b/e2e/test/scenarios/admin/settings/maps.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/settings/maps.cy.spec.js rename to e2e/test/scenarios/admin/settings/maps.cy.spec.js index c6e4c3180cb2e..a7ba26de61930 100644 --- a/frontend/test/metabase/scenarios/admin/settings/maps.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/maps.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("scenarios > admin > settings > map settings", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/admin/settings/public-sharing.cy.spec.js b/e2e/test/scenarios/admin/settings/public-sharing.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/admin/settings/public-sharing.cy.spec.js rename to e2e/test/scenarios/admin/settings/public-sharing.cy.spec.js index 1921ce5f4a0bd..8527246372126 100644 --- a/frontend/test/metabase/scenarios/admin/settings/public-sharing.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/public-sharing.cy.spec.js @@ -1,11 +1,11 @@ import { restore, modal, - enableActionsForDB, + setActionsEnabledForDB, createAction, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { ORDERS_ID } = SAMPLE_DATABASE; @@ -180,7 +180,7 @@ describe("scenarios > admin > settings > public sharing", () => { }); it("should see public actions", () => { - enableActionsForDB(); + setActionsEnabledForDB(SAMPLE_DB_ID); const expectedActionName = "Public action"; cy.createQuestion({ diff --git a/frontend/test/metabase/scenarios/admin/settings/reproductions/21532-back-button.cy.spec.js b/e2e/test/scenarios/admin/settings/reproductions/21532-back-button.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/admin/settings/reproductions/21532-back-button.cy.spec.js rename to e2e/test/scenarios/admin/settings/reproductions/21532-back-button.cy.spec.js index 231a33dae206d..f252757038530 100644 --- a/frontend/test/metabase/scenarios/admin/settings/reproductions/21532-back-button.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/reproductions/21532-back-button.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("issue 21532", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/admin/settings/settings.cy.spec.js b/e2e/test/scenarios/admin/settings/settings.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/settings/settings.cy.spec.js rename to e2e/test/scenarios/admin/settings/settings.cy.spec.js index ea1f5e170413b..5f1f1bd4fc783 100644 --- a/frontend/test/metabase/scenarios/admin/settings/settings.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/settings.cy.spec.js @@ -6,8 +6,8 @@ import { setupMetabaseCloud, isOSS, isEE, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/google.cy.spec.js b/e2e/test/scenarios/admin/settings/sso/google.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/settings/sso/google.cy.spec.js rename to e2e/test/scenarios/admin/settings/sso/google.cy.spec.js index 78f8733fab0d2..ed559f2068d78 100644 --- a/frontend/test/metabase/scenarios/admin/settings/sso/google.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/sso/google.cy.spec.js @@ -3,7 +3,7 @@ import { popover, restore, typeAndBlurUsingLabel, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const CLIENT_ID_SUFFIX = "apps.googleusercontent.com"; diff --git a/e2e/test/scenarios/admin/settings/sso/group-mappings-widget.js b/e2e/test/scenarios/admin/settings/sso/group-mappings-widget.js new file mode 100644 index 0000000000000..c84aae4fe1c84 --- /dev/null +++ b/e2e/test/scenarios/admin/settings/sso/group-mappings-widget.js @@ -0,0 +1,105 @@ +import { popover } from "e2e/support/helpers"; + +export function crudGroupMappingsWidget(authenticationMethod) { + cy.visit("/admin/settings/authentication/" + authenticationMethod); + cy.wait("@getSettings"); + cy.wait("@getSessionProperties"); + + // Create mapping, then delete it along with its groups + createMapping("cn=People1"); + addGroupsToMapping("cn=People1", ["Administrators", "data", "nosql"]); + deleteMappingWithGroups("cn=People1"); + + cy.wait(["@deleteGroup", "@deleteGroup"]); + + // Create mapping, then clear its groups of members + createMapping("cn=People2"); + addGroupsToMapping("cn=People2", ["collection", "readonly"]); + // Groups deleted along with first mapping should not be offered + cy.findByText("data").should("not.exist"); + cy.findByText("nosql").should("not.exist"); + + cy.icon("close").click({ force: true }); + cy.findByText(/remove all group members/i).click(); + cy.button("Remove mapping and members").click(); + + cy.wait(["@clearGroup", "@clearGroup"]); + + cy.visit("/admin/people/groups"); + cy.findByText("data").should("not.exist"); + cy.findByText("nosql").should("not.exist"); + + checkThatGroupHasNoMembers("collection"); + checkThatGroupHasNoMembers("readonly"); +} + +export function checkGroupConsistencyAfterDeletingMappings( + authenticationMethod, +) { + cy.visit("/admin/settings/authentication/" + authenticationMethod); + + createMapping("cn=People1"); + addGroupsToMapping("cn=People1", ["Administrators", "data", "nosql"]); + + createMapping("cn=People2"); + addGroupsToMapping("cn=People2", ["data", "collection"]); + + createMapping("cn=People3"); + addGroupsToMapping("cn=People3", ["collection", "readonly"]); + + deleteMappingWithGroups("cn=People2"); + + // cn=People1 will have Admin and nosql as groups + cy.findByText("1 other group"); + + // cn=People3 will have readonly as group + cy.findByText("readonly"); + + // Ensure mappings are as expected after a page reload + cy.visit("/admin/settings/authentication/" + authenticationMethod); + cy.findByText("1 other group"); + cy.findByText("readonly"); +} + +const deleteMappingWithGroups = mappingName => { + cy.findByText(mappingName) + .closest("tr") + .within(() => { + cy.icon("close").click({ force: true }); + }); + + cy.findByText(/delete the groups/i).click(); + cy.button("Remove mapping and delete groups").click(); +}; + +const createMapping = name => { + cy.button("New mapping").click(); + cy.findByLabelText("new-group-mapping-name-input").type(name); + cy.button("Add").click(); +}; + +const addGroupsToMapping = (mappingName, groups) => { + cy.findByText(mappingName) + .closest("tr") + .within(() => { + cy.findByText("Default").click(); + }); + + groups.forEach(group => { + popover().within(() => { + cy.findByText(group).click(); + + cy.findByText(group) + .closest(".List-section") + .within(() => { + cy.icon("check"); + }); + }); + }); +}; + +const checkThatGroupHasNoMembers = name => { + cy.findByText(name) + .closest("tr") + .within(() => cy.findByText("0")); +}; diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/jwt.cy.spec.js b/e2e/test/scenarios/admin/settings/sso/jwt.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/settings/sso/jwt.cy.spec.js rename to e2e/test/scenarios/admin/settings/sso/jwt.cy.spec.js index 7b237ecbbce56..e8f36e94be0e4 100644 --- a/frontend/test/metabase/scenarios/admin/settings/sso/jwt.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/sso/jwt.cy.spec.js @@ -4,7 +4,7 @@ import { typeAndBlurUsingLabel, modal, popover, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describeEE("scenarios > admin > settings > SSO > JWT", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js b/e2e/test/scenarios/admin/settings/sso/ldap.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js rename to e2e/test/scenarios/admin/settings/sso/ldap.cy.spec.js index b2891770f3b46..a12da7ac5e54e 100644 --- a/frontend/test/metabase/scenarios/admin/settings/sso/ldap.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/sso/ldap.cy.spec.js @@ -4,7 +4,7 @@ import { restore, setupLdap, typeAndBlurUsingLabel, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe( "scenarios > admin > settings > SSO > LDAP", diff --git a/frontend/test/metabase/scenarios/admin/settings/sso/saml.cy.spec.js b/e2e/test/scenarios/admin/settings/sso/saml.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/settings/sso/saml.cy.spec.js rename to e2e/test/scenarios/admin/settings/sso/saml.cy.spec.js index e4416c49432f3..e7e8e904701a6 100644 --- a/frontend/test/metabase/scenarios/admin/settings/sso/saml.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/sso/saml.cy.spec.js @@ -4,7 +4,7 @@ import { typeAndBlurUsingLabel, popover, modal, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describeEE("scenarios > admin > settings > SSO > SAML", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/admin/settings/whitelabel.cy.spec.js b/e2e/test/scenarios/admin/settings/whitelabel.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/admin/settings/whitelabel.cy.spec.js rename to e2e/test/scenarios/admin/settings/whitelabel.cy.spec.js index cfbefc0baf1f3..04d7be717d442 100644 --- a/frontend/test/metabase/scenarios/admin/settings/whitelabel.cy.spec.js +++ b/e2e/test/scenarios/admin/settings/whitelabel.cy.spec.js @@ -1,4 +1,4 @@ -import { describeEE, restore } from "__support__/e2e/helpers"; +import { describeEE, restore } from "e2e/support/helpers"; function checkFavicon() { cy.request("/api/setting/application-favicon-url") @@ -7,11 +7,9 @@ function checkFavicon() { } function checkLogo() { - cy.readFile("frontend/test/__support__/e2e/assets/logo.jpeg", "base64").then( - logo_data => { - cy.get(`img[ src="data:image/jpeg;base64,${logo_data}"]`); - }, - ); + cy.readFile("e2e/support/assets/logo.jpeg", "base64").then(logo_data => { + cy.get(`img[ src="data:image/jpeg;base64,${logo_data}"]`); + }); } describeEE("formatting > whitelabel", () => { @@ -69,10 +67,7 @@ describeEE("formatting > whitelabel", () => { describe("company logo", () => { beforeEach(() => { cy.log("Add a logo"); - cy.readFile( - "frontend/test/__support__/e2e/assets/logo.jpeg", - "base64", - ).then(logo_data => { + cy.readFile("e2e/support/assets/logo.jpeg", "base64").then(logo_data => { cy.request("PUT", "/api/setting/application-logo-url", { value: `data:image/jpeg;base64,${logo_data}`, }); diff --git a/frontend/test/metabase/scenarios/admin/subscription/payment-failure.cy.spec.js b/e2e/test/scenarios/admin/subscription/payment-failure.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/admin/subscription/payment-failure.cy.spec.js rename to e2e/test/scenarios/admin/subscription/payment-failure.cy.spec.js index 10cabd180770c..690651d5c235e 100644 --- a/frontend/test/metabase/scenarios/admin/subscription/payment-failure.cy.spec.js +++ b/e2e/test/scenarios/admin/subscription/payment-failure.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, mockSessionProperty } from "__support__/e2e/helpers"; +import { restore, mockSessionProperty } from "e2e/support/helpers"; describe("banner", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/admin/tools/erroring-questions.cy.spec.js b/e2e/test/scenarios/admin/tools/erroring-questions.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/admin/tools/erroring-questions.cy.spec.js rename to e2e/test/scenarios/admin/tools/erroring-questions.cy.spec.js index 7d1b55a9f843b..91ff303c70068 100644 --- a/frontend/test/metabase/scenarios/admin/tools/erroring-questions.cy.spec.js +++ b/e2e/test/scenarios/admin/tools/erroring-questions.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, isEE } from "__support__/e2e/helpers"; +import { restore, isEE } from "e2e/support/helpers"; const TOOLS_ERRORS_URL = "/admin/tools/errors"; diff --git a/frontend/test/metabase/scenarios/admin/troubleshooting/help.cy.spec.js b/e2e/test/scenarios/admin/troubleshooting/help.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/admin/troubleshooting/help.cy.spec.js rename to e2e/test/scenarios/admin/troubleshooting/help.cy.spec.js index d1794e5d43171..64411c7b9adbe 100644 --- a/frontend/test/metabase/scenarios/admin/troubleshooting/help.cy.spec.js +++ b/e2e/test/scenarios/admin/troubleshooting/help.cy.spec.js @@ -3,7 +3,7 @@ import { describeEE, restore, setupMetabaseCloud, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > admin > troubleshooting > help", () => { beforeEach(() => { diff --git a/e2e/test/scenarios/admin/troubleshooting/tasks.cy.spec.js b/e2e/test/scenarios/admin/troubleshooting/tasks.cy.spec.js new file mode 100644 index 0000000000000..84e1402b7e86e --- /dev/null +++ b/e2e/test/scenarios/admin/troubleshooting/tasks.cy.spec.js @@ -0,0 +1,104 @@ +import { restore } from "e2e/support/helpers"; + +const total = 57; +const limit = 50; + +describe("scenarios > admin > troubleshooting > tasks", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + // The only reliable way to reproduce this issue is by stubing page responses! + // All previous attempts to generate enough real tasks (more than 50) + // resulted in flaky and unpredictable tests. + stubPageResponses({ page: 0, alias: "first" }); + stubPageResponses({ page: 1, alias: "second" }); + }); + + it("pagination should work (metabase#14636)", () => { + cy.visit("/admin/troubleshooting/tasks"); + cy.wait("@first"); + + cy.findByText("Troubleshooting logs"); + cy.icon("chevronleft").as("previous"); + cy.icon("chevronright").as("next"); + + cy.contains("1 - 50"); + cy.contains("field values scanning"); + cy.contains("513"); + + shouldBeDisabled("@previous"); + shouldNotBeDisabled("@next"); + + cy.get("@next").click(); + cy.wait("@second"); + + cy.contains(`51 - ${total}`); + cy.contains("1 - 50").should("not.exist"); + cy.contains("analyze"); + cy.contains("200"); + + shouldNotBeDisabled("@previous"); + shouldBeDisabled("@next"); + }); +}); + +function shouldNotBeDisabled(selector) { + cy.get(selector).parent().should("not.have.attr", "disabled"); +} + +function shouldBeDisabled(selector) { + cy.get(selector).parent().should("have.attr", "disabled"); +} + +/** + * @param {Object} payload + * @param {(0|1)} payload.page + * @param {("first"|"second")} payload.alias + */ +function stubPageResponses({ page, alias }) { + const offset = page * limit; + + cy.intercept("GET", `/api/task?limit=${limit}&offset=${offset}`, req => { + req.reply(res => { + res.body = { + data: stubPageRows(page), + limit, + offset, + total, + }; + }); + }).as(alias); +} + +/** + * @typedef {Object} Row + * + * @param {(0|1)} page + * @returns Row[] + */ +function stubPageRows(page) { + // There rows details don't really matter. + // We're generating two types of rows. One for each page. + const tasks = ["field values scanning", "analyze"]; + const durations = [513, 200]; + + /** type: {Row} */ + const row = { + id: page + 1, + task: tasks[page], + db_id: 1, + started_at: "2023-03-04T01:45:26.005475-08:00", + ended_at: "2023-03-04T01:45:26.518597-08:00", + duration: durations[page], + task_details: null, + name: `Item $page}`, + model: "card", + }; + + const pageRows = [limit, total - limit]; + const length = pageRows[page]; + + const stubbedRows = Array.from({ length }, () => row); + return stubbedRows; +} diff --git a/frontend/test/metabase/scenarios/auditing/README.md b/e2e/test/scenarios/auditing/README.md similarity index 100% rename from frontend/test/metabase/scenarios/auditing/README.md rename to e2e/test/scenarios/auditing/README.md diff --git a/frontend/test/metabase/scenarios/auditing/ad-hoc.cy.spec.js b/e2e/test/scenarios/auditing/ad-hoc.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/auditing/ad-hoc.cy.spec.js rename to e2e/test/scenarios/auditing/ad-hoc.cy.spec.js index 032e0a0f89785..935e9ff86c261 100644 --- a/frontend/test/metabase/scenarios/auditing/ad-hoc.cy.spec.js +++ b/e2e/test/scenarios/auditing/ad-hoc.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, describeEE, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, describeEE, openNativeEditor } from "e2e/support/helpers"; describeEE("audit > ad-hoc", () => { describe("native query", () => { diff --git a/frontend/test/metabase/scenarios/auditing/approved-domains.cy.spec.js b/e2e/test/scenarios/auditing/approved-domains.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/auditing/approved-domains.cy.spec.js rename to e2e/test/scenarios/auditing/approved-domains.cy.spec.js index c935f48c60c32..aabefd89c39ab 100644 --- a/frontend/test/metabase/scenarios/auditing/approved-domains.cy.spec.js +++ b/e2e/test/scenarios/auditing/approved-domains.cy.spec.js @@ -6,7 +6,7 @@ import { sidebar, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const allowedDomain = "metabase.test"; const deniedDomain = "metabase.example"; diff --git a/frontend/test/metabase/scenarios/auditing/auditing.cy.spec.js b/e2e/test/scenarios/auditing/auditing.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/auditing/auditing.cy.spec.js rename to e2e/test/scenarios/auditing/auditing.cy.spec.js index 726296787260a..c03f87aa8a657 100644 --- a/frontend/test/metabase/scenarios/auditing/auditing.cy.spec.js +++ b/e2e/test/scenarios/auditing/auditing.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, describeEE, visitQuestion } from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, describeEE, visitQuestion } from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { normal } = USERS; const { PRODUCTS } = SAMPLE_DATABASE; const TOTAL_USERS = Object.entries(USERS).length; diff --git a/frontend/test/metabase/scenarios/auditing/questions-audit.cy.spec.js b/e2e/test/scenarios/auditing/questions-audit.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/auditing/questions-audit.cy.spec.js rename to e2e/test/scenarios/auditing/questions-audit.cy.spec.js index 70e029a789763..f2f459d458a08 100644 --- a/frontend/test/metabase/scenarios/auditing/questions-audit.cy.spec.js +++ b/e2e/test/scenarios/auditing/questions-audit.cy.spec.js @@ -1,5 +1,5 @@ import _ from "underscore"; -import { restore, describeEE, visitQuestion } from "__support__/e2e/helpers"; +import { restore, describeEE, visitQuestion } from "e2e/support/helpers"; describeEE("audit > auditing > questions", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/auditing/subscriptions.cy.spec.js b/e2e/test/scenarios/auditing/subscriptions.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/auditing/subscriptions.cy.spec.js rename to e2e/test/scenarios/auditing/subscriptions.cy.spec.js index d852ca091e55d..62bf6d40e1a94 100644 --- a/frontend/test/metabase/scenarios/auditing/subscriptions.cy.spec.js +++ b/e2e/test/scenarios/auditing/subscriptions.cy.spec.js @@ -4,10 +4,10 @@ import { popover, describeEE, getFullName, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { USERS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; const { admin, nodata } = USERS; diff --git a/frontend/test/metabase/scenarios/binning/binning-options.cy.spec.js b/e2e/test/scenarios/binning/binning-options.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/binning/binning-options.cy.spec.js rename to e2e/test/scenarios/binning/binning-options.cy.spec.js index ff9061a445c04..6e51be38d26c2 100644 --- a/frontend/test/metabase/scenarios/binning/binning-options.cy.spec.js +++ b/e2e/test/scenarios/binning/binning-options.cy.spec.js @@ -5,10 +5,10 @@ import { visitQuestionAdhoc, getBinningButtonForDimension, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, ORDERS, PEOPLE_ID, PEOPLE, PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/binning/binning-reproductions.cy.spec.js b/e2e/test/scenarios/binning/binning-reproductions.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/binning/binning-reproductions.cy.spec.js rename to e2e/test/scenarios/binning/binning-reproductions.cy.spec.js index 5754a0a6aedfd..bd22897946742 100644 --- a/frontend/test/metabase/scenarios/binning/binning-reproductions.cy.spec.js +++ b/e2e/test/scenarios/binning/binning-reproductions.cy.spec.js @@ -8,10 +8,10 @@ import { startNewQuestion, summarize, openOrdersTable, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -207,7 +207,9 @@ describe("binning related reproductions", () => { cy.findByTestId("sidebar-left").within(() => { cy.findByTestId("Table-button").click(); cy.findByTextEnsureVisible("Table options"); - cy.findByText("Created At").siblings(".Icon-eye_outline").click(); + cy.findByText("Created At: Month") + .siblings("[data-testid$=hide-button]") + .click(); cy.button("Done").click(); }); diff --git a/frontend/test/metabase/scenarios/binning/correctness/longitude.cy.spec.js b/e2e/test/scenarios/binning/correctness/longitude.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/binning/correctness/longitude.cy.spec.js rename to e2e/test/scenarios/binning/correctness/longitude.cy.spec.js index 80af1df87b9a7..7518cba6b086c 100644 --- a/frontend/test/metabase/scenarios/binning/correctness/longitude.cy.spec.js +++ b/e2e/test/scenarios/binning/correctness/longitude.cy.spec.js @@ -3,7 +3,7 @@ import { popover, openPeopleTable, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { LONGITUDE_OPTIONS } from "./shared/constants"; diff --git a/frontend/test/metabase/scenarios/binning/correctness/shared/constants.js b/e2e/test/scenarios/binning/correctness/shared/constants.js similarity index 100% rename from frontend/test/metabase/scenarios/binning/correctness/shared/constants.js rename to e2e/test/scenarios/binning/correctness/shared/constants.js diff --git a/frontend/test/metabase/scenarios/binning/correctness/time-series.cy.spec.js b/e2e/test/scenarios/binning/correctness/time-series.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/binning/correctness/time-series.cy.spec.js rename to e2e/test/scenarios/binning/correctness/time-series.cy.spec.js index f5e0bacd4c223..ce6f537649d1c 100644 --- a/frontend/test/metabase/scenarios/binning/correctness/time-series.cy.spec.js +++ b/e2e/test/scenarios/binning/correctness/time-series.cy.spec.js @@ -3,9 +3,9 @@ import { popover, getBinningButtonForDimension, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { TIME_OPTIONS } from "./shared/constants"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/binning/qb-explicit-joins.cy.spec.js b/e2e/test/scenarios/binning/qb-explicit-joins.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/binning/qb-explicit-joins.cy.spec.js rename to e2e/test/scenarios/binning/qb-explicit-joins.cy.spec.js index 584c9dfe119f5..d2e749bfe68d4 100644 --- a/frontend/test/metabase/scenarios/binning/qb-explicit-joins.cy.spec.js +++ b/e2e/test/scenarios/binning/qb-explicit-joins.cy.spec.js @@ -4,8 +4,8 @@ import { changeBinningForDimension, summarize, startNewQuestion, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, ORDERS, PEOPLE_ID, PEOPLE, PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/binning/qb-implicit-joins.cy.spec.js b/e2e/test/scenarios/binning/qb-implicit-joins.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/binning/qb-implicit-joins.cy.spec.js rename to e2e/test/scenarios/binning/qb-implicit-joins.cy.spec.js index b0ba292b3e757..a0b182641ee20 100644 --- a/frontend/test/metabase/scenarios/binning/qb-implicit-joins.cy.spec.js +++ b/e2e/test/scenarios/binning/qb-implicit-joins.cy.spec.js @@ -4,7 +4,7 @@ import { visualize, summarize, visitQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; /** * The list of issues this spec covers: diff --git a/frontend/test/metabase/scenarios/binning/qb-regular-table.cy.spec.js b/e2e/test/scenarios/binning/qb-regular-table.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/binning/qb-regular-table.cy.spec.js rename to e2e/test/scenarios/binning/qb-regular-table.cy.spec.js index 6ad932069af23..6f02dff764ec1 100644 --- a/frontend/test/metabase/scenarios/binning/qb-regular-table.cy.spec.js +++ b/e2e/test/scenarios/binning/qb-regular-table.cy.spec.js @@ -4,8 +4,8 @@ import { visualize, changeBinningForDimension, summarize, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/binning/sql.cy.spec.js b/e2e/test/scenarios/binning/sql.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/binning/sql.cy.spec.js rename to e2e/test/scenarios/binning/sql.cy.spec.js index de535e8f9e0bd..5e07685c7fb08 100644 --- a/frontend/test/metabase/scenarios/binning/sql.cy.spec.js +++ b/e2e/test/scenarios/binning/sql.cy.spec.js @@ -5,7 +5,7 @@ import { changeBinningForDimension, summarize, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const questionDetails = { name: "SQL Binning", diff --git a/frontend/test/metabase/scenarios/collections/archive.cy.spec.js b/e2e/test/scenarios/collections/archive.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/collections/archive.cy.spec.js rename to e2e/test/scenarios/collections/archive.cy.spec.js index 3f9e3c77e8732..d213c7068e7ee 100644 --- a/frontend/test/metabase/scenarios/collections/archive.cy.spec.js +++ b/e2e/test/scenarios/collections/archive.cy.spec.js @@ -1,5 +1,5 @@ -import { getCollectionIdFromSlug, restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { getCollectionIdFromSlug, restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/collections/collection-items-listing.cy.spec.js b/e2e/test/scenarios/collections/collection-items-listing.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/collections/collection-items-listing.cy.spec.js rename to e2e/test/scenarios/collections/collection-items-listing.cy.spec.js index c3edbb1fa969b..a3d934bab5aaf 100644 --- a/frontend/test/metabase/scenarios/collections/collection-items-listing.cy.spec.js +++ b/e2e/test/scenarios/collections/collection-items-listing.cy.spec.js @@ -1,6 +1,6 @@ import _ from "underscore"; -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -25,7 +25,7 @@ describe("scenarios > collection items listing", () => { const PAGE_SIZE = 25; describe("pagination", () => { - const SUBCOLLECTIONS = 2; + const SUBCOLLECTIONS = 1; const ADDED_QUESTIONS = 15; const ADDED_DASHBOARDS = 14; diff --git a/frontend/test/metabase/scenarios/collections/collection-pinned-overview.cy.spec.js b/e2e/test/scenarios/collections/collection-pinned-overview.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/collections/collection-pinned-overview.cy.spec.js rename to e2e/test/scenarios/collections/collection-pinned-overview.cy.spec.js index 5ec675f3a2b8d..e1b6b02f7fc6d 100644 --- a/frontend/test/metabase/scenarios/collections/collection-pinned-overview.cy.spec.js +++ b/e2e/test/scenarios/collections/collection-pinned-overview.cy.spec.js @@ -1,5 +1,5 @@ -import { popover, restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { popover, restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/collections/collections.cy.spec.js b/e2e/test/scenarios/collections/collections.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/collections/collections.cy.spec.js rename to e2e/test/scenarios/collections/collections.cy.spec.js index 8f9e7265154e7..d30eb64faca1c 100644 --- a/frontend/test/metabase/scenarios/collections/collections.cy.spec.js +++ b/e2e/test/scenarios/collections/collections.cy.spec.js @@ -1,4 +1,5 @@ import { assocIn } from "icepick"; +import _ from "underscore"; import { restore, modal, @@ -10,21 +11,57 @@ import { closeNavigationSidebar, openCollectionMenu, visitCollection, - getPersonalCollectionName, -} from "__support__/e2e/helpers"; -import { USERS, USER_GROUPS } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS, USER_GROUPS } from "e2e/support/cypress_data"; import { displaySidebarChildOf } from "./helpers/e2e-collections-sidebar.js"; const { nocollection } = USERS; const { DATA_GROUP } = USER_GROUPS; describe("scenarios > collection defaults", () => { - describe("sidebar behavior", () => { - beforeEach(() => { - restore(); - cy.signInAsAdmin(); + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + describe("new collection modal", () => { + it("should be usable on small screens", () => { + const COLLECTIONS_COUNT = 5; + _.times(COLLECTIONS_COUNT, index => { + cy.request("POST", "/api/collection", { + name: `Collection ${index + 1}`, + color: "#509EE3", + parent_id: null, + }); + }); + + cy.visit("/"); + + cy.viewport(800, 500); + + cy.findByText("New").click(); + cy.findByText("Collection").click(); + + modal().within(() => { + cy.findByLabelText("Name").type("Test collection"); + cy.findByLabelText("Description").type("Test collection description"); + cy.findByText("Our analytics").click(); + }); + + popover().within(() => { + cy.findByText(`Collection ${COLLECTIONS_COUNT}`).click(); + }); + + cy.findByText("Create").click(); + + cy.findByTestId("collection-name-heading").should( + "have.text", + "Test collection", + ); }); + }); + describe("sidebar behavior", () => { it("should navigate effortlessly through collections tree", () => { visitRootCollection(); @@ -375,18 +412,6 @@ describe("scenarios > collection defaults", () => { }); }); - it("should not be able to move or archive a personal collection", () => { - cy.visit("/collection/root"); - - openEllipsisMenuFor(getPersonalCollectionName(USERS.admin)); - - popover().within(() => { - cy.findByText("Bookmark").should("be.visible"); - cy.findByText("Move").should("not.exist"); - cy.findByText("Archive").should("not.exist"); - }); - }); - describe("bulk actions", () => { describe("selection", () => { it("should be possible to apply bulk selection to all items (metabase#14705)", () => { @@ -407,19 +432,6 @@ describe("scenarios > collection defaults", () => { cy.findByTestId("bulk-action-bar").should("not.be.visible"); }); - it("should not be possible to archive or move a personal collection via bulk actions", () => { - cy.visit("/collection/root"); - - selectItemUsingCheckbox( - getPersonalCollectionName(USERS.admin), - "person", - ); - - cy.findByText("1 item selected").should("be.visible"); - cy.button("Move").should("be.disabled"); - cy.button("Archive").should("be.disabled"); - }); - function bulkSelectDeselectWorkflow() { cy.visit("/collection/root"); selectItemUsingCheckbox("Orders"); @@ -430,7 +442,7 @@ describe("scenarios > collection defaults", () => { cy.findByRole("checkbox"); cy.icon("dash").click({ force: true }); cy.icon("dash").should("not.exist"); - cy.findByText("6 items selected"); + cy.findByText("5 items selected"); // Deselect all cy.icon("check").click({ force: true }); diff --git a/frontend/test/metabase/scenarios/collections/helpers/e2e-collections-sidebar.js b/e2e/test/scenarios/collections/helpers/e2e-collections-sidebar.js similarity index 100% rename from frontend/test/metabase/scenarios/collections/helpers/e2e-collections-sidebar.js rename to e2e/test/scenarios/collections/helpers/e2e-collections-sidebar.js diff --git a/frontend/test/metabase/scenarios/collections/permissions.cy.spec.js b/e2e/test/scenarios/collections/permissions.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/collections/permissions.cy.spec.js rename to e2e/test/scenarios/collections/permissions.cy.spec.js index f237d2bf10bb0..21d08cd393255 100644 --- a/frontend/test/metabase/scenarios/collections/permissions.cy.spec.js +++ b/e2e/test/scenarios/collections/permissions.cy.spec.js @@ -8,9 +8,9 @@ import { openNativeEditor, openCollectionMenu, modal, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; import { displaySidebarChildOf } from "./helpers/e2e-collections-sidebar.js"; const PERMISSIONS = { @@ -282,7 +282,7 @@ describe("collection permissions", () => { cy.icon("lock").should("not.exist"); /** * We can take 2 routes from here - it will really depend on the design decision: - * 1. Edit icon shouldn't exist at all in which case some other call to action menu/button should exist + * 1. Edit icon shouldn't exist at all in which case some other call to drill-through menu/button should exist * notifying the user that this collection is archived and prompting them to unarchive it * 2. Edit icon stays but with "Unarchive this item" ONLY in the menu */ diff --git a/frontend/test/metabase/scenarios/collections/personal-collections.cy.spec.js b/e2e/test/scenarios/collections/personal-collections.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/collections/personal-collections.cy.spec.js rename to e2e/test/scenarios/collections/personal-collections.cy.spec.js index 83e492cb1a3a6..6d00f4dfadffc 100644 --- a/frontend/test/metabase/scenarios/collections/personal-collections.cy.spec.js +++ b/e2e/test/scenarios/collections/personal-collections.cy.spec.js @@ -6,9 +6,9 @@ import { openNewCollectionItemFlowFor, getCollectionActions, openCollectionMenu, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const ADMIN_PERSONAL_COLLECTION_ID = 1; const NODATA_PERSONAL_COLLECTION_ID = 5; diff --git a/frontend/test/metabase/scenarios/collections/reproductions/23515-pinned-question-pagination.cy.spec.js b/e2e/test/scenarios/collections/reproductions/23515-pinned-question-pagination.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/collections/reproductions/23515-pinned-question-pagination.cy.spec.js rename to e2e/test/scenarios/collections/reproductions/23515-pinned-question-pagination.cy.spec.js index 35e18c296dadf..e0e7893ac36e8 100644 --- a/frontend/test/metabase/scenarios/collections/reproductions/23515-pinned-question-pagination.cy.spec.js +++ b/e2e/test/scenarios/collections/reproductions/23515-pinned-question-pagination.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("issue 23515", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/collections/reproductions/24660-same-name-parent-collections.cy.spec.js b/e2e/test/scenarios/collections/reproductions/24660-same-name-parent-collections.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/collections/reproductions/24660-same-name-parent-collections.cy.spec.js rename to e2e/test/scenarios/collections/reproductions/24660-same-name-parent-collections.cy.spec.js index ee2652a42586a..e270abcff6ef4 100644 --- a/frontend/test/metabase/scenarios/collections/reproductions/24660-same-name-parent-collections.cy.spec.js +++ b/e2e/test/scenarios/collections/reproductions/24660-same-name-parent-collections.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, startNewQuestion } from "__support__/e2e/helpers"; +import { restore, startNewQuestion } from "e2e/support/helpers"; const collectionName = "Parent"; diff --git a/frontend/test/metabase/scenarios/collections/revision-history.cy.spec.js b/e2e/test/scenarios/collections/revision-history.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/collections/revision-history.cy.spec.js rename to e2e/test/scenarios/collections/revision-history.cy.spec.js index 7750242aec404..60e6f659e25b9 100644 --- a/frontend/test/metabase/scenarios/collections/revision-history.cy.spec.js +++ b/e2e/test/scenarios/collections/revision-history.cy.spec.js @@ -6,7 +6,7 @@ import { visitQuestion, questionInfoButton, rightSidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const PERMISSIONS = { curate: ["admin", "normal", "nodata"], diff --git a/frontend/test/metabase/scenarios/cross-version/source/00-setup.cy.spec.js b/e2e/test/scenarios/cross-version/source/00-setup.cy.spec.js similarity index 100% rename from frontend/test/metabase/scenarios/cross-version/source/00-setup.cy.spec.js rename to e2e/test/scenarios/cross-version/source/00-setup.cy.spec.js diff --git a/e2e/test/scenarios/cross-version/source/01-generate-metadata.cy.spec.js b/e2e/test/scenarios/cross-version/source/01-generate-metadata.cy.spec.js new file mode 100644 index 0000000000000..da2d89f4a6634 --- /dev/null +++ b/e2e/test/scenarios/cross-version/source/01-generate-metadata.cy.spec.js @@ -0,0 +1,9 @@ +import { withSampleDatabase } from "e2e/support/helpers"; + +it("should generate metadata", () => { + cy.signInAsAdmin(); + + withSampleDatabase(SAMPLE_DATABASE => { + cy.writeFile("e2e/support/cypress_sample_database.json", SAMPLE_DATABASE); + }); +}); diff --git a/frontend/test/metabase/scenarios/cross-version/source/02-datamodel.cy.spec.js b/e2e/test/scenarios/cross-version/source/02-datamodel.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/cross-version/source/02-datamodel.cy.spec.js rename to e2e/test/scenarios/cross-version/source/02-datamodel.cy.spec.js index 16e6f950b579f..2fcef52fc2f0f 100644 --- a/frontend/test/metabase/scenarios/cross-version/source/02-datamodel.cy.spec.js +++ b/e2e/test/scenarios/cross-version/source/02-datamodel.cy.spec.js @@ -1,5 +1,5 @@ -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, REVIEWS, PRODUCTS, PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/cross-version/source/03-questions.cy.spec.js b/e2e/test/scenarios/cross-version/source/03-questions.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/cross-version/source/03-questions.cy.spec.js rename to e2e/test/scenarios/cross-version/source/03-questions.cy.spec.js index cff7424078aef..1bac3140c2194 100644 --- a/frontend/test/metabase/scenarios/cross-version/source/03-questions.cy.spec.js +++ b/e2e/test/scenarios/cross-version/source/03-questions.cy.spec.js @@ -1,4 +1,4 @@ -import { visualize } from "__support__/e2e/helpers"; +import { visualize } from "e2e/support/helpers"; it("should create questions", () => { cy.signInAsAdmin(); diff --git a/frontend/test/metabase/scenarios/cross-version/source/helpers/cross-version-source-helpers.js b/e2e/test/scenarios/cross-version/source/helpers/cross-version-source-helpers.js similarity index 100% rename from frontend/test/metabase/scenarios/cross-version/source/helpers/cross-version-source-helpers.js rename to e2e/test/scenarios/cross-version/source/helpers/cross-version-source-helpers.js diff --git a/frontend/test/metabase/scenarios/cross-version/source/shared/cross-version-source.config.js b/e2e/test/scenarios/cross-version/source/shared/cross-version-source.config.js similarity index 60% rename from frontend/test/metabase/scenarios/cross-version/source/shared/cross-version-source.config.js rename to e2e/test/scenarios/cross-version/source/shared/cross-version-source.config.js index 38c47459e9716..cf3ac6885f1aa 100644 --- a/frontend/test/metabase/scenarios/cross-version/source/shared/cross-version-source.config.js +++ b/e2e/test/scenarios/cross-version/source/shared/cross-version-source.config.js @@ -1,4 +1,4 @@ const { defineConfig } = require("cypress"); -const { crossVersionSourceConfig } = require("__support__/e2e/config"); +const { crossVersionSourceConfig } = require("e2e/support/config"); module.exports = defineConfig({ e2e: crossVersionSourceConfig }); diff --git a/frontend/test/metabase/scenarios/cross-version/target/helpers/cross-version-target-helpers.js b/e2e/test/scenarios/cross-version/target/helpers/cross-version-target-helpers.js similarity index 100% rename from frontend/test/metabase/scenarios/cross-version/target/helpers/cross-version-target-helpers.js rename to e2e/test/scenarios/cross-version/target/helpers/cross-version-target-helpers.js diff --git a/frontend/test/metabase/scenarios/cross-version/target/shared/cross-version-target.config.js b/e2e/test/scenarios/cross-version/target/shared/cross-version-target.config.js similarity index 60% rename from frontend/test/metabase/scenarios/cross-version/target/shared/cross-version-target.config.js rename to e2e/test/scenarios/cross-version/target/shared/cross-version-target.config.js index 6413865ba104c..e732c024c2ada 100644 --- a/frontend/test/metabase/scenarios/cross-version/target/shared/cross-version-target.config.js +++ b/e2e/test/scenarios/cross-version/target/shared/cross-version-target.config.js @@ -1,4 +1,4 @@ const { defineConfig } = require("cypress"); -const { crossVersionTargetConfig } = require("__support__/e2e/config"); +const { crossVersionTargetConfig } = require("e2e/support/config"); module.exports = defineConfig({ e2e: crossVersionTargetConfig }); diff --git a/frontend/test/metabase/scenarios/cross-version/target/smoke.cy.spec.js b/e2e/test/scenarios/cross-version/target/smoke.cy.spec.js similarity index 100% rename from frontend/test/metabase/scenarios/cross-version/target/smoke.cy.spec.js rename to e2e/test/scenarios/cross-version/target/smoke.cy.spec.js diff --git a/frontend/test/metabase/scenarios/custom-column/cc-data-type.cy.spec.js b/e2e/test/scenarios/custom-column/cc-data-type.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/custom-column/cc-data-type.cy.spec.js rename to e2e/test/scenarios/custom-column/cc-data-type.cy.spec.js index bfa049fb7b4a1..13a56a9ea8950 100644 --- a/frontend/test/metabase/scenarios/custom-column/cc-data-type.cy.spec.js +++ b/e2e/test/scenarios/custom-column/cc-data-type.cy.spec.js @@ -7,9 +7,9 @@ import { openOrdersTable, visualize, getNotebookStep, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, PEOPLE_ID, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/cc-error-feedback.cy.spec.js b/e2e/test/scenarios/custom-column/cc-error-feedback.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/custom-column/cc-error-feedback.cy.spec.js rename to e2e/test/scenarios/custom-column/cc-error-feedback.cy.spec.js index 849f8239f89be..5aeab4ed90935 100644 --- a/frontend/test/metabase/scenarios/custom-column/cc-error-feedback.cy.spec.js +++ b/e2e/test/scenarios/custom-column/cc-error-feedback.cy.spec.js @@ -2,7 +2,7 @@ import { restore, openProductsTable, enterCustomColumnDetails, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > question > custom column > error feedback", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/custom-column/cc-expression-editor.cy.spec.js b/e2e/test/scenarios/custom-column/cc-expression-editor.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/custom-column/cc-expression-editor.cy.spec.js rename to e2e/test/scenarios/custom-column/cc-expression-editor.cy.spec.js index b5e129acc74ac..f94838421e86b 100644 --- a/frontend/test/metabase/scenarios/custom-column/cc-expression-editor.cy.spec.js +++ b/e2e/test/scenarios/custom-column/cc-expression-editor.cy.spec.js @@ -2,7 +2,7 @@ import { restore, openOrdersTable, enterCustomColumnDetails, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; // ExpressionEditorTextfield jsx component describe("scenarios > question > custom column > expression editor", () => { diff --git a/frontend/test/metabase/scenarios/custom-column/cc-help-text.cy.spec.js b/e2e/test/scenarios/custom-column/cc-help-text.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/custom-column/cc-help-text.cy.spec.js rename to e2e/test/scenarios/custom-column/cc-help-text.cy.spec.js index e8f5d3fc670f4..3920845312851 100644 --- a/frontend/test/metabase/scenarios/custom-column/cc-help-text.cy.spec.js +++ b/e2e/test/scenarios/custom-column/cc-help-text.cy.spec.js @@ -2,7 +2,7 @@ import { enterCustomColumnDetails, restore, openProductsTable, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > question > custom column > help text", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/custom-column/cc-typing-suggestion.cy.spec.js b/e2e/test/scenarios/custom-column/cc-typing-suggestion.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/custom-column/cc-typing-suggestion.cy.spec.js rename to e2e/test/scenarios/custom-column/cc-typing-suggestion.cy.spec.js index 271c9b63c6758..06db3baccfcfc 100644 --- a/frontend/test/metabase/scenarios/custom-column/cc-typing-suggestion.cy.spec.js +++ b/e2e/test/scenarios/custom-column/cc-typing-suggestion.cy.spec.js @@ -2,7 +2,7 @@ import { enterCustomColumnDetails, openProductsTable, restore, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > question > custom column > typing suggestion", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/custom-column/custom-column.cy.spec.js b/e2e/test/scenarios/custom-column/custom-column.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/custom-column/custom-column.cy.spec.js rename to e2e/test/scenarios/custom-column/custom-column.cy.spec.js index d06b727ce479c..83558c8b82339 100644 --- a/frontend/test/metabase/scenarios/custom-column/custom-column.cy.spec.js +++ b/e2e/test/scenarios/custom-column/custom-column.cy.spec.js @@ -9,10 +9,10 @@ import { enterCustomColumnDetails, getBinningButtonForDimension, filter, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/12445-cc-mysql-apply-substring.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/12445-cc-mysql-apply-substring.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/custom-column/reproductions/12445-cc-mysql-apply-substring.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/12445-cc-mysql-apply-substring.cy.spec.js index 063f2f96acaeb..3d29643b2fd24 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/12445-cc-mysql-apply-substring.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/12445-cc-mysql-apply-substring.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, withDatabase } from "__support__/e2e/helpers"; +import { restore, withDatabase } from "e2e/support/helpers"; const CC_NAME = "Abbr"; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/13289-cc-post-aggregation-zoom-in.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/13289-cc-post-aggregation-zoom-in.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/custom-column/reproductions/13289-cc-post-aggregation-zoom-in.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/13289-cc-post-aggregation-zoom-in.cy.spec.js index 4e690acf69a46..0461bc5c51a2e 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/13289-cc-post-aggregation-zoom-in.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/13289-cc-post-aggregation-zoom-in.cy.spec.js @@ -5,7 +5,7 @@ import { enterCustomColumnDetails, visualize, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const CC_NAME = "Math"; describe("issue 13289", () => { diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/13751-cc-allow-strings-in-filter.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/13751-cc-allow-strings-in-filter.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/custom-column/reproductions/13751-cc-allow-strings-in-filter.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/13751-cc-allow-strings-in-filter.cy.spec.js index f33e1ed4c44eb..7175e4ec2c99f 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/13751-cc-allow-strings-in-filter.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/13751-cc-allow-strings-in-filter.cy.spec.js @@ -4,7 +4,7 @@ import { visualize, restore, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const CC_NAME = "C-States"; const PG_DB_NAME = "QA Postgres12"; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/14517-cc-do-not-remove-regex-escape-chars.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/14517-cc-do-not-remove-regex-escape-chars.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/custom-column/reproductions/14517-cc-do-not-remove-regex-escape-chars.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/14517-cc-do-not-remove-regex-escape-chars.cy.spec.js index 8c6e3dfe924d9..f8138a0148312 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/14517-cc-do-not-remove-regex-escape-chars.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/14517-cc-do-not-remove-regex-escape-chars.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover, startNewQuestion } from "__support__/e2e/helpers"; +import { restore, popover, startNewQuestion } from "e2e/support/helpers"; const PG_DB_NAME = "QA Postgres12"; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/14843-cc-apply-filter-not-equal-to.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/14843-cc-apply-filter-not-equal-to.cy.spec.js similarity index 83% rename from frontend/test/metabase/scenarios/custom-column/reproductions/14843-cc-apply-filter-not-equal-to.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/14843-cc-apply-filter-not-equal-to.cy.spec.js index 66cacd652364f..40ed34c2b350c 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/14843-cc-apply-filter-not-equal-to.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/14843-cc-apply-filter-not-equal-to.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, popover, visualize, filter } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover, visualize, filter } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; const CC_NAME = "City Length"; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/18069-cc-sum-aggregation-dimension-type.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/18069-cc-sum-aggregation-dimension-type.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/custom-column/reproductions/18069-cc-sum-aggregation-dimension-type.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/18069-cc-sum-aggregation-dimension-type.cy.spec.js index b24176d05a6a3..4da208d0db423 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/18069-cc-sum-aggregation-dimension-type.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/18069-cc-sum-aggregation-dimension-type.cy.spec.js @@ -1,11 +1,6 @@ -import { - restore, - popover, - visualize, - summarize, -} from "__support__/e2e/helpers"; - -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover, visualize, summarize } from "e2e/support/helpers"; + +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/18747-cc-connected-to-dashboard-parameter.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/18747-cc-connected-to-dashboard-parameter.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/custom-column/reproductions/18747-cc-connected-to-dashboard-parameter.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/18747-cc-connected-to-dashboard-parameter.cy.spec.js index 89c3595ff4e08..bf7715010c276 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/18747-cc-connected-to-dashboard-parameter.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/18747-cc-connected-to-dashboard-parameter.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, popover, visitDashboard } from "__support__/e2e/helpers"; +import { restore, popover, visitDashboard } from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/18814-cc-used-in-aggregation-for-nested-query.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/18814-cc-used-in-aggregation-for-nested-query.cy.spec.js similarity index 87% rename from frontend/test/metabase/scenarios/custom-column/reproductions/18814-cc-used-in-aggregation-for-nested-query.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/18814-cc-used-in-aggregation-for-nested-query.cy.spec.js index 8a5e78c5b68cd..bd1e0be33d3e6 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/18814-cc-used-in-aggregation-for-nested-query.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/18814-cc-used-in-aggregation-for-nested-query.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover, visualize } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover, visualize } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/19744-cc-after-aggregation-limited-filters.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/19744-cc-after-aggregation-limited-filters.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/custom-column/reproductions/19744-cc-after-aggregation-limited-filters.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/19744-cc-after-aggregation-limited-filters.cy.spec.js index 06f9a32a5a867..599a90a9a8fdd 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/19744-cc-after-aggregation-limited-filters.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/19744-cc-after-aggregation-limited-filters.cy.spec.js @@ -4,10 +4,10 @@ import { visitQuestionAdhoc, popover, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/19745-cc-nested-query-remove-expressions.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/19745-cc-nested-query-remove-expressions.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/custom-column/reproductions/19745-cc-nested-query-remove-expressions.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/19745-cc-nested-query-remove-expressions.cy.spec.js index 9e01ce7c9f742..b50f98ffd31e2 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/19745-cc-nested-query-remove-expressions.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/19745-cc-nested-query-remove-expressions.cy.spec.js @@ -9,8 +9,8 @@ import { selectDashboardFilter, visitDashboard, visitQuestion, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/20229-cc-missing-if-all-columns-not-selected.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/20229-cc-missing-if-all-columns-not-selected.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/custom-column/reproductions/20229-cc-missing-if-all-columns-not-selected.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/20229-cc-missing-if-all-columns-not-selected.cy.spec.js index 2d779f679c1f1..a60cb988ebdd9 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/20229-cc-missing-if-all-columns-not-selected.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/20229-cc-missing-if-all-columns-not-selected.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover, visualize } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover, visualize } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/21135-cc-same-name-as-existing-column.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/21135-cc-same-name-as-existing-column.cy.spec.js similarity index 85% rename from frontend/test/metabase/scenarios/custom-column/reproductions/21135-cc-same-name-as-existing-column.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/21135-cc-same-name-as-existing-column.cy.spec.js index dc3cf7ed3939e..7a815e0fa1ee7 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/21135-cc-same-name-as-existing-column.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/21135-cc-same-name-as-existing-column.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; @@ -31,7 +31,7 @@ describe("issue 21135", () => { // We should probably use data-testid or some better selector but it is crucial // to narrow the results to the preview area to avoid false positive result. - cy.get("[class*=TableInteractive]").within(() => { + cy.findByTestId("preview-root").within(() => { cy.findByText("Rustic Paper Wallet"); cy.findAllByText("Price").should("have.length", 2); diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/21513-cc-confusion-field-function.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/21513-cc-confusion-field-function.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/custom-column/reproductions/21513-cc-confusion-field-function.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/21513-cc-confusion-field-function.cy.spec.js index 823154dffe79d..56c77f8dae377 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/21513-cc-confusion-field-function.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/21513-cc-confusion-field-function.cy.spec.js @@ -4,7 +4,7 @@ import { openProductsTable, summarize, enterCustomColumnDetails, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 21513", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/23862-cc-group-by-nested.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/23862-cc-group-by-nested.cy.spec.js similarity index 84% rename from frontend/test/metabase/scenarios/custom-column/reproductions/23862-cc-group-by-nested.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/23862-cc-group-by-nested.cy.spec.js index 35d59a628afb9..d9745b5093ae0 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/23862-cc-group-by-nested.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/23862-cc-group-by-nested.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/24922-cc-case-segment.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/24922-cc-case-segment.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/custom-column/reproductions/24922-cc-case-segment.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/24922-cc-case-segment.cy.spec.js index d042ce64ee370..4bd0868cfe996 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/24922-cc-case-segment.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/24922-cc-case-segment.cy.spec.js @@ -3,8 +3,8 @@ import { openOrdersTable, restore, visualize, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/25189-cc-column-reference-only.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/25189-cc-column-reference-only.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/custom-column/reproductions/25189-cc-column-reference-only.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/25189-cc-column-reference-only.cy.spec.js index 2f1c37b7a2387..d2cb47d2ed0d9 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/25189-cc-column-reference-only.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/25189-cc-column-reference-only.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, filter, summarize } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, filter, summarize } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/custom-column/reproductions/27745-cc-numeric-missing-summarize.cy.spec.js b/e2e/test/scenarios/custom-column/reproductions/27745-cc-numeric-missing-summarize.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/custom-column/reproductions/27745-cc-numeric-missing-summarize.cy.spec.js rename to e2e/test/scenarios/custom-column/reproductions/27745-cc-numeric-missing-summarize.cy.spec.js index 523173ba0dd38..6eb44bf6341a4 100644 --- a/frontend/test/metabase/scenarios/custom-column/reproductions/27745-cc-numeric-missing-summarize.cy.spec.js +++ b/e2e/test/scenarios/custom-column/reproductions/27745-cc-numeric-missing-summarize.cy.spec.js @@ -5,10 +5,10 @@ import { visualize, popover, resetTestTable, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; ["postgres", "mysql"].forEach(dialect => { - describe.skip(`issue 27745 (${dialect})`, { tags: "@external" }, () => { + describe(`issue 27745 (${dialect})`, { tags: "@external" }, () => { const tableName = "colors27745"; beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js similarity index 78% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js index 72dca85fe0e0e..4cfeae92ec18f 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js @@ -6,7 +6,7 @@ import { saveDashboard, setFilter, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import * as DateFilter from "../native-filters/helpers/e2e-date-filter-helpers"; import { DASHBOARD_DATE_FILTERS } from "./shared/dashboard-filters-date"; @@ -108,6 +108,41 @@ describe("scenarios > dashboard > filters > date", () => { cy.icon("ellipsis").click(); cy.contains("Include this minute").click(); }); + + it("correctly serializes exclude filter on non-English locales (metabase#29122)", () => { + cy.request("GET", "/api/user/current").then(({ body: { id: USER_ID } }) => { + cy.request("PUT", `/api/user/${USER_ID}`, { locale: "fr" }); + }); + + visitDashboard(1); + cy.icon("pencil").click(); + cy.icon("filter").click(); + + popover().within(() => { + cy.findByText("Heure").click(); // "Time" + cy.findByText("Toutes les options").click(); // "All Options" + }); + + cy.findByText("Sélectionner...").click(); // "Select…" + popover().contains("Created At").first().click(); + + saveDashboard({ + buttonLabel: "Sauvegarder", + editBarText: "Vous êtes en train d'éditer ce tableau de bord.", + }); + + cy.findByText("Filtre de date").click(); // "Date Filter" + cy.findByText("Exclure...").click(); // "Exclude..." + cy.findByText("Mois de l'année...").click(); // "Months of the year..." + cy.findByText("janvier").click(); // "January" + + cy.findByText("Mettre à jour le filtre").click(); // "Update filter" + + cy.url().should( + "match", + /\/dashboard\/1\?filtre_de_date=exclude-months-Jan/, + ); + }); }); function dateFilterSelector({ filterType, filterValue } = {}) { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-explicit-join.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-explicit-join.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-explicit-join.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-explicit-join.cy.spec.js index 0848b99ef91c7..9777d5522b3fb 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-explicit-join.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-explicit-join.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, filterWidget, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, filterWidget, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-id.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-id.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-id.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-id.cy.spec.js index bbf3de7065a6f..6d407f70a55b0 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-id.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-id.cy.spec.js @@ -7,7 +7,7 @@ import { setFilter, checkFilterLabelAndValue, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { addWidgetStringFilter } from "../native-filters/helpers/e2e-field-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-location.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-location.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-location.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-location.cy.spec.js index 3ff75146e915a..11bd10caad717 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-location.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-location.cy.spec.js @@ -6,7 +6,7 @@ import { saveDashboard, setFilter, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { addWidgetStringFilter } from "../native-filters/helpers/e2e-field-filter-helpers"; import { DASHBOARD_LOCATION_FILTERS } from "./shared/dashboard-filters-location"; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-nested.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-nested.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-nested.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-nested.cy.spec.js index 8fefb17824312..061ac46242375 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-nested.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-nested.cy.spec.js @@ -6,9 +6,9 @@ import { saveDashboard, visitDashboard, setFilter, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-number.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-number.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-number.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-number.cy.spec.js index 3bed442ac417b..92ed7fe9ed620 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-number.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-number.cy.spec.js @@ -6,7 +6,7 @@ import { saveDashboard, setFilter, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { addWidgetNumberFilter } from "../native-filters/helpers/e2e-field-filter-helpers"; import { DASHBOARD_NUMBER_FILTERS } from "./shared/dashboard-filters-number"; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js index 7559b7908c820..8289ba692ee7e 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js @@ -13,8 +13,8 @@ import { visitPublicDashboard, describeEE, setSearchBoxFilterType, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-date.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-date.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-date.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-date.cy.spec.js index c7ed496256750..ced095d643b65 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-date.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-date.cy.spec.js @@ -7,7 +7,7 @@ import { setFilter, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import * as DateFilter from "../native-filters/helpers/e2e-date-filter-helpers"; import { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-id.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-id.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-id.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-id.cy.spec.js index 3f17eb18bc848..4a35e7ec7e8ee 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-id.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-id.cy.spec.js @@ -7,9 +7,9 @@ import { setFilter, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { addWidgetStringFilter } from "../native-filters/helpers/e2e-field-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-location.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-location.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-location.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-location.cy.spec.js index 6da5fe4cee20d..e19a44a22c924 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-location.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-location.cy.spec.js @@ -7,7 +7,7 @@ import { setFilter, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { addWidgetStringFilter } from "../native-filters/helpers/e2e-field-filter-helpers"; import { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-number.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-number.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-number.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-number.cy.spec.js index 9a98594185bf9..faa1028d40a8b 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-number.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-number.cy.spec.js @@ -7,7 +7,7 @@ import { setFilter, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { addWidgetNumberFilter } from "../native-filters/helpers/e2e-field-filter-helpers"; import { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-required-field-filter.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-field-filter.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-required-field-filter.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-field-filter.cy.spec.js index 8f922c45230e5..ed31e219186c9 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-required-field-filter.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-field-filter.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, filterWidget, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, filterWidget, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js index ce08f9e7912e7..d3f13fc569882 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js @@ -5,7 +5,7 @@ import { editDashboard, saveDashboard, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const questionDetails = { name: "Return input value", diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-text-category.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-text-category.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-text-category.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-text-category.cy.spec.js index 06f3bd3a4763e..fc346d717abbc 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-sql-text-category.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-text-category.cy.spec.js @@ -7,7 +7,7 @@ import { setFilter, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { applyFilterByType } from "../native-filters/helpers/e2e-field-filter-helpers"; import { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-text-category.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-text-category.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-text-category.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/dashboard-filters-text-category.cy.spec.js index 8bc29c63a8708..5859dd6890a8c 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/dashboard-filters-text-category.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-text-category.cy.spec.js @@ -6,7 +6,7 @@ import { saveDashboard, setFilter, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { applyFilterByType } from "../native-filters/helpers/e2e-field-filter-helpers"; import { DASHBOARD_TEXT_FILTERS } from "./shared/dashboard-filters-text-category"; diff --git a/e2e/test/scenarios/dashboard-filters/old-parameters.cy.spec.js b/e2e/test/scenarios/dashboard-filters/old-parameters.cy.spec.js new file mode 100644 index 0000000000000..71df178d451c4 --- /dev/null +++ b/e2e/test/scenarios/dashboard-filters/old-parameters.cy.spec.js @@ -0,0 +1,204 @@ +import { restore, popover, visitDashboard } from "e2e/support/helpers"; +// NOTE: some overlap with parameters-embedded.cy.spec.js +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { PEOPLE, PEOPLE_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; + +// the dashboard parameters used in these tests ('category' and 'location/...') are no longer accessible +// via the UI but should still work as expected + +describe("scenarios > dashboard > OLD parameters", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + describe("question connected to a 'category' parameter", () => { + beforeEach(() => { + const filter = { + id: "c2967a17", + name: "Category", + slug: "category", + type: "category", + }; + + const questionDetails = { + name: "Products table", + query: { + "source-table": PRODUCTS_ID, + }, + }; + + const dashboardDetails = { + parameters: [filter], + }; + + cy.createQuestionAndDashboard({ questionDetails, dashboardDetails }).then( + ({ body: { id, card_id, dashboard_id } }) => { + cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { + cards: [ + { + id, + card_id, + row: 0, + col: 0, + size_x: 8, + size_y: 6, + parameter_mappings: [ + { + card_id, + parameter_id: filter.id, + target: ["dimension", ["field", PRODUCTS.CATEGORY, null]], + }, + ], + }, + ], + }); + + visitDashboard(dashboard_id); + }, + ); + }); + + it("should work", () => { + cy.findAllByText("Doohickey"); + + cy.contains("Category").click(); + popover().within(() => { + cy.findByText("Gadget").click(); + cy.findByText("Add filter").click(); + }); + + // verify that the filter is applied + cy.findByText("Doohickey").should("not.exist"); + }); + }); + + describe("question connected to a 'location/state' parameter", () => { + beforeEach(() => { + const filter = { + id: "c2967a17", + name: "City", + slug: "city", + type: "location/city", + }; + + const questionDetails = { + name: "People table", + query: { + "source-table": PEOPLE_ID, + }, + }; + + const dashboardDetails = { parameters: [filter] }; + + cy.createQuestionAndDashboard({ questionDetails, dashboardDetails }).then( + ({ body: { id, card_id, dashboard_id } }) => { + cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { + cards: [ + { + id, + card_id, + row: 0, + col: 0, + size_x: 8, + size_y: 6, + parameter_mappings: [ + { + card_id, + parameter_id: filter.id, + target: ["dimension", ["field", PEOPLE.CITY, null]], + }, + ], + }, + ], + }); + + visitDashboard(dashboard_id); + }, + ); + }); + + it("should work", () => { + cy.contains("City").click(); + popover().within(() => { + cy.get("input").type("Flagstaff{enter}"); + cy.findByText("Add filter").click(); + }); + + cy.get(".DashCard tbody tr").should("have.length", 1); + }); + }); + + describe("native question field filter connected to 'category' parameter", () => { + beforeEach(() => { + const filter = { + id: "c2967a17", + name: "Category", + slug: "category", + type: "category", + }; + + const questionDetails = { + name: "Products SQL", + native: { + query: "select * from products where {{category}}", + "template-tags": { + category: { + "display-name": "Field Filter", + id: "abc123", + name: "category", + type: "dimension", + "widget-type": "category", + dimension: ["field", PRODUCTS.CATEGORY, null], + default: ["Doohickey"], + }, + }, + }, + display: "table", + }; + + const dashboardDetails = { parameters: [filter] }; + + cy.createNativeQuestionAndDashboard({ + questionDetails, + dashboardDetails, + }).then(({ body: { id, card_id, dashboard_id } }) => { + cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { + cards: [ + { + id, + card_id, + row: 0, + col: 0, + size_x: 8, + size_y: 6, + parameter_mappings: [ + { + card_id, + parameter_id: "c2967a17", + target: ["dimension", ["field", PRODUCTS.CATEGORY, null]], + }, + ], + }, + ], + }); + + visitDashboard(dashboard_id); + }); + }); + + it("should work", () => { + cy.findAllByText("Doohickey"); + + cy.contains("Category").click(); + popover().within(() => { + cy.findByText("Gadget").click(); + cy.findByText("Add filter").click(); + }); + + // verify that the filter is applied + cy.findByText("Doohickey").should("not.exist"); + }); + }); +}); diff --git a/frontend/test/metabase/scenarios/dashboard-filters/parameters.cy.spec.js b/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/dashboard-filters/parameters.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js index 86449c1469d67..186648cc0bf64 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/parameters.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js @@ -8,9 +8,9 @@ import { getDashboardCard, selectDashboardFilter, saveDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { ORDERS_ID, ORDERS, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/12720-no-data-permissions-connected-filter.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/12720-no-data-permissions-connected-filter.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/12720-no-data-permissions-connected-filter.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/12720-no-data-permissions-connected-filter.cy.spec.js index 34e6dbeaf13fb..c510abc1b544d 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/12720-no-data-permissions-connected-filter.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/12720-no-data-permissions-connected-filter.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitDashboard, filterWidget } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitDashboard, filterWidget } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS } = SAMPLE_DATABASE; @@ -36,7 +36,9 @@ describe("issue 12720", () => { cy.signInAsAdmin(); // In this test we're using already present question ("Orders") and the dashboard with that question ("Orders in a dashboard") - cy.addFilterToDashboard({ filter: dashboardFilter, dashboard_id: 1 }); + cy.request("PUT", "/api/dashboard/1", { + parameters: [dashboardFilter], + }); cy.createNativeQuestion(questionDetails).then( ({ body: { id: SQL_ID } }) => { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/12985-dropdown-search.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/12985-dropdown-search.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/12985-dropdown-search.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/12985-dropdown-search.cy.spec.js index 9de91c60dcb53..d8b6c084519b0 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/12985-dropdown-search.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/12985-dropdown-search.cy.spec.js @@ -3,8 +3,8 @@ import { filterWidget, popover, visitDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/16112-nodata-should-use-dashboard-filters.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/16112-nodata-should-use-dashboard-filters.cy.spec.js similarity index 68% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/16112-nodata-should-use-dashboard-filters.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/16112-nodata-should-use-dashboard-filters.cy.spec.js index 0890d5659b292..0f5236d45a080 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/16112-nodata-should-use-dashboard-filters.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/16112-nodata-should-use-dashboard-filters.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; @@ -72,23 +72,39 @@ describe("issues 15119 and 16112", () => { ], }); + // Actually need to setup the linked filter: + visitDashboard(dashboard_id); + cy.get('[data-metabase-event="Dashboard;Edit"]').click(); + cy.findByText("Rating Filter").click(); + cy.findByText("Linked filters").click(); + // cy.findByText("Reviewer Filter").click(); + cy.findByText("Limit this filter's choices") + .parent() + .within(() => { + // turn on the toggle + cy.get("input").click(); + }); + cy.findByText("Save").click(); + cy.signIn("nodata"); visitDashboard(dashboard_id); }, ); - cy.findByText(ratingFilter.name).click(); - popover().contains("3").click(); + cy.findByText(reviewerFilter.name).click(); + popover().contains("adam").click(); cy.button("Add filter").click(); - cy.get(".DashCard").should("contain", "maia").and("contain", "daryl"); - cy.location("search").should("eq", "?rating=3"); + cy.get(".DashCard").should("contain", "adam"); + cy.location("search").should("eq", "?reviewer=adam"); - cy.findByText(reviewerFilter.name).click(); - cy.findByPlaceholderText("Enter some text").type("maia{enter}").blur(); + cy.findByText(ratingFilter.name).click(); + + popover().contains("5").click(); cy.button("Add filter").click(); - cy.get(".DashCard").should("contain", "maia").and("not.contain", "daryl"); - cy.location("search").should("eq", "?reviewer=maia&rating=3"); + cy.get(".DashCard").should("contain", "adam"); + cy.get(".DashCard").should("contain", "5"); + cy.location("search").should("eq", "?reviewer=adam&rating=5"); }); }); diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/17211-false-no-matching-filter-alert.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/17211-false-no-matching-filter-alert.cy.spec.js similarity index 84% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/17211-false-no-matching-filter-alert.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/17211-false-no-matching-filter-alert.cy.spec.js index 4bf84f2f30bcd..6d159b2fde837 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/17211-false-no-matching-filter-alert.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/17211-false-no-matching-filter-alert.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, filterWidget, visitDashboard } from "__support__/e2e/helpers"; +import { restore, filterWidget, visitDashboard } from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PEOPLE } = SAMPLE_DATABASE; @@ -18,15 +18,17 @@ const filter = { sectionId: "location", }; +const dashboardDetails = { + parameters: [filter], +}; + describe("issue 17211", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); - cy.createQuestionAndDashboard({ questionDetails }).then( + cy.createQuestionAndDashboard({ questionDetails, dashboardDetails }).then( ({ body: { id, card_id, dashboard_id } }) => { - cy.addFilterToDashboard({ filter, dashboard_id }); - cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { cards: [ { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/17551-include-today-in-all-time-next-filter.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/17551-include-today-in-all-time-next-filter.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/17551-include-today-in-all-time-next-filter.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/17551-include-today-in-all-time-next-filter.cy.spec.js index e8fcec1355048..d172e7f6a3b7d 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/17551-include-today-in-all-time-next-filter.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/17551-include-today-in-all-time-next-filter.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, filterWidget, visitDashboard } from "__support__/e2e/helpers"; +import { restore, filterWidget, visitDashboard } from "e2e/support/helpers"; import { setAdHocFilter } from "../../native-filters/helpers/e2e-date-filter-helpers"; describe("issue 17551", () => { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js index b4046e92131d2..e0106b6e37c07 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/17775-filter-custom-column-date.cy.spec.js @@ -5,9 +5,9 @@ import { editDashboard, saveDashboard, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { setQuarterAndYear } from "../../native-filters/helpers/e2e-date-filter-helpers"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/19494-wrong-default-value-multiple-cards-same-question.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/19494-wrong-default-value-multiple-cards-same-question.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/19494-wrong-default-value-multiple-cards-same-question.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/19494-wrong-default-value-multiple-cards-same-question.cy.spec.js index 40cc1f559e970..3d05004345ca2 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/19494-wrong-default-value-multiple-cards-same-question.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/19494-wrong-default-value-multiple-cards-same-question.cy.spec.js @@ -4,7 +4,7 @@ import { editDashboard, saveDashboard, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const filter1 = { name: "Card 1 Filter", diff --git a/e2e/test/scenarios/dashboard-filters/reproductions/20656-dashboard-breaks-for-user-without-card-permissions.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/20656-dashboard-breaks-for-user-without-card-permissions.cy.spec.js new file mode 100644 index 0000000000000..04173b2e50d51 --- /dev/null +++ b/e2e/test/scenarios/dashboard-filters/reproductions/20656-dashboard-breaks-for-user-without-card-permissions.cy.spec.js @@ -0,0 +1,81 @@ +import { + restore, + filterWidget, + visitDashboard, + editDashboard, +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; + +const filter = { + name: "ID", + slug: "id", + id: "11d79abe", + type: "id", + sectionId: "id", +}; + +const questionDetails = { + query: { "source-table": PRODUCTS_ID, limit: 2 }, + // Admin's personal collection is always the first one (hence, the id 1) + collection_id: 1, +}; + +const dashboardDetails = { + parameters: [filter], +}; + +describe("issue 20656", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + it("should allow a user to visit a dashboard even without a permission to see the dashboard card (metabase#20656, metabase#24536)", () => { + cy.createQuestionAndDashboard({ questionDetails, dashboardDetails }).then( + ({ body: { id, card_id, dashboard_id } }) => { + cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { + cards: [ + { + id, + card_id, + row: 0, + col: 0, + size_x: 18, + size_y: 10, + parameter_mappings: [ + { + parameter_id: filter.id, + card_id, + target: ["dimension", ["field", PRODUCTS.ID, null]], + }, + ], + }, + ], + }); + + cy.signInAsNormalUser(); + + visitDashboard(dashboard_id); + }, + ); + + // Make sure the filter widget is there + filterWidget(); + cy.findByText("Sorry, you don't have permission to see this card."); + + // Trying to edit the filter should not show mapping fields and shouldn't break frontend (metabase#24536) + editDashboard(); + + cy.findByTestId("edit-dashboard-parameters-widget-container") + .find(".Icon-gear") + .click(); + + cy.findByText("Column to filter on") + .parent() + .within(() => { + cy.icon("key"); + }); + }); +}); diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/22482-round-relative-ranges.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/22482-round-relative-ranges.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/22482-round-relative-ranges.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/22482-round-relative-ranges.cy.spec.js index 40c2ab71d128d..1cd7b72bdc942 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/22482-round-relative-ranges.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/22482-round-relative-ranges.cy.spec.js @@ -7,7 +7,7 @@ import { saveDashboard, setFilter, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 22482", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js similarity index 82% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js index 49f750e79e5cf..59fb1110ef4f4 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js @@ -4,12 +4,14 @@ import { filterWidget, editDashboard, saveDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; + sidebar, +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; const ccName = "Custom Category"; +const ccDisplayName = "Product.Custom Category"; const questionDetails = { name: "22788", @@ -33,7 +35,7 @@ const dashboardDetails = { parameters: [filter], }; -describe.skip("issue 22788", () => { +describe("issue 22788", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); @@ -76,9 +78,15 @@ describe.skip("issue 22788", () => { cy.findByText("Column to filter on") .parent() .within(() => { - cy.findByText(ccName); + cy.findByText(ccDisplayName); }); + // need to actually change the dashboard to test a real save + sidebar().within(() => { + cy.findByDisplayValue("Text").clear().type('my filter text'); + cy.button('Done').click(); + }); + saveDashboard(); addFilterAndAssert(); diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/24235-exlude-all-date-options.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/24235-exlude-all-date-options.cy.spec.js similarity index 85% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/24235-exlude-all-date-options.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/24235-exlude-all-date-options.cy.spec.js index ba804d5e4c331..752787a4deea1 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/24235-exlude-all-date-options.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/24235-exlude-all-date-options.cy.spec.js @@ -1,5 +1,5 @@ -import { popover, restore, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { popover, restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -20,6 +20,8 @@ const parameterTarget = [ ["field", PRODUCTS.CREATED_AT, { "temporal-unit": "month" }], ]; +const dashboardDetails = { parameters: [parameter] }; + describe("issue 24235", () => { beforeEach(() => { restore(); @@ -28,9 +30,8 @@ describe("issue 24235", () => { }); it("should remove filter when all exclude options are selected (metabase#24235)", () => { - cy.createQuestionAndDashboard({ questionDetails }).then( + cy.createQuestionAndDashboard({ questionDetails, dashboardDetails }).then( ({ body: { id, card_id, dashboard_id } }) => { - cy.addFilterToDashboard({ filter: parameter, dashboard_id }); mapParameterToDashboardCard({ id, card_id, dashboard_id }); visitDashboard(dashboard_id); }, diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js index 59d1f99e48b64..8bf0ca273a019 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js @@ -5,8 +5,8 @@ import { popover, editDashboard, saveDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/25322-loading-list-values.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/25322-loading-list-values.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/25322-loading-list-values.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/25322-loading-list-values.cy.spec.js index f7a04476f14df..45c35215f3a41 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/25322-loading-list-values.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/25322-loading-list-values.cy.spec.js @@ -1,5 +1,5 @@ -import { popover, restore, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { popover, restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/25355-multi-series-parameter-mapping.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/25355-multi-series-parameter-mapping.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/25355-multi-series-parameter-mapping.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/25355-multi-series-parameter-mapping.cy.spec.js index 06dc9302f4aca..bdb82ecf9bfe6 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/25355-multi-series-parameter-mapping.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/25355-multi-series-parameter-mapping.cy.spec.js @@ -3,8 +3,8 @@ import { popover, restore, visitDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/25374-comma-separated-values-not-passed-to-question.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/25374-comma-separated-values-not-passed-to-question.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/25374-comma-separated-values-not-passed-to-question.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/25374-comma-separated-values-not-passed-to-question.cy.spec.js index f3248e38a6116..343634f87f743 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/25374-comma-separated-values-not-passed-to-question.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/25374-comma-separated-values-not-passed-to-question.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visitDashboard, filterWidget } from "__support__/e2e/helpers"; +import { restore, visitDashboard, filterWidget } from "e2e/support/helpers"; const questionDetails = { name: "25374", diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/25908-contains-filter-case-sensitivity.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/25908-contains-filter-case-sensitivity.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/25908-contains-filter-case-sensitivity.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/25908-contains-filter-case-sensitivity.cy.spec.js index cba8b9c0a3417..920330b515539 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/25908-contains-filter-case-sensitivity.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/25908-contains-filter-case-sensitivity.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/27356-navigation-between-two-dashboards.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/27356-navigation-between-two-dashboards.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/27356-navigation-between-two-dashboards.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/27356-navigation-between-two-dashboards.cy.spec.js index 122f51bf7b973..ab33433d9bac5 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/27356-navigation-between-two-dashboards.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/27356-navigation-between-two-dashboards.cy.spec.js @@ -2,7 +2,7 @@ import { restore, openNavigationSidebar, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const ratingFilter = { name: "Text", diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/27768-cc-filter-appears-disconnected.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/27768-cc-filter-appears-disconnected.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/27768-cc-filter-appears-disconnected.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/27768-cc-filter-appears-disconnected.cy.spec.js index 035c3169f0459..849f3781508ce 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/27768-cc-filter-appears-disconnected.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/27768-cc-filter-appears-disconnected.cy.spec.js @@ -5,8 +5,8 @@ import { editDashboard, saveDashboard, filterWidget, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/8030-reload-card-without-change.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/8030-reload-card-without-change.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/dashboard-filters/reproductions/8030-reload-card-without-change.cy.spec.js rename to e2e/test/scenarios/dashboard-filters/reproductions/8030-reload-card-without-change.cy.spec.js index 86fdb1f96cde6..1ad57c7bef7c3 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/reproductions/8030-reload-card-without-change.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/8030-reload-card-without-change.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-date.js b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-date.js similarity index 100% rename from frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-date.js rename to e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-date.js diff --git a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-location.js b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-location.js similarity index 100% rename from frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-location.js rename to e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-location.js diff --git a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-number.js b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-number.js similarity index 100% rename from frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-number.js rename to e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-number.js diff --git a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-date.js b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-date.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-date.js rename to e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-date.js index f45b0adf50cf5..752bbd695ba31 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-date.js +++ b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-date.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-location.js b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-location.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-location.js rename to e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-location.js index c26af717ce2be..0b44a0ddc9b98 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-location.js +++ b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-location.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-number.js b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-number.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-number.js rename to e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-number.js index 0f78390694cec..43f996272037e 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-number.js +++ b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-number.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-text-category.js b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-text-category.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-text-category.js rename to e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-text-category.js index 12db381c7a7c8..52ec7cb880079 100644 --- a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-sql-text-category.js +++ b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-sql-text-category.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-text-category.js b/e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-text-category.js similarity index 100% rename from frontend/test/metabase/scenarios/dashboard-filters/shared/dashboard-filters-text-category.js rename to e2e/test/scenarios/dashboard-filters/shared/dashboard-filters-text-category.js diff --git a/e2e/test/scenarios/dashboard/actions-on-dashboards.cy.spec.js b/e2e/test/scenarios/dashboard/actions-on-dashboards.cy.spec.js new file mode 100644 index 0000000000000..00a4acf71877e --- /dev/null +++ b/e2e/test/scenarios/dashboard/actions-on-dashboards.cy.spec.js @@ -0,0 +1,651 @@ +import { + restore, + queryWritableDB, + resetTestTable, + createModelFromTableName, + fillActionQuery, + resyncDatabase, + visitDashboard, + editDashboard, + saveDashboard, + modal, + setFilter, + sidebar, + popover, + filterWidget, + createImplicitAction, + dragField, +} from "e2e/support/helpers"; + +import { many_data_types_rows } from "e2e/support/test_tables_data"; + +import { WRITABLE_DB_ID } from "e2e/support/cypress_data"; +import { addWidgetStringFilter } from "../native-filters/helpers/e2e-field-filter-helpers"; + +const TEST_TABLE = "scoreboard_actions"; +const TEST_COLUMNS_TABLE = "many_data_types"; +const MODEL_NAME = "Test Action Model"; + +["mysql", "postgres"].forEach(dialect => { + describe( + `Write Actions on Dashboards (${dialect})`, + { tags: ["@external", "@actions"] }, + () => { + beforeEach(() => { + cy.intercept("GET", /\/api\/card\/\d+/).as("getModel"); + cy.intercept("GET", "/api/card?f=using_model&model_id=**").as( + "getCardAssociations", + ); + cy.intercept("GET", "/api/action").as("getActions"); + cy.intercept("GET", "/api/action?model-id=*").as("getModelActions"); + + cy.intercept( + "GET", + "/api/dashboard/*/dashcard/*/execute?parameters=*", + ).as("executePrefetch"); + + cy.intercept("POST", "/api/dashboard/*/dashcard/*/execute").as( + "executeAPI", + ); + }); + + describe("adding and executing actions", () => { + beforeEach(() => { + resetTestTable({ type: dialect, table: TEST_TABLE }); + restore(`${dialect}-writable`); + cy.signInAsAdmin(); + resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TEST_TABLE }); + createModelFromTableName({ + tableName: TEST_TABLE, + modelName: MODEL_NAME, + }); + }); + + it("adds a custom query action to a dashboard and runs it", () => { + const ACTION_NAME = "Update Score"; + + queryWritableDB( + `SELECT * FROM ${TEST_TABLE} WHERE id = 1`, + dialect, + ).then(result => { + expect(result.rows.length).to.equal(1); + expect(result.rows[0].score).to.equal(0); + }); + + cy.get("@modelId").then(id => { + cy.visit(`/model/${id}/detail`); + cy.wait(["@getModel", "@getModelActions", "@getCardAssociations"]); + }); + + cy.findByRole("tab", { name: "Actions" }).click(); + cy.findByText("New action").click(); + + cy.findByRole("dialog").within(() => { + fillActionQuery( + `UPDATE ${TEST_TABLE} SET score = {{ new_score }} WHERE id = {{ id }}`, + ); + }); + + // can't have this in the .within() because it needs access to document.body + dragField(1, 0); + + cy.findByRole("dialog").within(() => { + cy.findAllByText("Number").each(el => { + cy.wrap(el).click(); + }); + cy.findByText("Save").click(); + }); + + cy.findByPlaceholderText("My new fantastic action").type(ACTION_NAME); + cy.findByText("Create").click(); + + createDashboardWithActionButton({ + actionName: ACTION_NAME, + idFilter: true, + }); + + filterWidget().click(); + addWidgetStringFilter("1"); + + clickHelper("Update Score"); + + cy.findByRole("dialog").within(() => { + cy.findByLabelText("New score").type("55"); + cy.button(ACTION_NAME).click(); + }); + + cy.wait("@executeAPI"); + + queryWritableDB( + `SELECT * FROM ${TEST_TABLE} WHERE id = 1`, + dialect, + ).then(result => { + expect(result.rows.length).to.equal(1); + expect(result.rows[0].score).to.equal(55); + }); + }); + + it("adds an implicit create action to a dashboard and runs it", () => { + cy.get("@modelId").then(id => { + createImplicitAction({ + kind: "create", + model_id: id, + }); + }); + + createDashboardWithActionButton({ + actionName: "Create", + }); + + clickHelper("Create"); + + modal().within(() => { + cy.findByPlaceholderText("Team name").type("Zany Zebras"); + cy.findByPlaceholderText("Score").type("44"); + + cy.button("Save").click(); + }); + + cy.wait("@executeAPI"); + + queryWritableDB( + `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Zany Zebras'`, + dialect, + ).then(result => { + expect(result.rows.length).to.equal(1); + + expect(result.rows[0].score).to.equal(44); + }); + }); + + it("adds an implicit update action to a dashboard and runs it", () => { + const actionName = "Update"; + + cy.get("@modelId").then(id => { + createImplicitAction({ + kind: "update", + model_id: id, + }); + }); + + createDashboardWithActionButton({ + actionName, + idFilter: true, + }); + + filterWidget().click(); + addWidgetStringFilter("5"); + + clickHelper(actionName); + + cy.wait("@executePrefetch"); + // let's check that the existing values are pre-filled correctly + modal().within(() => { + cy.findByPlaceholderText("Team name") + .should("have.value", "Energetic Elephants") + .clear() + .type("Emotional Elephants"); + + cy.findByPlaceholderText("Score") + .should("have.value", "30") + .clear() + .type("88"); + + cy.button("Update").click(); + }); + + cy.wait("@executeAPI"); + + queryWritableDB( + `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Emotional Elephants'`, + dialect, + ).then(result => { + expect(result.rows.length).to.equal(1); + + expect(result.rows[0].score).to.equal(88); + }); + }); + + it("adds an implicit delete action to a dashboard and runs it", () => { + queryWritableDB( + `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Cuddly Cats'`, + dialect, + ).then(result => { + expect(result.rows.length).to.equal(1); + expect(result.rows[0].id).to.equal(3); + }); + + cy.get("@modelId").then(id => { + createImplicitAction({ + kind: "delete", + model_id: id, + }); + }); + + createDashboardWithActionButton({ + actionName: "Delete", + }); + + clickHelper("Delete"); + + modal().within(() => { + cy.findByPlaceholderText("Id").type("3"); + cy.button("Delete").click(); + }); + + cy.wait("@executeAPI"); + + queryWritableDB( + `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Cuddly Cats'`, + dialect, + ).then(result => { + expect(result.rows.length).to.equal(0); + }); + }); + }); + + describe(`Actions Data Types`, () => { + beforeEach(() => { + resetTestTable({ type: dialect, table: TEST_COLUMNS_TABLE }); + restore(`${dialect}-writable`); + cy.signInAsAdmin(); + resyncDatabase({ + dbId: WRITABLE_DB_ID, + tableName: TEST_COLUMNS_TABLE, + }); + createModelFromTableName({ + tableName: TEST_COLUMNS_TABLE, + modelName: MODEL_NAME, + }); + }); + + it("can update various data types via implicit actions", () => { + cy.get("@modelId").then(id => { + createImplicitAction({ + kind: "update", + model_id: id, + }); + }); + + createDashboardWithActionButton({ + actionName: "Update", + idFilter: true, + }); + + filterWidget().click(); + addWidgetStringFilter("1"); + + clickHelper("Update"); + + cy.wait("@executePrefetch"); + + const oldRow = many_data_types_rows[0]; + + modal().within(() => { + changeValue({ + fieldName: "Uuid", + fieldType: "text", + oldValue: oldRow.uuid, + newValue: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a77", + }); + + changeValue({ + fieldName: "Integer", + fieldType: "number", + oldValue: oldRow.integer, + newValue: 123, + }); + + changeValue({ + fieldName: "Float", + fieldType: "number", + oldValue: oldRow.float, + newValue: 2.2, + }); + + cy.findByLabelText("Boolean").should("be.checked").click(); + + changeValue({ + fieldName: "String", + fieldType: "text", + oldValue: oldRow.string, + newValue: "new string", + }); + + changeValue({ + fieldName: "Date", + fieldType: "date", + oldValue: oldRow.date, + newValue: "2020-05-01", + }); + + // we can't assert on this value because mysql and postgres seem to + // handle timezones differently 🥴 + cy.findByPlaceholderText("Timestamptz") + .should("have.attr", "type", "datetime-local") + .clear() + .type("2020-05-01T16:45:00"); + + cy.button("Update").click(); + }); + + cy.wait("@executeAPI"); + + queryWritableDB( + `SELECT * FROM ${TEST_COLUMNS_TABLE} WHERE id = 1`, + dialect, + ).then(result => { + expect(result.rows.length).to.equal(1); + + const row = result.rows[0]; + + expect(row).to.have.property( + "uuid", + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a77", + ); + expect(row).to.have.property("integer", 123); + expect(row).to.have.property("float", 2.2); + expect(row).to.have.property("string", "new string"); + expect(row).to.have.property( + "boolean", + dialect === "mysql" ? 0 : false, + ); + expect(row.date).to.include("2020-05-01"); // js converts this to a full date obj + expect(row.timestampTZ).to.include("2020-05-01"); // we got timezone issues here + }); + }); + + it("can insert various data types via implicit actions", () => { + cy.get("@modelId").then(id => { + createImplicitAction({ + kind: "create", + model_id: id, + }); + }); + + createDashboardWithActionButton({ + actionName: "Create", + }); + + clickHelper("Create"); + + modal().within(() => { + cy.findByPlaceholderText("Uuid").type( + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15", + ); + + cy.findByPlaceholderText("Integer").type("-20"); + cy.findByPlaceholderText("Integerunsigned").type("20"); + cy.findByPlaceholderText("Tinyint").type("101"); + cy.findByPlaceholderText("Tinyint1").type("1"); + cy.findByPlaceholderText("Smallint").type("32767"); + cy.findByPlaceholderText("Mediumint").type("8388607"); + cy.findByPlaceholderText("Bigint").type("922337204775"); + cy.findByPlaceholderText("Float").type("3.4"); + cy.findByPlaceholderText("Double").type("1.79769313486"); + cy.findByPlaceholderText("Decimal").type("123901.21"); + + cy.findByLabelText("Boolean").click(); + + cy.findByPlaceholderText("String").type("Zany Zebras"); + cy.findByPlaceholderText("Text").type("Zany Zebras"); + + cy.findByPlaceholderText("Date").type("2020-02-01"); + cy.findByPlaceholderText("Datetime").type("2020-03-01T12:00:00"); + cy.findByPlaceholderText("Datetimetz").type("2020-03-01T12:00:00"); + cy.findByPlaceholderText("Time").type("12:57:57"); + cy.findByPlaceholderText("Timestamp").type("2020-03-01T12:00:00"); + cy.findByPlaceholderText("Timestamptz").type("2020-03-01T12:00:00"); + + cy.button("Save").click(); + }); + + cy.wait("@executeAPI"); + + queryWritableDB( + `SELECT * FROM ${TEST_COLUMNS_TABLE} WHERE string = 'Zany Zebras'`, + dialect, + ).then(result => { + expect(result.rows.length).to.equal(1); + const row = result.rows[0]; + + expect(row.uuid).to.equal("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15"); + + expect(row.integer).to.equal(-20); + expect(row.integerUnsigned).to.equal(20); + expect(row.tinyint).to.equal(101); + expect(row.tinyint1).to.equal(1); + expect(row.smallint).to.equal(32767); + expect(row.mediumint).to.equal(8388607); + expect(row.bigint).to.equal( + dialect === "mysql" ? 922337204775 : String(922337204775), // the pg driver makes this a string + ); + expect(row.float).to.equal(3.4); + expect(row.double).to.equal(1.79769313486); + expect(row.decimal).to.equal("123901.21"); // js needs this to be a string + + expect(row.boolean).to.equal(dialect === "mysql" ? 1 : true); + + expect(row.string).to.equal("Zany Zebras"); + expect(row.text).to.equal("Zany Zebras"); + + expect(row.date).to.include("2020-02-01"); // js converts this to a full date + + // timezones are problematic here + expect(row.datetime).to.include("2020-03-01"); + expect(row.datetimeTZ).to.include("2020-03-01"); + expect(row.time).to.include("57:57"); + expect(row.timestamp).to.include("2020-03-01"); + expect(row.timestampTZ).to.include("2020-03-01"); + }); + }); + + it("does not show json, enum, or binary columns for implicit actions", () => { + cy.get("@modelId").then(id => { + createImplicitAction({ + kind: "create", + model_id: id, + }); + }); + + createDashboardWithActionButton({ + actionName: "Create", + idFilter: true, + }); + + clickHelper("Create"); + + modal().within(() => { + cy.findByPlaceholderText("Uuid").should("be.visible"); + cy.findByPlaceholderText("Json").should("not.exist"); + cy.findByPlaceholderText("Jsonb").should("not.exist"); + cy.findByPlaceholderText("Binary").should("not.exist"); + + if (dialect === "mysql") { + // we only have enums in postgres as of Feb 2023 + cy.findByPlaceholderText("Enum").should("not.exist"); + } + }); + }); + + it("properly loads and updates date and time fields for implicit update actions", () => { + cy.get("@modelId").then(id => { + createImplicitAction({ + kind: "update", + model_id: id, + }); + }); + + createDashboardWithActionButton({ + actionName: "Update", + idFilter: true, + }); + + filterWidget().click(); + addWidgetStringFilter("1"); + + clickHelper("Update"); + + cy.wait("@executePrefetch"); + + const oldRow = many_data_types_rows[0]; + const newTime = "2020-01-10T01:35:55"; + + modal().within(() => { + changeValue({ + fieldName: "Date", + fieldType: "date", + oldValue: oldRow.date, + newValue: newTime.slice(0, 10), + }); + + changeValue({ + fieldName: "Datetime", + fieldType: "datetime-local", + oldValue: oldRow.datetime.replace(" ", "T"), + newValue: newTime, + }); + + changeValue({ + fieldName: "Time", + fieldType: "time", + oldValue: oldRow.time, + newValue: newTime.slice(-8), + }); + + changeValue({ + fieldName: "Timestamp", + fieldType: "datetime-local", + oldValue: oldRow.timestamp.replace(" ", "T"), + newValue: newTime, + }); + + // only postgres has timezone-aware columns + // the instance is in US/Pacific so it's -8 hours + if (dialect === "postgres") { + changeValue({ + fieldName: "Datetimetz", + fieldType: "datetime-local", + oldValue: "2020-01-01T00:35:55", + newValue: newTime, + }); + + changeValue({ + fieldName: "Timestamptz", + fieldType: "datetime-local", + oldValue: "2020-01-01T00:35:55", + newValue: newTime, + }); + } + + if (dialect === "mysql") { + changeValue({ + fieldName: "Datetimetz", + fieldType: "datetime-local", + oldValue: oldRow.datetimeTZ.replace(" ", "T"), + newValue: newTime, + }); + + changeValue({ + fieldName: "Timestamptz", + fieldType: "datetime-local", + oldValue: oldRow.timestampTZ.replace(" ", "T"), + newValue: newTime, + }); + } + cy.button("Update").click(); + }); + + cy.wait("@executeAPI"); + + queryWritableDB( + `SELECT * FROM ${TEST_COLUMNS_TABLE} WHERE id = 1`, + dialect, + ).then(result => { + const row = result.rows[0]; + + // the driver adds a time to this date so we have to use .include + expect(row.date).to.include(newTime.slice(0, 10)); + expect(row.time).to.equal(newTime.slice(-8)); + + // metabase is smart and localizes these, so all of these are +8 hours + const newTimeAdjusted = newTime.replace("T01", "T09"); + // we need to use .include because the driver adds milliseconds to the timestamp + expect(row.datetime).to.include(newTimeAdjusted); + expect(row.timestamp).to.include(newTimeAdjusted); + expect(row.datetimeTZ).to.include(newTimeAdjusted); + expect(row.timestampTZ).to.include(newTimeAdjusted); + }); + }); + }); + }, + ); +}); + +function createDashboardWithActionButton({ + actionName, + modelName = MODEL_NAME, + idFilter = false, +}) { + cy.createDashboard({ name: "action packed dashboard" }).then( + ({ body: { id: dashboardId } }) => { + visitDashboard(dashboardId); + }, + ); + + editDashboard(); + + if (idFilter) { + setFilter("ID"); + sidebar().within(() => { + cy.button("Done").click(); + }); + } + + cy.button("Add action").click(); + cy.get("aside").within(() => { + cy.findByPlaceholderText("Button text").clear().type(actionName); + cy.button("Pick an action").click(); + }); + + cy.wait("@getActions"); + + cy.findByRole("dialog").within(() => { + cy.findByText(modelName).click(); + cy.findByText(actionName).click(); + }); + + if (idFilter) { + cy.findByRole("dialog").within(() => { + cy.findByText(/has no parameters to map/i).should("not.exist"); + cy.findByText(/Where should the values/i); + cy.findAllByText(/ask the user/i) + .first() + .click(); + }); + popover().within(() => { + cy.findByText("ID").click(); + }); + } + + cy.findByRole("dialog").within(() => { + cy.button("Done").click(); + }); + + saveDashboard(); +} + +const changeValue = ({ fieldName, fieldType, oldValue, newValue }) => { + cy.findByPlaceholderText(fieldName) + .should("have.attr", "type", fieldType) + .should("have.value", oldValue) + .clear() + .type(newValue); +}; + +const clickHelper = buttonName => { + // this is dirty, but it seems to be the only reliable solution to detached elements before cypress v12 + // https://github.com/cypress-io/cypress/issues/7306 + cy.wait(100); + cy.button(buttonName).click(); +}; diff --git a/frontend/test/metabase/scenarios/dashboard/bookmarks.cy.spec.js b/e2e/test/scenarios/dashboard/bookmarks.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/dashboard/bookmarks.cy.spec.js rename to e2e/test/scenarios/dashboard/bookmarks.cy.spec.js index 0762bb0d4b6fe..a067b21264214 100644 --- a/frontend/test/metabase/scenarios/dashboard/bookmarks.cy.spec.js +++ b/e2e/test/scenarios/dashboard/bookmarks.cy.spec.js @@ -3,7 +3,7 @@ import { navigationSidebar, openNavigationSidebar, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > dashboard > bookmarks", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/dashboard/caching.cy.spec.js b/e2e/test/scenarios/dashboard/caching.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard/caching.cy.spec.js rename to e2e/test/scenarios/dashboard/caching.cy.spec.js index e219b1d7d6590..d30555ae43ad7 100644 --- a/frontend/test/metabase/scenarios/dashboard/caching.cy.spec.js +++ b/e2e/test/scenarios/dashboard/caching.cy.spec.js @@ -4,7 +4,7 @@ import { popover, visitDashboard, rightSidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describeEE("scenarios > dashboard > caching", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/dashboard/chained-filters.cy.spec.js b/e2e/test/scenarios/dashboard/chained-filters.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard/chained-filters.cy.spec.js rename to e2e/test/scenarios/dashboard/chained-filters.cy.spec.js index 021e2324134ae..6c7fafc9e8346 100644 --- a/frontend/test/metabase/scenarios/dashboard/chained-filters.cy.spec.js +++ b/e2e/test/scenarios/dashboard/chained-filters.cy.spec.js @@ -3,8 +3,8 @@ import { popover, showDashboardCardActions, visitDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard/click-behavior.cy.spec.js b/e2e/test/scenarios/dashboard/click-behavior.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard/click-behavior.cy.spec.js rename to e2e/test/scenarios/dashboard/click-behavior.cy.spec.js index f6fe54bcdc3a2..5e668e81bbb67 100644 --- a/frontend/test/metabase/scenarios/dashboard/click-behavior.cy.spec.js +++ b/e2e/test/scenarios/dashboard/click-behavior.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard-drill.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard-drill.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/dashboard/dashboard-drill.cy.spec.js rename to e2e/test/scenarios/dashboard/dashboard-drill.cy.spec.js index 0d8c152587e8b..2ce233025ae9b 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard-drill.cy.spec.js +++ b/e2e/test/scenarios/dashboard/dashboard-drill.cy.spec.js @@ -5,10 +5,10 @@ import { filterWidget, showDashboardCardActions, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard-management.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard-management.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard/dashboard-management.cy.spec.js rename to e2e/test/scenarios/dashboard/dashboard-management.cy.spec.js index 6843833a3f51b..c15cfad908d81 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard-management.cy.spec.js +++ b/e2e/test/scenarios/dashboard/dashboard-management.cy.spec.js @@ -5,9 +5,9 @@ import { visitDashboard, modal, rightSidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const PERMISSIONS = { curate: ["admin", "normal", "nodata"], diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js rename to e2e/test/scenarios/dashboard/dashboard.cy.spec.js index e1d1b583014a2..2c3dcc9292469 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js +++ b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js @@ -11,12 +11,10 @@ import { visitDashboard, appbar, rightSidebar, - downloadAndAssert, - assertSheetRowsCount, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; @@ -537,26 +535,27 @@ describe("scenarios > dashboard", () => { cy.findAllByText("18,760").should("have.length", 2); }); - it("should show collection breadcrumbs for a dashboard", () => { - visitDashboard(1); - appbar().within(() => cy.findByText("Our analytics").click()); + it("should allow you to add questions using 'Add a saved question' button (metabase#29450)", () => { + cy.createDashboard({ name: "dash:29450" }); - cy.findByText("Orders").should("be.visible"); - }); + cy.visit("/collection/root"); + // enter newly created dashboard + cy.findByText("dash:29450").click(); - it("should allow downloading card data", () => { - visitDashboard(1); - cy.findByTestId("dashcard").within(() => { - cy.findByTestId("legend-caption").realHover(); + cy.findByText("Add a saved question").click(); + + sidebar().within(() => { + cy.findByText("Orders, Count").click(); }); - downloadAndAssert({ fileType: "xlsx", questionId: 1 }, sheet => { - expect(sheet["A1"].v).to.eq("ID"); - expect(sheet["A2"].v).to.eq(1); - expect(sheet["A3"].v).to.eq(2); + saveDashboard(); + }); - assertSheetRowsCount(18760)(sheet); - }); + it("should show collection breadcrumbs for a dashboard", () => { + visitDashboard(1); + appbar().within(() => cy.findByText("Our analytics").click()); + + cy.findByText("Orders").should("be.visible"); }); }); diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard_data_permissions.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard_data_permissions.cy.spec.js similarity index 81% rename from frontend/test/metabase/scenarios/dashboard/dashboard_data_permissions.cy.spec.js rename to e2e/test/scenarios/dashboard/dashboard_data_permissions.cy.spec.js index 11b9e4c2db292..f021d6fc0a694 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard_data_permissions.cy.spec.js +++ b/e2e/test/scenarios/dashboard/dashboard_data_permissions.cy.spec.js @@ -3,7 +3,7 @@ import { popover, selectDashboardFilter, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; function filterDashboard(suggests = true) { visitDashboard(1); @@ -58,6 +58,17 @@ describe("support > permissions (metabase#8472)", () => { it("should allow a nodata user to select the filter", () => { cy.signIn("nodata"); - filterDashboard(false); + filterDashboard(); + }); + + it("should not allow a nocollection user to visit the page, hence cannot see the filter", () => { + cy.signIn("nocollection"); + cy.request({ + method: "GET", + url: "/api/dashboard/1", + failOnStatusCode: false, + }).should(xhr => { + expect(xhr.status).to.equal(403); + }); }); }); diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard_local-only.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard_local-only.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/dashboard/dashboard_local-only.cy.spec.js rename to e2e/test/scenarios/dashboard/dashboard_local-only.cy.spec.js index 5f9a46240f0c1..ca3838c31d8ee 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard_local-only.cy.spec.js +++ b/e2e/test/scenarios/dashboard/dashboard_local-only.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, filterWidget } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, filterWidget } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard/duplicate.cy.spec.js b/e2e/test/scenarios/dashboard/duplicate.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/dashboard/duplicate.cy.spec.js rename to e2e/test/scenarios/dashboard/duplicate.cy.spec.js index f6c120cedfeab..1ce3c9020546d 100644 --- a/frontend/test/metabase/scenarios/dashboard/duplicate.cy.spec.js +++ b/e2e/test/scenarios/dashboard/duplicate.cy.spec.js @@ -1,8 +1,4 @@ -import { - restore, - visitCollection, - visitDashboard, -} from "__support__/e2e/helpers"; +import { restore, visitCollection, visitDashboard } from "e2e/support/helpers"; describe("scenarios > dashboard > duplicate", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/dashboard/permissions.cy.spec.js b/e2e/test/scenarios/dashboard/permissions.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/dashboard/permissions.cy.spec.js rename to e2e/test/scenarios/dashboard/permissions.cy.spec.js index 3afe719e812c2..b4783d14d1668 100644 --- a/frontend/test/metabase/scenarios/dashboard/permissions.cy.spec.js +++ b/e2e/test/scenarios/dashboard/permissions.cy.spec.js @@ -1,7 +1,7 @@ import _ from "underscore"; import { assoc } from "icepick"; -import { restore, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; describe("scenarios > dashboard > permissions", () => { let dashboardId; diff --git a/frontend/test/metabase/scenarios/dashboard/reproductions/17160-click-behavior-multiple-options.cy.spec.js b/e2e/test/scenarios/dashboard/reproductions/17160-click-behavior-multiple-options.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/dashboard/reproductions/17160-click-behavior-multiple-options.cy.spec.js rename to e2e/test/scenarios/dashboard/reproductions/17160-click-behavior-multiple-options.cy.spec.js index 0ee2df01e7176..e480a33f5b6ab 100644 --- a/frontend/test/metabase/scenarios/dashboard/reproductions/17160-click-behavior-multiple-options.cy.spec.js +++ b/e2e/test/scenarios/dashboard/reproductions/17160-click-behavior-multiple-options.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard/reproductions/18454-card-description.cy.spec.js b/e2e/test/scenarios/dashboard/reproductions/18454-card-description.cy.spec.js similarity index 85% rename from frontend/test/metabase/scenarios/dashboard/reproductions/18454-card-description.cy.spec.js rename to e2e/test/scenarios/dashboard/reproductions/18454-card-description.cy.spec.js index 544031aff0c27..7d93845c95a3c 100644 --- a/frontend/test/metabase/scenarios/dashboard/reproductions/18454-card-description.cy.spec.js +++ b/e2e/test/scenarios/dashboard/reproductions/18454-card-description.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitDashboard } from "__support__/e2e/helpers"; +import { restore, visitDashboard } from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard/reproductions/20637-add-series-to-dashcard.cy.spec.js b/e2e/test/scenarios/dashboard/reproductions/20637-add-series-to-dashcard.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/dashboard/reproductions/20637-add-series-to-dashcard.cy.spec.js rename to e2e/test/scenarios/dashboard/reproductions/20637-add-series-to-dashcard.cy.spec.js index 2ab2ba779e159..2d72a164437b6 100644 --- a/frontend/test/metabase/scenarios/dashboard/reproductions/20637-add-series-to-dashcard.cy.spec.js +++ b/e2e/test/scenarios/dashboard/reproductions/20637-add-series-to-dashcard.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, saveDashboard } from "__support__/e2e/helpers"; +import { restore, saveDashboard } from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard/reproductions/21830-slow-loading-card-viz-options-error.cy.spec.js b/e2e/test/scenarios/dashboard/reproductions/21830-slow-loading-card-viz-options-error.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard/reproductions/21830-slow-loading-card-viz-options-error.cy.spec.js rename to e2e/test/scenarios/dashboard/reproductions/21830-slow-loading-card-viz-options-error.cy.spec.js index 8628083d875f9..9e699d7b4757a 100644 --- a/frontend/test/metabase/scenarios/dashboard/reproductions/21830-slow-loading-card-viz-options-error.cy.spec.js +++ b/e2e/test/scenarios/dashboard/reproductions/21830-slow-loading-card-viz-options-error.cy.spec.js @@ -3,7 +3,7 @@ import { getDashboardCard, restore, showDashboardCardActions, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 21830", () => { beforeEach(() => { diff --git a/e2e/test/scenarios/dashboard/reproductions/26230-dashboard-sticky-filter-incorrectly-positioned.cy.spec.js b/e2e/test/scenarios/dashboard/reproductions/26230-dashboard-sticky-filter-incorrectly-positioned.cy.spec.js new file mode 100644 index 0000000000000..b73bf014ecccb --- /dev/null +++ b/e2e/test/scenarios/dashboard/reproductions/26230-dashboard-sticky-filter-incorrectly-positioned.cy.spec.js @@ -0,0 +1,94 @@ +import { restore, visitDashboard } from "e2e/support/helpers"; + +describe("issue 26230", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + prepareAndVisitDashboards(); + }); + + it("should not preserve the sticky filter behavior when navigating to the second dashboard (metabase#26230)", () => { + cy.findByRole("main").scrollTo("bottom"); // This line is essential for the reproduction! + + cy.button("Toggle sidebar").click(); + cy.findByRole("main") + .findByDisplayValue("dashboard with a tall card 2") + .should("not.be.visible"); + + cy.findByTestId("dashboard-parameters-widget-container").should( + "have.css", + "position", + "fixed", + ); + + cy.intercept("GET", "/api/dashboard/*").as("loadDashboard"); + cy.findByRole("listitem", { name: "dashboard with a tall card" }).click(); + cy.wait("@loadDashboard"); + + cy.findByTestId("dashboard-parameters-widget-container").should( + "not.have.css", + "position", + "fixed", + ); + }); +}); + +function prepareAndVisitDashboards() { + cy.createDashboard({ + name: "dashboard with a tall card", + parameters: [ + { + id: "12345678", + name: "Text", + slug: "text", + type: "string/=", + sectionId: "string", + }, + ], + }).then(({ body: { id } }) => { + createTextDashcard(id); + bookmarkDashboard(id); + }); + + cy.createDashboard({ + name: "dashboard with a tall card 2", + parameters: [ + { + id: "87654321", + name: "Text", + slug: "text", + type: "string/=", + sectionId: "string", + }, + ], + }).then(({ body: { id } }) => { + createTextDashcard(id); + bookmarkDashboard(id); + visitDashboard(id); + }); +} + +function bookmarkDashboard(dashboardId) { + cy.request("POST", `/api/bookmark/dashboard/${dashboardId}`); +} + +function createTextDashcard(id) { + cy.request("POST", `/api/dashboard/${id}/cards`, { + cardId: null, + col: 0, + row: 0, + size_x: 4, + size_y: 20, + visualization_settings: { + virtual_card: { + name: null, + display: "text", + visualization_settings: {}, + dataset_query: {}, + archived: false, + }, + text: "I am a tall card", + }, + }); +} diff --git a/frontend/test/metabase/scenarios/dashboard/reproductions/26826-dashboard-alien-card.cy.spec.js b/e2e/test/scenarios/dashboard/reproductions/26826-dashboard-alien-card.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard/reproductions/26826-dashboard-alien-card.cy.spec.js rename to e2e/test/scenarios/dashboard/reproductions/26826-dashboard-alien-card.cy.spec.js index 0156238c2766b..c61b5d52a0a1d 100644 --- a/frontend/test/metabase/scenarios/dashboard/reproductions/26826-dashboard-alien-card.cy.spec.js +++ b/e2e/test/scenarios/dashboard/reproductions/26826-dashboard-alien-card.cy.spec.js @@ -3,7 +3,7 @@ import { visitDashboard, openProductsTable, saveDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe.skip("issue 26826", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/dashboard/reproductions/27020-27105-static-viz-date-formatting-failures.cy.spec.js b/e2e/test/scenarios/dashboard/reproductions/27020-27105-static-viz-date-formatting-failures.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard/reproductions/27020-27105-static-viz-date-formatting-failures.cy.spec.js rename to e2e/test/scenarios/dashboard/reproductions/27020-27105-static-viz-date-formatting-failures.cy.spec.js index cf3de0a277460..c6b76cd0e659c 100644 --- a/frontend/test/metabase/scenarios/dashboard/reproductions/27020-27105-static-viz-date-formatting-failures.cy.spec.js +++ b/e2e/test/scenarios/dashboard/reproductions/27020-27105-static-viz-date-formatting-failures.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; const questionDetails27105 = { name: "27105", diff --git a/e2e/test/scenarios/dashboard/reproductions/29076-dashboard-card-drill-sandbox.cy.spec.js b/e2e/test/scenarios/dashboard/reproductions/29076-dashboard-card-drill-sandbox.cy.spec.js new file mode 100644 index 0000000000000..6834e2961ca2c --- /dev/null +++ b/e2e/test/scenarios/dashboard/reproductions/29076-dashboard-card-drill-sandbox.cy.spec.js @@ -0,0 +1,30 @@ +import { describeEE, restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; + +describeEE("issue 29076", () => { + beforeEach(() => { + restore(); + + cy.intercept("/api/dashboard/*/dashcard/*/card/*/query").as("cardQuery"); + + cy.signInAsAdmin(); + cy.sandboxTable({ + table_id: PRODUCTS_ID, + attribute_remappings: { + attr_uid: ["dimension", ["field", PRODUCTS.ID, null]], + }, + }); + cy.signInAsSandboxedUser(); + }); + + it("should be able to drilldown to a saved question in a dashboard with sandboxing (metabase#29076)", () => { + visitDashboard(1); + cy.wait("@cardQuery"); + + cy.findByText("Orders").click(); + cy.wait("@cardQuery"); + cy.findByText("Visualization").should("be.visible"); + }); +}); diff --git a/frontend/test/metabase/scenarios/dashboard/text-box.cy.spec.js b/e2e/test/scenarios/dashboard/text-box.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/dashboard/text-box.cy.spec.js rename to e2e/test/scenarios/dashboard/text-box.cy.spec.js index 106c5dd2db0ef..eaa9ef86eedcd 100644 --- a/frontend/test/metabase/scenarios/dashboard/text-box.cy.spec.js +++ b/e2e/test/scenarios/dashboard/text-box.cy.spec.js @@ -4,7 +4,7 @@ import { popover, visitDashboard, addTextBox, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > dashboard > text-box", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/dashboard/text-parameters.cy.spec.js b/e2e/test/scenarios/dashboard/text-parameters.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/dashboard/text-parameters.cy.spec.js rename to e2e/test/scenarios/dashboard/text-parameters.cy.spec.js index 5f7ce8bfb8665..bacfcadf7533c 100644 --- a/frontend/test/metabase/scenarios/dashboard/text-parameters.cy.spec.js +++ b/e2e/test/scenarios/dashboard/text-parameters.cy.spec.js @@ -7,8 +7,8 @@ import { filterWidget, addTextBox, popover, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/dashboard/title-drill.cy.spec.js b/e2e/test/scenarios/dashboard/title-drill.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/dashboard/title-drill.cy.spec.js rename to e2e/test/scenarios/dashboard/title-drill.cy.spec.js index 9dd99ee07b383..785f2ae8b558b 100644 --- a/frontend/test/metabase/scenarios/dashboard/title-drill.cy.spec.js +++ b/e2e/test/scenarios/dashboard/title-drill.cy.spec.js @@ -3,8 +3,8 @@ import { filterWidget, popover, visitDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -101,35 +101,36 @@ describe("scenarios > dashboard > title drill", () => { display: "scalar", }; - cy.createNativeQuestionAndDashboard({ questionDetails }).then( - ({ body: { id, card_id, dashboard_id } }) => { - cy.addFilterToDashboard({ filter, dashboard_id }); - - // Connect filter to the card - cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { - cards: [ - { - id, - card_id, - row: 0, - col: 0, - size_x: 8, - size_y: 6, - parameter_mappings: [ - { - parameter_id: filter.id, - card_id, - target: ["dimension", ["template-tag", "filter"]], - }, - ], - }, - ], - }); + const dashboardDetails = { parameters: [filter] }; + + cy.createNativeQuestionAndDashboard({ + questionDetails, + dashboardDetails, + }).then(({ body: { id, card_id, dashboard_id } }) => { + // Connect filter to the card + cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { + cards: [ + { + id, + card_id, + row: 0, + col: 0, + size_x: 8, + size_y: 6, + parameter_mappings: [ + { + parameter_id: filter.id, + card_id, + target: ["dimension", ["template-tag", "filter"]], + }, + ], + }, + ], + }); - visitDashboard(dashboard_id); - checkScalarResult("200"); - }, - ); + visitDashboard(dashboard_id); + checkScalarResult("200"); + }); }); describe("as a user with access to underlying data", () => { diff --git a/frontend/test/metabase/scenarios/dashboard/visualizaiton-options.cy.spec.js b/e2e/test/scenarios/dashboard/visualizaiton-options.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/dashboard/visualizaiton-options.cy.spec.js rename to e2e/test/scenarios/dashboard/visualizaiton-options.cy.spec.js index b33cfbe135fb3..a0367b3e7cd11 100644 --- a/frontend/test/metabase/scenarios/dashboard/visualizaiton-options.cy.spec.js +++ b/e2e/test/scenarios/dashboard/visualizaiton-options.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visitDashboard } from "__support__/e2e/helpers"; +import { restore, visitDashboard } from "e2e/support/helpers"; describe("scenarios > dashboard > visualization options", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/dashboard/x-rays.cy.spec.js b/e2e/test/scenarios/dashboard/x-rays.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/dashboard/x-rays.cy.spec.js rename to e2e/test/scenarios/dashboard/x-rays.cy.spec.js index 7e60138d6a223..90c1e0983b4a1 100644 --- a/frontend/test/metabase/scenarios/dashboard/x-rays.cy.spec.js +++ b/e2e/test/scenarios/dashboard/x-rays.cy.spec.js @@ -5,10 +5,10 @@ import { summarize, visualize, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; @@ -21,11 +21,10 @@ describe("scenarios > x-rays", () => { const XRAY_DATASETS = 11; // enough to load most questions - it.skip("should not display x-rays if the feature is disabled in admin settings (metabase#26571)", () => { + it("should not display x-rays if the feature is disabled in admin settings (metabase#26571)", () => { cy.request("PUT", "api/setting/enable-xrays", { value: false }); cy.visit("/"); - cy.findByText("Metabase tips"); cy.findByText( "Try out these sample x-rays to see what Metabase can do.", diff --git a/frontend/test/metabase/scenarios/docker-compose.yml b/e2e/test/scenarios/docker-compose.yml similarity index 100% rename from frontend/test/metabase/scenarios/docker-compose.yml rename to e2e/test/scenarios/docker-compose.yml diff --git a/frontend/test/metabase/scenarios/downloads/downloads.cy.spec.js b/e2e/test/scenarios/downloads/downloads.cy.spec.js similarity index 63% rename from frontend/test/metabase/scenarios/downloads/downloads.cy.spec.js rename to e2e/test/scenarios/downloads/downloads.cy.spec.js index 31d384d63a571..92e711fa0a3ff 100644 --- a/frontend/test/metabase/scenarios/downloads/downloads.cy.spec.js +++ b/e2e/test/scenarios/downloads/downloads.cy.spec.js @@ -5,8 +5,11 @@ import { visualize, visitDashboard, popover, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; + assertSheetRowsCount, + filterWidget, + saveDashboard, +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -34,6 +37,45 @@ describe("scenarios > question > download", () => { }); }); + describe("from dashboards", () => { + it("should allow downloading card data", () => { + cy.intercept("GET", "/api/dashboard/**").as("dashboard"); + visitDashboard(1); + cy.findByTestId("dashcard").within(() => { + cy.findByTestId("legend-caption").realHover(); + }); + + assertOrdersExport(18760); + + cy.icon("pencil").click(); + + cy.icon("filter").click(); + + popover().within(() => { + cy.contains("ID").click(); + }); + + cy.get(".DashCard").contains("Select…").click(); + popover().contains("ID").eq(0).click(); + + saveDashboard(); + + filterWidget().contains("ID").click(); + + popover().find("input").type("1"); + + cy.findByText("Add filter").click(); + + cy.wait("@dashboard"); + + cy.findByTestId("dashcard").within(() => { + cy.findByTestId("legend-caption").realHover(); + }); + + assertOrdersExport(1); + }); + }); + describe("png images", () => { const canSavePngQuestion = { name: "Q1", @@ -104,3 +146,22 @@ describe("scenarios > question > download", () => { }); }); }); + +function assertOrdersExport(length) { + downloadAndAssert( + { + fileType: "xlsx", + questionId: 1, + dashcardId: 1, + dashboardId: 1, + }, + sheet => { + expect(sheet["A1"].v).to.eq("ID"); + expect(sheet["A2"].v).to.eq(1); + expect(sheet["B1"].v).to.eq("User ID"); + expect(sheet["B2"].v).to.eq(1); + + assertSheetRowsCount(length)(sheet); + }, + ); +} diff --git a/frontend/test/metabase/scenarios/downloads/reproductions/10803-timestamp-formatting.cy.spec.js b/e2e/test/scenarios/downloads/reproductions/10803-timestamp-formatting.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/downloads/reproductions/10803-timestamp-formatting.cy.spec.js rename to e2e/test/scenarios/downloads/reproductions/10803-timestamp-formatting.cy.spec.js index 0e63ed8976f5a..88e34a70303a8 100644 --- a/frontend/test/metabase/scenarios/downloads/reproductions/10803-timestamp-formatting.cy.spec.js +++ b/e2e/test/scenarios/downloads/reproductions/10803-timestamp-formatting.cy.spec.js @@ -2,7 +2,7 @@ import { restore, downloadAndAssert, runNativeQuery, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const testCases = ["csv", "xlsx"]; diff --git a/frontend/test/metabase/scenarios/downloads/reproductions/18219-temporal-units-not-formatted.cy.spec.js b/e2e/test/scenarios/downloads/reproductions/18219-temporal-units-not-formatted.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/downloads/reproductions/18219-temporal-units-not-formatted.cy.spec.js rename to e2e/test/scenarios/downloads/reproductions/18219-temporal-units-not-formatted.cy.spec.js index 8cb8092611b4e..8f3d63c82913c 100644 --- a/frontend/test/metabase/scenarios/downloads/reproductions/18219-temporal-units-not-formatted.cy.spec.js +++ b/e2e/test/scenarios/downloads/reproductions/18219-temporal-units-not-formatted.cy.spec.js @@ -1,9 +1,5 @@ -import { - restore, - downloadAndAssert, - visitQuestion, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, downloadAndAssert, visitQuestion } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/downloads/reproductions/18382-old-syntax-missing-renamed-columns.cy.spec.js b/e2e/test/scenarios/downloads/reproductions/18382-old-syntax-missing-renamed-columns.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/downloads/reproductions/18382-old-syntax-missing-renamed-columns.cy.spec.js rename to e2e/test/scenarios/downloads/reproductions/18382-old-syntax-missing-renamed-columns.cy.spec.js index 0aee6dd0a32d2..e6848cdf4b70b 100644 --- a/frontend/test/metabase/scenarios/downloads/reproductions/18382-old-syntax-missing-renamed-columns.cy.spec.js +++ b/e2e/test/scenarios/downloads/reproductions/18382-old-syntax-missing-renamed-columns.cy.spec.js @@ -2,10 +2,10 @@ import { restore, visitQuestionAdhoc, downloadAndAssert, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS, REVIEWS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/downloads/reproductions/18440-remapped-display-value-dropped.cy.spec.js b/e2e/test/scenarios/downloads/reproductions/18440-remapped-display-value-dropped.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/downloads/reproductions/18440-remapped-display-value-dropped.cy.spec.js rename to e2e/test/scenarios/downloads/reproductions/18440-remapped-display-value-dropped.cy.spec.js index 612ba42f0ad5a..3be4ce1e8ba7f 100644 --- a/frontend/test/metabase/scenarios/downloads/reproductions/18440-remapped-display-value-dropped.cy.spec.js +++ b/e2e/test/scenarios/downloads/reproductions/18440-remapped-display-value-dropped.cy.spec.js @@ -3,10 +3,10 @@ import { visitQuestionAdhoc, downloadAndAssert, visitQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/downloads/reproductions/18573-remapped-fields-not-renamed.cy.spec.js b/e2e/test/scenarios/downloads/reproductions/18573-remapped-fields-not-renamed.cy.spec.js similarity index 87% rename from frontend/test/metabase/scenarios/downloads/reproductions/18573-remapped-fields-not-renamed.cy.spec.js rename to e2e/test/scenarios/downloads/reproductions/18573-remapped-fields-not-renamed.cy.spec.js index ffce04ad670ce..bd200c55dd3c5 100644 --- a/frontend/test/metabase/scenarios/downloads/reproductions/18573-remapped-fields-not-renamed.cy.spec.js +++ b/e2e/test/scenarios/downloads/reproductions/18573-remapped-fields-not-renamed.cy.spec.js @@ -2,10 +2,10 @@ import { restore, visitQuestionAdhoc, downloadAndAssert, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/downloads/reproductions/18729-date-formatting-x-of-y.cy.spec.js b/e2e/test/scenarios/downloads/reproductions/18729-date-formatting-x-of-y.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/downloads/reproductions/18729-date-formatting-x-of-y.cy.spec.js rename to e2e/test/scenarios/downloads/reproductions/18729-date-formatting-x-of-y.cy.spec.js index 4d46ebd6fc7b9..f8bd95efe6a39 100644 --- a/frontend/test/metabase/scenarios/downloads/reproductions/18729-date-formatting-x-of-y.cy.spec.js +++ b/e2e/test/scenarios/downloads/reproductions/18729-date-formatting-x-of-y.cy.spec.js @@ -2,10 +2,10 @@ import { restore, downloadAndAssert, visitQuestionAdhoc, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/downloads/reproductions/19889-native-query-export-column-order.cy.spec.js b/e2e/test/scenarios/downloads/reproductions/19889-native-query-export-column-order.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/downloads/reproductions/19889-native-query-export-column-order.cy.spec.js rename to e2e/test/scenarios/downloads/reproductions/19889-native-query-export-column-order.cy.spec.js index 5d2371677e075..dc8e801170689 100644 --- a/frontend/test/metabase/scenarios/downloads/reproductions/19889-native-query-export-column-order.cy.spec.js +++ b/e2e/test/scenarios/downloads/reproductions/19889-native-query-export-column-order.cy.spec.js @@ -1,8 +1,4 @@ -import { - restore, - downloadAndAssert, - visitQuestion, -} from "__support__/e2e/helpers"; +import { restore, downloadAndAssert, visitQuestion } from "e2e/support/helpers"; const questionDetails = { name: "19889", diff --git a/frontend/test/metabase/scenarios/embedding/embedding-dashboard.cy.spec.js b/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/embedding/embedding-dashboard.cy.spec.js rename to e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js index 239d0f3040001..55b41d436f017 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-dashboard.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js @@ -5,9 +5,9 @@ import { visitEmbeddedPage, filterWidget, visitIframe, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { questionDetails, dashboardDetails, diff --git a/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js b/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js rename to e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js index fd46c87d2e49a..0e0d216e98040 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-full-app.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js @@ -1,4 +1,4 @@ -import { adhocQuestionHash, popover, restore } from "__support__/e2e/helpers"; +import { adhocQuestionHash, popover, restore } from "e2e/support/helpers"; describe("scenarios > embedding > full app", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/embedding/embedding-linked-filters.cy.spec.js b/e2e/test/scenarios/embedding/embedding-linked-filters.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/embedding/embedding-linked-filters.cy.spec.js rename to e2e/test/scenarios/embedding/embedding-linked-filters.cy.spec.js index b441620ae1f4b..0fa3a7cd07f56 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-linked-filters.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-linked-filters.cy.spec.js @@ -3,7 +3,7 @@ import { visitEmbeddedPage, filterWidget, popover, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { nativeQuestionDetails, diff --git a/frontend/test/metabase/scenarios/embedding/embedding-native.cy.spec.js b/e2e/test/scenarios/embedding/embedding-native.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/embedding/embedding-native.cy.spec.js rename to e2e/test/scenarios/embedding/embedding-native.cy.spec.js index 283bcaa7d593b..10b269f7912e0 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-native.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-native.cy.spec.js @@ -4,7 +4,7 @@ import { filterWidget, visitEmbeddedPage, visitIframe, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { questionDetails } from "./shared/embedding-native"; diff --git a/frontend/test/metabase/scenarios/embedding/embedding-premium-token.cy.spec.js b/e2e/test/scenarios/embedding/embedding-premium-token.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/embedding/embedding-premium-token.cy.spec.js rename to e2e/test/scenarios/embedding/embedding-premium-token.cy.spec.js index bc84be2479755..3e93229f55ccd 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-premium-token.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-premium-token.cy.spec.js @@ -1,8 +1,8 @@ -import { restore, isOSS } from "__support__/e2e/helpers"; +import { restore, isOSS } from "e2e/support/helpers"; const embeddingPage = "/admin/settings/embedding-in-other-applications"; const licensePage = "/admin/settings/premium-embedding-license"; -const upgradeUrl = "https://www.metabase.com/upgrade/"; +const upgradeUrl = "https://www.metabase.com/upgrade"; // A random embedding token with valid format const embeddingToken = @@ -135,5 +135,5 @@ function stubTokenResponses() { function assertLinkMatchesUrl(text, url) { cy.findByRole("link", { name: text }) .should("have.attr", "href") - .and("eq", url); + .and("contain", url); } diff --git a/frontend/test/metabase/scenarios/embedding/embedding-questions.cy.spec.js b/e2e/test/scenarios/embedding/embedding-questions.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/embedding/embedding-questions.cy.spec.js rename to e2e/test/scenarios/embedding/embedding-questions.cy.spec.js index b1811bb450d6d..255f92ea052b7 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-questions.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-questions.cy.spec.js @@ -3,8 +3,8 @@ import { visitQuestion, popover, visitIframe, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { regularQuestion, diff --git a/frontend/test/metabase/scenarios/embedding/embedding-smoketests.cy.spec.js b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/embedding/embedding-smoketests.cy.spec.js rename to e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js index 4897973a5b878..333481db31ea5 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-smoketests.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js @@ -5,11 +5,11 @@ import { isOSS, visitDashboard, visitIframe, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const embeddingPage = "/admin/settings/embedding-in-other-applications"; const licenseUrl = "https://metabase.com/license/embedding"; -const upgradeUrl = "https://www.metabase.com/upgrade/"; +const upgradeUrl = "https://www.metabase.com/upgrade"; const learnEmbeddingUrl = "https://www.metabase.com/learn/embedding/embedding-charts-and-dashboards.html"; @@ -235,7 +235,7 @@ function enableSharing() { function assertLinkMatchesUrl(text, url) { cy.findByRole("link", { name: text }) .should("have.attr", "href") - .and("eq", url); + .and("contain", url); } function ensureEmbeddingIsDisabled() { diff --git a/frontend/test/metabase/scenarios/embedding/embedding-snippets.cy.spec.js b/e2e/test/scenarios/embedding/embedding-snippets.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/embedding/embedding-snippets.cy.spec.js rename to e2e/test/scenarios/embedding/embedding-snippets.cy.spec.js index 415bb258c4e49..861510ab975fe 100644 --- a/frontend/test/metabase/scenarios/embedding/embedding-snippets.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-snippets.cy.spec.js @@ -4,7 +4,7 @@ import { visitDashboard, visitQuestion, isEE, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { JS_CODE, IFRAME_CODE } from "./shared/embedding-snippets"; diff --git a/frontend/test/metabase/scenarios/embedding/reproductions/15860-locked-filters-same-source-table.cy.spec.js b/e2e/test/scenarios/embedding/reproductions/15860-locked-filters-same-source-table.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/embedding/reproductions/15860-locked-filters-same-source-table.cy.spec.js rename to e2e/test/scenarios/embedding/reproductions/15860-locked-filters-same-source-table.cy.spec.js index e2d933e90562f..d9c608d6f4433 100644 --- a/frontend/test/metabase/scenarios/embedding/reproductions/15860-locked-filters-same-source-table.cy.spec.js +++ b/e2e/test/scenarios/embedding/reproductions/15860-locked-filters-same-source-table.cy.spec.js @@ -3,9 +3,9 @@ import { popover, visitDashboard, visitIframe, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/embedding/reproductions/20438-dashboard-filter-single-value.cy.spec.js b/e2e/test/scenarios/embedding/reproductions/20438-dashboard-filter-single-value.cy.spec.js similarity index 58% rename from frontend/test/metabase/scenarios/embedding/reproductions/20438-dashboard-filter-single-value.cy.spec.js rename to e2e/test/scenarios/embedding/reproductions/20438-dashboard-filter-single-value.cy.spec.js index 19848c4d03299..aeb11fc9add7a 100644 --- a/frontend/test/metabase/scenarios/embedding/reproductions/20438-dashboard-filter-single-value.cy.spec.js +++ b/e2e/test/scenarios/embedding/reproductions/20438-dashboard-filter-single-value.cy.spec.js @@ -4,8 +4,8 @@ import { popover, visitDashboard, visitIframe, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; @@ -36,6 +36,10 @@ const filter = { sectionId: "string", }; +const dashboardDetails = { + parameters: [filter], +}; + describe("issue 20438", () => { beforeEach(() => { cy.intercept("GET", "/api/embed/dashboard/**").as("getEmbed"); @@ -43,40 +47,39 @@ describe("issue 20438", () => { restore(); cy.signInAsAdmin(); - cy.createNativeQuestionAndDashboard({ questionDetails }).then( - ({ body: { id, card_id, dashboard_id } }) => { - cy.addFilterToDashboard({ filter, dashboard_id }); - - // Connect filter to the card - cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { - cards: [ - { - id, - card_id, - row: 0, - col: 0, - size_x: 18, - size_y: 8, - parameter_mappings: [ - { - parameter_id: filter.id, - card_id, - target: ["dimension", ["template-tag", "CATEGORY"]], - }, - ], - }, - ], - }); - - // Enable embedding and enable the "Text" filter - cy.request("PUT", `/api/dashboard/${dashboard_id}`, { - enable_embedding: true, - embedding_params: { [filter.slug]: "enabled" }, - }); - - visitDashboard(dashboard_id); - }, - ); + cy.createNativeQuestionAndDashboard({ + questionDetails, + dashboardDetails, + }).then(({ body: { id, card_id, dashboard_id } }) => { + // Connect filter to the card + cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { + cards: [ + { + id, + card_id, + row: 0, + col: 0, + size_x: 18, + size_y: 8, + parameter_mappings: [ + { + parameter_id: filter.id, + card_id, + target: ["dimension", ["template-tag", "CATEGORY"]], + }, + ], + }, + ], + }); + + // Enable embedding and enable the "Text" filter + cy.request("PUT", `/api/dashboard/${dashboard_id}`, { + enable_embedding: true, + embedding_params: { [filter.slug]: "enabled" }, + }); + + visitDashboard(dashboard_id); + }); }); it("dashboard filter connected to the field filter should work with a single value in embedded dashboards (metabase#20438)", () => { diff --git a/frontend/test/metabase/scenarios/embedding/reproductions/20634-locked-parameters-in-embedded-question.cy.spec.js b/e2e/test/scenarios/embedding/reproductions/20634-locked-parameters-in-embedded-question.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/embedding/reproductions/20634-locked-parameters-in-embedded-question.cy.spec.js rename to e2e/test/scenarios/embedding/reproductions/20634-locked-parameters-in-embedded-question.cy.spec.js index bb946cec875bb..ecc169e38a80d 100644 --- a/frontend/test/metabase/scenarios/embedding/reproductions/20634-locked-parameters-in-embedded-question.cy.spec.js +++ b/e2e/test/scenarios/embedding/reproductions/20634-locked-parameters-in-embedded-question.cy.spec.js @@ -1,7 +1,9 @@ -import { restore, visitIframe } from "__support__/e2e/helpers"; +import { restore, visitIframe } from "e2e/support/helpers"; describe("locked parameters in embedded question (metabase#20634)", () => { beforeEach(() => { + cy.intercept("PUT", "/api/card/*").as("publishChanges"); + restore(); cy.signInAsAdmin(); @@ -46,6 +48,7 @@ describe("locked parameters in embedded question (metabase#20634)", () => { // publish the embedded question so that we can directly navigate to its url cy.findByText("Publish").click(); + cy.wait("@publishChanges"); }); // directly navigate to the embedded question diff --git a/frontend/test/metabase/scenarios/embedding/reproductions/20845-25031-locked-numeric-param.cy.spec.js b/e2e/test/scenarios/embedding/reproductions/20845-25031-locked-numeric-param.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/embedding/reproductions/20845-25031-locked-numeric-param.cy.spec.js rename to e2e/test/scenarios/embedding/reproductions/20845-25031-locked-numeric-param.cy.spec.js index c14662005c909..eacbecca0698b 100644 --- a/frontend/test/metabase/scenarios/embedding/reproductions/20845-25031-locked-numeric-param.cy.spec.js +++ b/e2e/test/scenarios/embedding/reproductions/20845-25031-locked-numeric-param.cy.spec.js @@ -3,7 +3,7 @@ import { visitEmbeddedPage, visitDashboard, visitQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const dashboardFilter = { name: "Equal to", diff --git a/frontend/test/metabase/scenarios/embedding/shared/embedding-dashboard.js b/e2e/test/scenarios/embedding/shared/embedding-dashboard.js similarity index 97% rename from frontend/test/metabase/scenarios/embedding/shared/embedding-dashboard.js rename to e2e/test/scenarios/embedding/shared/embedding-dashboard.js index 62d77929e42ec..8fdbde8c76a7e 100644 --- a/frontend/test/metabase/scenarios/embedding/shared/embedding-dashboard.js +++ b/e2e/test/scenarios/embedding/shared/embedding-dashboard.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/embedding/shared/embedding-linked-filters.js b/e2e/test/scenarios/embedding/shared/embedding-linked-filters.js similarity index 97% rename from frontend/test/metabase/scenarios/embedding/shared/embedding-linked-filters.js rename to e2e/test/scenarios/embedding/shared/embedding-linked-filters.js index 43f4d05fa47da..df7cddb03d5b7 100644 --- a/frontend/test/metabase/scenarios/embedding/shared/embedding-linked-filters.js +++ b/e2e/test/scenarios/embedding/shared/embedding-linked-filters.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/embedding/shared/embedding-native.js b/e2e/test/scenarios/embedding/shared/embedding-native.js similarity index 96% rename from frontend/test/metabase/scenarios/embedding/shared/embedding-native.js rename to e2e/test/scenarios/embedding/shared/embedding-native.js index 8d025b7a2af4c..34f649edc8c99 100644 --- a/frontend/test/metabase/scenarios/embedding/shared/embedding-native.js +++ b/e2e/test/scenarios/embedding/shared/embedding-native.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/embedding/shared/embedding-questions.js b/e2e/test/scenarios/embedding/shared/embedding-questions.js similarity index 95% rename from frontend/test/metabase/scenarios/embedding/shared/embedding-questions.js rename to e2e/test/scenarios/embedding/shared/embedding-questions.js index d4d132617e723..6d7cc54fdab15 100644 --- a/frontend/test/metabase/scenarios/embedding/shared/embedding-questions.js +++ b/e2e/test/scenarios/embedding/shared/embedding-questions.js @@ -1,4 +1,4 @@ -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/embedding/shared/embedding-snippets.js b/e2e/test/scenarios/embedding/shared/embedding-snippets.js similarity index 100% rename from frontend/test/metabase/scenarios/embedding/shared/embedding-snippets.js rename to e2e/test/scenarios/embedding/shared/embedding-snippets.js diff --git a/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js b/e2e/test/scenarios/filters/filter-bulk.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js rename to e2e/test/scenarios/filters/filter-bulk.cy.spec.js index 3471a368bf9a3..89baa06527aa1 100644 --- a/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js +++ b/e2e/test/scenarios/filters/filter-bulk.cy.spec.js @@ -6,9 +6,9 @@ import { filter, filterField, filterFieldPopover, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, ORDERS, PEOPLE_ID, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/filters/filter.cy.spec.js b/e2e/test/scenarios/filters/filter.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/filters/filter.cy.spec.js rename to e2e/test/scenarios/filters/filter.cy.spec.js index 240cf00430ea4..f206665ce684f 100644 --- a/frontend/test/metabase/scenarios/filters/filter.cy.spec.js +++ b/e2e/test/scenarios/filters/filter.cy.spec.js @@ -13,10 +13,10 @@ import { filterField, filterFieldPopover, setupBooleanQuery, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/filters/operators.cy.spec.js b/e2e/test/scenarios/filters/operators.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/filters/operators.cy.spec.js rename to e2e/test/scenarios/filters/operators.cy.spec.js index bfb8ef099d0ca..e60a99b7eafb5 100644 --- a/frontend/test/metabase/scenarios/filters/operators.cy.spec.js +++ b/e2e/test/scenarios/filters/operators.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover, startNewQuestion } from "__support__/e2e/helpers"; +import { restore, popover, startNewQuestion } from "e2e/support/helpers"; describe("operators in questions", () => { beforeEach(() => { @@ -75,7 +75,7 @@ describe("operators in questions", () => { cy.findByText("Is").click(); }); - popover().within(() => { + cy.findByTestId("operator-select-list").within(() => { expected.text.expected.map(e => cy.contains(e).should("exist")); expected.text.unexpected.map(e => cy.contains(e).should("not.exist")); }); @@ -92,7 +92,7 @@ describe("operators in questions", () => { cy.findByText("Equal to").click(); }); - popover().within(() => { + cy.findByTestId("operator-select-list").within(() => { expected.number.expected.map(e => cy.contains(e).should("exist")); expected.number.unexpected.map(e => cy.contains(e).should("not.exist")); }); @@ -190,7 +190,7 @@ describe("operators in questions", () => { cy.findByText("Is").click(); }); - popover().within(() => { + cy.findByTestId("operator-select-list").within(() => { expected.id.expected.map(e => cy.contains(e).should("exist")); expected.id.unexpected.map(e => cy.contains(e).should("not.exist")); }); @@ -207,7 +207,7 @@ describe("operators in questions", () => { cy.findByText("Is").click(); }); - popover().within(() => { + cy.findByTestId("operator-select-list").within(() => { expected.geo.expected.map(e => cy.contains(e).should("exist")); expected.geo.unexpected.map(e => cy.contains(e).should("not.exist")); }); diff --git a/frontend/test/metabase/scenarios/filters/relative-datetime.cy.spec.js b/e2e/test/scenarios/filters/relative-datetime.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/filters/relative-datetime.cy.spec.js rename to e2e/test/scenarios/filters/relative-datetime.cy.spec.js index bda912ca3239b..fadc013049918 100644 --- a/frontend/test/metabase/scenarios/filters/relative-datetime.cy.spec.js +++ b/e2e/test/scenarios/filters/relative-datetime.cy.spec.js @@ -1,5 +1,5 @@ import moment from "moment-timezone"; -import { restore, popover, openOrdersTable } from "__support__/e2e/helpers"; +import { restore, popover, openOrdersTable } from "e2e/support/helpers"; const STARTING_FROM_UNITS = [ "minutes", diff --git a/frontend/test/metabase/scenarios/filters/reproductions/16621-create-multiple-filters-with-same-value.cy.spec.js b/e2e/test/scenarios/filters/reproductions/16621-create-multiple-filters-with-same-value.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/filters/reproductions/16621-create-multiple-filters-with-same-value.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/16621-create-multiple-filters-with-same-value.cy.spec.js index 05bd71df9935a..a7911c9841ca4 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/16621-create-multiple-filters-with-same-value.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/16621-create-multiple-filters-with-same-value.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openProductsTable } from "__support__/e2e/helpers"; +import { restore, openProductsTable } from "e2e/support/helpers"; describe("issue 16661", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/filters/reproductions/18770-post-aggregation-filter-disrupts-drillthrough.cy.spec.js b/e2e/test/scenarios/filters/reproductions/18770-post-aggregation-filter-disrupts-drillthrough.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/filters/reproductions/18770-post-aggregation-filter-disrupts-drillthrough.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/18770-post-aggregation-filter-disrupts-drillthrough.cy.spec.js index 24b3b1d3d7d0f..d88833eef42d0 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/18770-post-aggregation-filter-disrupts-drillthrough.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/18770-post-aggregation-filter-disrupts-drillthrough.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover, visualize } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover, visualize } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, PRODUCTS, ORDERS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/filters/reproductions/20551-filter-starts-with.cy.spec.js b/e2e/test/scenarios/filters/reproductions/20551-filter-starts-with.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/filters/reproductions/20551-filter-starts-with.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/20551-filter-starts-with.cy.spec.js index 6c854b7c1a8bf..bdce92e58e1e7 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/20551-filter-starts-with.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/20551-filter-starts-with.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openProductsTable, filter } from "__support__/e2e/helpers"; +import { restore, openProductsTable, filter } from "e2e/support/helpers"; describe("issue 20551", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/filters/reproductions/20683-postgres-current-quarter.cy.spec.js b/e2e/test/scenarios/filters/reproductions/20683-postgres-current-quarter.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/filters/reproductions/20683-postgres-current-quarter.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/20683-postgres-current-quarter.cy.spec.js index 4ebedbe679502..8895d5f44d961 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/20683-postgres-current-quarter.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/20683-postgres-current-quarter.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visualize } from "__support__/e2e/helpers"; +import { restore, visualize } from "e2e/support/helpers"; describe("issue 20683", { tags: "@external" }, () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/filters/reproductions/21979-exclude-day-of-the-week.cy.spec.js b/e2e/test/scenarios/filters/reproductions/21979-exclude-day-of-the-week.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/filters/reproductions/21979-exclude-day-of-the-week.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/21979-exclude-day-of-the-week.cy.spec.js index a6bf8a393d0f5..535902a63e83e 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/21979-exclude-day-of-the-week.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/21979-exclude-day-of-the-week.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openProductsTable, popover } from "__support__/e2e/helpers"; +import { restore, openProductsTable, popover } from "e2e/support/helpers"; describe("issue 21979", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/filters/reproductions/22230-filter-on-aggregation-max-of.cy.spec.js b/e2e/test/scenarios/filters/reproductions/22230-filter-on-aggregation-max-of.cy.spec.js similarity index 58% rename from frontend/test/metabase/scenarios/filters/reproductions/22230-filter-on-aggregation-max-of.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/22230-filter-on-aggregation-max-of.cy.spec.js index 4bcc37205f946..8c8ee8e37f9a9 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/22230-filter-on-aggregation-max-of.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/22230-filter-on-aggregation-max-of.cy.spec.js @@ -3,10 +3,10 @@ import { visitQuestionAdhoc, popover, visualize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; @@ -23,7 +23,7 @@ const questionDetails = { }, }; -describe.skip("issue 22230", () => { +describe("issue 22230", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); @@ -34,11 +34,15 @@ describe.skip("issue 22230", () => { it("should be able to filter on an aggregation (metabase#22230)", () => { cy.findByText("Filter").click(); popover().contains("Max of Name").click(); + cy.findByTestId("select-button").click(); + cy.findByRole("option", { name: "Starts with" }).click(); - cy.findByPlaceholderText("Enter some text").type("Zora").blur(); + cy.findByPlaceholderText("Enter some text").type("Zo").blur(); cy.button("Add filter").click(); visualize(); - cy.findByText("Zora Schamberger"); + cy.findByText("Showing 2 rows").should("be.visible"); + cy.findByText("Zora Schamberger").should("be.visible"); + cy.findByText("Zoie Kozey").should("be.visible"); }); }); diff --git a/frontend/test/metabase/scenarios/filters/reproductions/22730-table-column-time-filter.cy.spec.js b/e2e/test/scenarios/filters/reproductions/22730-table-column-time-filter.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/filters/reproductions/22730-table-column-time-filter.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/22730-table-column-time-filter.cy.spec.js index bb507fc1692bc..f6b7b9fba345e 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/22730-table-column-time-filter.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/22730-table-column-time-filter.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover } from "__support__/e2e/helpers"; +import { restore, popover } from "e2e/support/helpers"; describe("issue 22730", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/filters/reproductions/24664-multiple-filters-editing.cy.spec.js b/e2e/test/scenarios/filters/reproductions/24664-multiple-filters-editing.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/filters/reproductions/24664-multiple-filters-editing.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/24664-multiple-filters-editing.cy.spec.js index 953a689e26ed7..90a662c422beb 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/24664-multiple-filters-editing.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/24664-multiple-filters-editing.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openProductsTable } from "__support__/e2e/helpers"; +import { restore, openProductsTable } from "e2e/support/helpers"; describe("issue 24664", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/filters/reproductions/24994-update-filters.cy.spec.js b/e2e/test/scenarios/filters/reproductions/24994-update-filters.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/filters/reproductions/24994-update-filters.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/24994-update-filters.cy.spec.js index 74c659ab79305..0e7650aedea86 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/24994-update-filters.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/24994-update-filters.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/filters/reproductions/25378-relative-date-on-breakout.cy.spec.js b/e2e/test/scenarios/filters/reproductions/25378-relative-date-on-breakout.cy.spec.js similarity index 86% rename from frontend/test/metabase/scenarios/filters/reproductions/25378-relative-date-on-breakout.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/25378-relative-date-on-breakout.cy.spec.js index 97a0fa8c389d5..c0101be3b4350 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/25378-relative-date-on-breakout.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/25378-relative-date-on-breakout.cy.spec.js @@ -3,10 +3,10 @@ import { visitQuestionAdhoc, popover, visualize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -24,7 +24,7 @@ const questionDetails = { display: "line", }; -describe.skip("issue 25378", () => { +describe("issue 25378", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); diff --git a/frontend/test/metabase/scenarios/filters/reproductions/25927-column-filters-not-working-after-cc.cy.spec.js b/e2e/test/scenarios/filters/reproductions/25927-column-filters-not-working-after-cc.cy.spec.js similarity index 84% rename from frontend/test/metabase/scenarios/filters/reproductions/25927-column-filters-not-working-after-cc.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/25927-column-filters-not-working-after-cc.cy.spec.js index 5568736760b4a..c80580d98fc33 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/25927-column-filters-not-working-after-cc.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/25927-column-filters-not-working-after-cc.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js b/e2e/test/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js similarity index 86% rename from frontend/test/metabase/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js index e311897587a79..18b4c4a95dea7 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/25990-filter-nested-join.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/filters/reproductions/25994-between-after-summarize-not-working.cy.spec.js b/e2e/test/scenarios/filters/reproductions/25994-between-after-summarize-not-working.cy.spec.js similarity index 83% rename from frontend/test/metabase/scenarios/filters/reproductions/25994-between-after-summarize-not-working.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/25994-between-after-summarize-not-working.cy.spec.js index 30723dc0dff85..2ca29d5b994c3 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/25994-between-after-summarize-not-working.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/25994-between-after-summarize-not-working.cy.spec.js @@ -3,9 +3,9 @@ import { visitQuestionAdhoc, popover, visualize, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -23,7 +23,7 @@ const questionDetails = { }, }; -describe.skip("issue 25994", () => { +describe("issue 25994", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); diff --git a/frontend/test/metabase/scenarios/filters/reproductions/26861-exclude-breaks-native.cy.spec.js b/e2e/test/scenarios/filters/reproductions/26861-exclude-breaks-native.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/filters/reproductions/26861-exclude-breaks-native.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/26861-exclude-breaks-native.cy.spec.js index 60f6b198a7c4e..443a8cc9ba2e1 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/26861-exclude-breaks-native.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/26861-exclude-breaks-native.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, filterWidget } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, filterWidget } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/filters/reproductions/27123-exclude-always-shows-days-of-week.cy.spec.js b/e2e/test/scenarios/filters/reproductions/27123-exclude-always-shows-days-of-week.cy.spec.js similarity index 84% rename from frontend/test/metabase/scenarios/filters/reproductions/27123-exclude-always-shows-days-of-week.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/27123-exclude-always-shows-days-of-week.cy.spec.js index b0c771be6be8d..2a9536061bc55 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/27123-exclude-always-shows-days-of-week.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/27123-exclude-always-shows-days-of-week.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/filters/reproductions/9339-clipboard-numeric-filter.cy.spec.js b/e2e/test/scenarios/filters/reproductions/9339-clipboard-numeric-filter.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/filters/reproductions/9339-clipboard-numeric-filter.cy.spec.js rename to e2e/test/scenarios/filters/reproductions/9339-clipboard-numeric-filter.cy.spec.js index a292e111bd7ac..bb5eef8824548 100644 --- a/frontend/test/metabase/scenarios/filters/reproductions/9339-clipboard-numeric-filter.cy.spec.js +++ b/e2e/test/scenarios/filters/reproductions/9339-clipboard-numeric-filter.cy.spec.js @@ -1,4 +1,4 @@ -import { openOrdersTable, restore } from "__support__/e2e/helpers"; +import { openOrdersTable, restore } from "e2e/support/helpers"; describe("issue 9339", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/filters/view.cy.spec.js b/e2e/test/scenarios/filters/view.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/filters/view.cy.spec.js rename to e2e/test/scenarios/filters/view.cy.spec.js index 58cc52e0c3e7f..a0919c545149f 100644 --- a/frontend/test/metabase/scenarios/filters/view.cy.spec.js +++ b/e2e/test/scenarios/filters/view.cy.spec.js @@ -3,8 +3,8 @@ import { popover, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/joins/joins.cy.spec.js b/e2e/test/scenarios/joins/joins.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/joins/joins.cy.spec.js rename to e2e/test/scenarios/joins/joins.cy.spec.js index e1b4a5557b574..b0d03f9f2afcd 100644 --- a/frontend/test/metabase/scenarios/joins/joins.cy.spec.js +++ b/e2e/test/scenarios/joins/joins.cy.spec.js @@ -9,10 +9,10 @@ import { visitQuestionAdhoc, enterCustomColumnDetails, openProductsTable, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, diff --git a/frontend/test/metabase/scenarios/joins/reproductions/15342-mysql-correct-joins-order.cy.spec.js b/e2e/test/scenarios/joins/reproductions/15342-mysql-correct-joins-order.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/joins/reproductions/15342-mysql-correct-joins-order.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/15342-mysql-correct-joins-order.cy.spec.js index cbb2c78605e6b..5f9f5fb948ca2 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/15342-mysql-correct-joins-order.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/15342-mysql-correct-joins-order.cy.spec.js @@ -3,7 +3,7 @@ import { popover, visualize, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const MYSQL_DB_NAME = "QA MySQL8"; diff --git a/frontend/test/metabase/scenarios/joins/reproductions/17710-notebook-incomplete-joins-removed.cy.spec.js b/e2e/test/scenarios/joins/reproductions/17710-notebook-incomplete-joins-removed.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/joins/reproductions/17710-notebook-incomplete-joins-removed.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/17710-notebook-incomplete-joins-removed.cy.spec.js index c0c7cec10e93f..a609fcbba6fa6 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/17710-notebook-incomplete-joins-removed.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/17710-notebook-incomplete-joins-removed.cy.spec.js @@ -3,7 +3,7 @@ import { popover, openOrdersTable, visualize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 17710", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/joins/reproductions/17712-notebook-extra-sections-removed.cy.spec.js b/e2e/test/scenarios/joins/reproductions/17712-notebook-extra-sections-removed.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/joins/reproductions/17712-notebook-extra-sections-removed.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/17712-notebook-extra-sections-removed.cy.spec.js index 8710f597c31d9..21b6a0de73537 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/17712-notebook-extra-sections-removed.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/17712-notebook-extra-sections-removed.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover, openOrdersTable } from "__support__/e2e/helpers"; +import { restore, popover, openOrdersTable } from "e2e/support/helpers"; describe("issue 17712", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/joins/reproductions/17767-cannot-join-on-aggregation-with-implicit-joins.cy.spec.js b/e2e/test/scenarios/joins/reproductions/17767-cannot-join-on-aggregation-with-implicit-joins.cy.spec.js similarity index 87% rename from frontend/test/metabase/scenarios/joins/reproductions/17767-cannot-join-on-aggregation-with-implicit-joins.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/17767-cannot-join-on-aggregation-with-implicit-joins.cy.spec.js index fa4d30a88ff76..bf6d3089d2f04 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/17767-cannot-join-on-aggregation-with-implicit-joins.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/17767-cannot-join-on-aggregation-with-implicit-joins.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover, visualize } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover, visualize } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/joins/reproductions/17968-notebook-join-table-names.cy.spec.js b/e2e/test/scenarios/joins/reproductions/17968-notebook-join-table-names.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/joins/reproductions/17968-notebook-join-table-names.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/17968-notebook-join-table-names.cy.spec.js index 25383ba0d8473..773ac0f5d13fb 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/17968-notebook-join-table-names.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/17968-notebook-join-table-names.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover, openOrdersTable } from "__support__/e2e/helpers"; +import { restore, popover, openOrdersTable } from "e2e/support/helpers"; describe("issue 17968", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/joins/reproductions/18502-cannot-join-two-saved-questions-same-table.cy.spec.js b/e2e/test/scenarios/joins/reproductions/18502-cannot-join-two-saved-questions-same-table.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/joins/reproductions/18502-cannot-join-two-saved-questions-same-table.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/18502-cannot-join-two-saved-questions-same-table.cy.spec.js index d0f4873becfd7..093d8135c9cc5 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/18502-cannot-join-two-saved-questions-same-table.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/18502-cannot-join-two-saved-questions-same-table.cy.spec.js @@ -3,10 +3,10 @@ import { popover, visualize, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/joins/reproductions/18512-cannot-join-two-saved-questions-with-same-implicit-explicit-grouped-field.cy.spec.js b/e2e/test/scenarios/joins/reproductions/18512-cannot-join-two-saved-questions-with-same-implicit-explicit-grouped-field.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/joins/reproductions/18512-cannot-join-two-saved-questions-with-same-implicit-explicit-grouped-field.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/18512-cannot-join-two-saved-questions-with-same-implicit-explicit-grouped-field.cy.spec.js index cd9e86ee462fe..335144ab488b3 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/18512-cannot-join-two-saved-questions-with-same-implicit-explicit-grouped-field.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/18512-cannot-join-two-saved-questions-with-same-implicit-explicit-grouped-field.cy.spec.js @@ -3,8 +3,8 @@ import { popover, visualize, startNewQuestion, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID, REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/joins/reproductions/18589-numeric-binning-in-joins.cy.spec.js b/e2e/test/scenarios/joins/reproductions/18589-numeric-binning-in-joins.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/joins/reproductions/18589-numeric-binning-in-joins.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/18589-numeric-binning-in-joins.cy.spec.js index 82b5733ff1b48..106d443cccb73 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/18589-numeric-binning-in-joins.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/18589-numeric-binning-in-joins.cy.spec.js @@ -4,7 +4,7 @@ import { visualize, popover, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 18589", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/joins/reproductions/18630-field-literals-in-joins.cy.spec.js b/e2e/test/scenarios/joins/reproductions/18630-field-literals-in-joins.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/joins/reproductions/18630-field-literals-in-joins.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/18630-field-literals-in-joins.cy.spec.js index c6c9277eff20c..dab259c446e01 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/18630-field-literals-in-joins.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/18630-field-literals-in-joins.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/joins/reproductions/18818-crash-when-joining-on-custom-column.cy.spec.js b/e2e/test/scenarios/joins/reproductions/18818-crash-when-joining-on-custom-column.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/joins/reproductions/18818-crash-when-joining-on-custom-column.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/18818-crash-when-joining-on-custom-column.cy.spec.js index c49c04b7365d5..4aee08fb0696d 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/18818-crash-when-joining-on-custom-column.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/18818-crash-when-joining-on-custom-column.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/joins/reproductions/20519-cannot-join-on-aggregation-with-implicit-joins-and-nested-query.cy.spec.js b/e2e/test/scenarios/joins/reproductions/20519-cannot-join-on-aggregation-with-implicit-joins-and-nested-query.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/joins/reproductions/20519-cannot-join-on-aggregation-with-implicit-joins-and-nested-query.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/20519-cannot-join-on-aggregation-with-implicit-joins-and-nested-query.cy.spec.js index 4b18e632f3523..760573154a47b 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/20519-cannot-join-on-aggregation-with-implicit-joins-and-nested-query.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/20519-cannot-join-on-aggregation-with-implicit-joins-and-nested-query.cy.spec.js @@ -2,10 +2,10 @@ import { restore, enterCustomColumnDetails, visualize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/joins/reproductions/22859-multi-nested-joins-wrong-aliasing.cy.spec.js b/e2e/test/scenarios/joins/reproductions/22859-multi-nested-joins-wrong-aliasing.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/joins/reproductions/22859-multi-nested-joins-wrong-aliasing.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/22859-multi-nested-joins-wrong-aliasing.cy.spec.js index 807be60a57a22..ae03376d4ebc2 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/22859-multi-nested-joins-wrong-aliasing.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/22859-multi-nested-joins-wrong-aliasing.cy.spec.js @@ -4,9 +4,9 @@ import { visualize, startNewQuestion, openOrdersTable, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS, REVIEWS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/joins/reproductions/27380-dashboard-drops-joined-fields-on-zoom-in.cy.spec.js b/e2e/test/scenarios/joins/reproductions/27380-dashboard-drops-joined-fields-on-zoom-in.cy.spec.js similarity index 78% rename from frontend/test/metabase/scenarios/joins/reproductions/27380-dashboard-drops-joined-fields-on-zoom-in.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/27380-dashboard-drops-joined-fields-on-zoom-in.cy.spec.js index 2d409a1fcd52e..f4778b0dd9144 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/27380-dashboard-drops-joined-fields-on-zoom-in.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/27380-dashboard-drops-joined-fields-on-zoom-in.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; @@ -18,7 +18,7 @@ const questionDetails = { display: "line", }; -describe("issue 27380", () => { +describe.skip("issue 27380", () => { beforeEach(() => { cy.intercept("POST", "/api/dataset").as("dataset"); @@ -40,10 +40,10 @@ describe("issue 27380", () => { // Graph should still exist // Let's check only the y-axis label - cy.get(".y-axis-label").invoke("text").should("eq", "Count"); + cy.get("y-axis-label").invoke("text").should("eq", "Count"); cy.icon("notebook").click(); cy.findByText("Pick a column to group by").should("not.exist"); - cy.findByText("Count by Product → Created At: Week"); + cy.findByText(/Products? → Created At: Month/); }); }); diff --git a/frontend/test/metabase/scenarios/joins/reproductions/27873-missing-joined-group-by.cy.spec.js b/e2e/test/scenarios/joins/reproductions/27873-missing-joined-group-by.cy.spec.js similarity index 81% rename from frontend/test/metabase/scenarios/joins/reproductions/27873-missing-joined-group-by.cy.spec.js rename to e2e/test/scenarios/joins/reproductions/27873-missing-joined-group-by.cy.spec.js index d5fc29d09d935..6f01adb04198d 100644 --- a/frontend/test/metabase/scenarios/joins/reproductions/27873-missing-joined-group-by.cy.spec.js +++ b/e2e/test/scenarios/joins/reproductions/27873-missing-joined-group-by.cy.spec.js @@ -1,10 +1,6 @@ -import { - restore, - visitQuestionAdhoc, - summarize, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestionAdhoc, summarize } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { ORDERS, ORDERS_ID, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; @@ -36,7 +32,7 @@ const questionDetails = { display: "table", }; -describe.skip("issue 27873", () => { +describe("issue 27873", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); diff --git a/e2e/test/scenarios/joins/reproductions/29795-native-query-join-question-misses-metadata.cy.spec.js b/e2e/test/scenarios/joins/reproductions/29795-native-query-join-question-misses-metadata.cy.spec.js new file mode 100644 index 0000000000000..bd012e7964a6f --- /dev/null +++ b/e2e/test/scenarios/joins/reproductions/29795-native-query-join-question-misses-metadata.cy.spec.js @@ -0,0 +1,47 @@ +import { + restore, + visualize, + popover, + openOrdersTable, +} from "e2e/support/helpers"; + +describe("issue 29795", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + it("should allow join based on native query (metabase#29795)", () => { + const NATIVE_QUESTION = "native question"; + const LIMIT = 5; + cy.createNativeQuestion( + { + name: NATIVE_QUESTION, + native: { query: `SELECT * FROM "PUBLIC"."ORDERS" LIMIT ${LIMIT}` }, + }, + { loadMetadata: true }, + ); + + openOrdersTable({ mode: "notebook", limit: LIMIT }); + + cy.icon("join_left_outer").click(); + + popover().within(() => { + cy.icon("chevronleft").click(); + cy.findByText("Saved Questions").click(); + cy.findByRole("menuitem", { name: NATIVE_QUESTION }).click(); + }); + + popover().within(() => { + cy.findByRole("option", { name: "ID" }).click(); + }); + + popover().within(() => { + cy.findByRole("option", { name: "USER_ID" }).click(); + }); + + visualize(() => { + cy.findAllByText(/User ID/i).should("have.length", 2); + }); + }); +}); diff --git a/frontend/test/metabase/scenarios/models/create.cy.spec.js b/e2e/test/scenarios/models/create.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/models/create.cy.spec.js rename to e2e/test/scenarios/models/create.cy.spec.js index a960685fbb873..6e43613b7431e 100644 --- a/frontend/test/metabase/scenarios/models/create.cy.spec.js +++ b/e2e/test/scenarios/models/create.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visitCollection } from "__support__/e2e/helpers"; +import { restore, visitCollection } from "e2e/support/helpers"; const modelName = "A name"; diff --git a/frontend/test/metabase/scenarios/models/helpers/e2e-models-helpers.js b/e2e/test/scenarios/models/helpers/e2e-models-helpers.js similarity index 98% rename from frontend/test/metabase/scenarios/models/helpers/e2e-models-helpers.js rename to e2e/test/scenarios/models/helpers/e2e-models-helpers.js index 60191042e9dfe..666d899f0ee81 100644 --- a/frontend/test/metabase/scenarios/models/helpers/e2e-models-helpers.js +++ b/e2e/test/scenarios/models/helpers/e2e-models-helpers.js @@ -3,7 +3,7 @@ import { modal, openQuestionActions, interceptIfNotPreviouslyDefined, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; export function assertQuestionIsBasedOnModel({ questionName, diff --git a/frontend/test/metabase/scenarios/models/helpers/e2e-models-metadata-helpers.js b/e2e/test/scenarios/models/helpers/e2e-models-metadata-helpers.js similarity index 95% rename from frontend/test/metabase/scenarios/models/helpers/e2e-models-metadata-helpers.js rename to e2e/test/scenarios/models/helpers/e2e-models-metadata-helpers.js index 6e68cb2fc7bcf..295e489192b52 100644 --- a/frontend/test/metabase/scenarios/models/helpers/e2e-models-metadata-helpers.js +++ b/e2e/test/scenarios/models/helpers/e2e-models-metadata-helpers.js @@ -1,4 +1,4 @@ -import { popover } from "__support__/e2e/helpers"; +import { popover } from "e2e/support/helpers"; export function openColumnOptions(column) { cy.findByText(column).click(); diff --git a/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js b/e2e/test/scenarios/models/model-actions.cy.spec.js similarity index 58% rename from frontend/test/metabase/scenarios/models/model-actions.cy.spec.js rename to e2e/test/scenarios/models/model-actions.cy.spec.js index 9f8b033773b01..65720f531880d 100644 --- a/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js +++ b/e2e/test/scenarios/models/model-actions.cy.spec.js @@ -1,16 +1,30 @@ +import { assocIn } from "icepick"; import { - enableActionsForDB, + setActionsEnabledForDB, modal, popover, restore, fillActionQuery, createAction, -} from "__support__/e2e/helpers"; + navigationSidebar, + openNavigationSidebar, + resetTestTable, + resyncDatabase, + createModelFromTableName, + queryWritableDB, +} from "e2e/support/helpers"; + +import { + SAMPLE_DB_ID, + USER_GROUPS, + WRITABLE_DB_ID, +} from "e2e/support/cypress_data"; import { createMockActionParameter } from "metabase-types/api/mocks"; const PG_DB_ID = 2; const PG_ORDERS_TABLE_ID = 9; +const WRITABLE_TEST_TABLE = "scoreboard_actions"; const SAMPLE_ORDERS_MODEL = { name: "Order", @@ -65,6 +79,12 @@ const SAMPLE_QUERY_ACTION = { }, }; +const SAMPLE_WRITABLE_QUERY_ACTION = assocIn( + SAMPLE_QUERY_ACTION, + ["dataset_query", "native", "query"], + `UPDATE ${WRITABLE_TEST_TABLE} SET score = 22 WHERE id = {{ ${TEST_TEMPLATE_TAG.name} }}`, +); + describe( "scenarios > models > actions", { tags: ["@external", "@actions"] }, @@ -72,7 +92,7 @@ describe( beforeEach(() => { restore("postgres-12"); cy.signInAsAdmin(); - enableActionsForDB(PG_DB_ID); + setActionsEnabledForDB(PG_DB_ID); cy.createQuestion(SAMPLE_ORDERS_MODEL, { wrapId: true, @@ -83,7 +103,7 @@ describe( cy.intercept("PUT", "/api/action/*").as("updateAction"); }); - it("should allow to view, create, edit, and archive model actions", () => { + it("should allow CRUD operations on model actions", () => { cy.get("@modelId").then(id => { cy.visit(`/model/${id}/detail`); cy.wait("@getModel"); @@ -93,14 +113,16 @@ describe( cy.findByRole("button", { name: /Create basic actions/i }).click(); cy.findByLabelText("Action list").within(() => { - cy.findByText("Create").should("be.visible"); - cy.findByText("Update").should("be.visible"); - cy.findByText("Delete").should("be.visible"); + cy.get("li").eq(0).findByText("Create").should("be.visible"); + cy.get("li").eq(1).findByText("Update").should("be.visible"); + cy.get("li").eq(2).findByText("Delete").should("be.visible"); }); cy.findByRole("link", { name: "New action" }).click(); fillActionQuery("DELETE FROM orders WHERE id = {{ id }}"); - fieldSettings().findByText("Number").click(); + cy.findByRole("radiogroup", { name: "Field type" }) + .findByText("Number") + .click(); cy.findByRole("button", { name: "Save" }).click(); modal().within(() => { cy.findByLabelText("Name").type("Delete Order"); @@ -112,8 +134,7 @@ describe( openActionEditorFor("Delete Order"); fillActionQuery(" AND status = 'pending'"); - fieldSettings() - .findByRole("radiogroup", { name: "Field type" }) + cy.findByRole("radiogroup", { name: "Field type" }) .findByLabelText("Number") .should("be.checked"); cy.findByRole("button", { name: "Update" }).click(); @@ -144,6 +165,24 @@ describe( cy.findByText("Create").should("not.exist"); cy.findByText("Update").should("not.exist"); cy.findByText("Delete").should("not.exist"); + + openNavigationSidebar(); + navigationSidebar().within(() => { + cy.icon("ellipsis").click(); + }); + popover().findByText("View archive").click(); + + getArchiveListItem("Delete Order").within(() => { + cy.icon("unarchive").click({ force: true }); + }); + cy.findByText("Delete Order").should("not.exist"); + cy.findByRole("button", { name: "Undo" }).click(); + cy.findByText("Delete Order").should("be.visible"); + getArchiveListItem("Delete Order").within(() => { + cy.icon("trash").click({ force: true }); + }); + cy.findByTestId("Delete Order").should("not.exist"); + cy.findByRole("button", { name: "Undo" }).should("not.exist"); }); it("should allow to create an action with the New button", () => { @@ -153,12 +192,6 @@ describe( cy.findByText("New").click(); popover().findByText("Action").click(); - cy.findByText("Select a database").click(); - popover().within(() => { - cy.findByText("Sample Database").should("not.exist"); - cy.findByText("QA Postgres12").click(); - }); - fillActionQuery(QUERY); cy.findByText(/New Action/) .clear() @@ -177,12 +210,125 @@ describe( cy.findByText(QUERY).should("be.visible"); }); - it("should allow to execute actions from the model page", () => { + it("should respect permissions", () => { + // Enabling actions for sample database as well + // to test database picker behavior in the action editor + setActionsEnabledForDB(SAMPLE_DB_ID); + + cy.updatePermissionsGraph({ + [USER_GROUPS.ALL_USERS_GROUP]: { + [PG_DB_ID]: { data: { schemas: "none", native: "none" } }, + }, + [USER_GROUPS.DATA_GROUP]: { + [PG_DB_ID]: { data: { schemas: "all", native: "write" } }, + }, + }); + cy.get("@modelId").then(modelId => { - createAction({ + cy.request("POST", "/api/action", { ...SAMPLE_QUERY_ACTION, model_id: modelId, }); + cy.signIn("readonly"); + cy.visit(`/model/${modelId}/detail/actions`); + cy.wait("@getModel"); + }); + + openActionMenuFor(SAMPLE_QUERY_ACTION.name); + popover().within(() => { + cy.findByText("Archive").should("not.exist"); + cy.findByText("View").click(); + }); + + cy.findByRole("dialog").within(() => { + cy.findByDisplayValue(SAMPLE_QUERY_ACTION.name).should("be.disabled"); + + cy.findByText("Sample Database").should("not.exist"); + cy.findByText("QA Postgres12").should("not.exist"); + + cy.button("Save").should("not.exist"); + cy.button("Update").should("not.exist"); + + assertQueryEditorDisabled(); + + cy.findByRole("form").within(() => { + cy.icon("gear").should("not.exist"); + }); + + cy.findByLabelText("Action settings").click(); + cy.findByLabelText("Success message").should("be.disabled"); + }); + + cy.signIn("normal"); + cy.reload(); + + // Check can pick between all databases + cy.findByRole("dialog").findByText("QA Postgres12").click(); + popover().within(() => { + cy.findByText("Sample Database").should("be.visible"); + cy.findByText("QA Postgres12").should("be.visible"); + }); + + cy.signInAsAdmin(); + setActionsEnabledForDB(SAMPLE_DB_ID, false); + cy.signIn("normal"); + cy.reload(); + + // Check can only see the action database + cy.findByRole("dialog").findByText("QA Postgres12").click(); + cy.findByText("Sample Database").should("not.exist"); + }); + + it("should display parameters for variable template tags only", () => { + cy.visit("/"); + cy.findByText("New").click(); + popover().findByText("Action").click(); + + fillActionQuery("{{#1-orders-model}}"); + cy.findByLabelText("#1-orders-model").should("not.exist"); + + fillActionQuery("{{snippet:101}}"); + cy.findByLabelText("#1-orders-model").should("not.exist"); + cy.findByLabelText("101").should("not.exist"); + + fillActionQuery("{{id}}"); + cy.findByLabelText("#1-orders-model").should("not.exist"); + cy.findByLabelText("101").should("not.exist"); + cy.findByLabelText("Id").should("be.visible"); + }); + }, +); + +["postgres", "mysql"].forEach(dialect => { + describe(`Write actions on model detail page (${dialect})`, () => { + beforeEach(() => { + cy.intercept("GET", "/api/card/*").as("getModel"); + + resetTestTable({ type: dialect, table: WRITABLE_TEST_TABLE }); + restore(`${dialect}-writable`); + cy.signInAsAdmin(); + resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: WRITABLE_TEST_TABLE }); + + createModelFromTableName({ + tableName: WRITABLE_TEST_TABLE, + idAlias: "writableModelId", + }); + }); + + it("should allow action execution from the model detail page", () => { + queryWritableDB( + `SELECT * FROM ${WRITABLE_TEST_TABLE} WHERE id = 1`, + dialect, + ).then(result => { + const row = result.rows[0]; + expect(row.score).to.equal(0); + }); + + cy.get("@writableModelId").then(modelId => { + createAction({ + ...SAMPLE_WRITABLE_QUERY_ACTION, + model_id: modelId, + }); cy.visit(`/model/${modelId}/detail/actions`); cy.wait("@getModel"); }); @@ -191,20 +337,29 @@ describe( modal().within(() => { cy.findByLabelText(TEST_PARAMETER.name).type("1"); - cy.button("Run").click(); + cy.button(SAMPLE_QUERY_ACTION.name).click(); }); cy.findByText(`${SAMPLE_QUERY_ACTION.name} ran successfully`).should( "be.visible", ); + + queryWritableDB( + `SELECT * FROM ${WRITABLE_TEST_TABLE} WHERE id = 1`, + dialect, + ).then(result => { + const row = result.rows[0]; + + expect(row.score).to.equal(22); + }); }); - it("should allow to make actions public and execute them", () => { - const IMPLICIT_ACTION_NAME = "Update order"; + it("should allow public sharing of actions and execution of public actions", () => { + const IMPLICIT_ACTION_NAME = "Update"; - cy.get("@modelId").then(modelId => { + cy.get("@writableModelId").then(modelId => { createAction({ - ...SAMPLE_QUERY_ACTION, + ...SAMPLE_WRITABLE_QUERY_ACTION, model_id: modelId, }); createAction({ @@ -217,7 +372,7 @@ describe( cy.wait("@getModel"); }); - enableSharingFor(SAMPLE_QUERY_ACTION.name, { + enableSharingFor(SAMPLE_WRITABLE_QUERY_ACTION.name, { publicUrlAlias: "queryActionPublicUrl", }); enableSharingFor(IMPLICIT_ACTION_NAME, { @@ -229,31 +384,53 @@ describe( cy.get("@queryActionPublicUrl").then(url => { cy.visit(url); cy.findByLabelText(TEST_PARAMETER.name).type("1"); - cy.findByRole("button", { name: "Submit" }).click(); - cy.findByText(`${SAMPLE_QUERY_ACTION.name} ran successfully`).should( - "be.visible", - ); + cy.button(SAMPLE_QUERY_ACTION.name).click(); + cy.findByText( + `${SAMPLE_WRITABLE_QUERY_ACTION.name} ran successfully`, + ).should("be.visible"); cy.findByRole("form").should("not.exist"); - cy.findByRole("button", { name: "Submit" }).should("not.exist"); + cy.button(SAMPLE_QUERY_ACTION.name).should("not.exist"); + + queryWritableDB( + `SELECT * FROM ${WRITABLE_TEST_TABLE} WHERE id = 1`, + dialect, + ).then(result => { + const row = result.rows[0]; + + expect(row.score).to.equal(22); + }); }); cy.get("@implicitActionPublicUrl").then(url => { cy.visit(url); - // Order 1 has quantity 2 by default, so we're not actually mutating data - cy.findByLabelText("Id").type("1"); - cy.findByLabelText(/quantity/i).type("2"); + // team 2 has 10 points, let's give them more + cy.findByLabelText("Id").type("2"); + cy.findByLabelText(/score/i).type("16"); + cy.findByLabelText(/team name/i).type("Bouncy Bears"); - cy.findByRole("button", { name: "Submit" }).click(); + cy.button(IMPLICIT_ACTION_NAME).click(); cy.findByText(`${IMPLICIT_ACTION_NAME} ran successfully`).should( "be.visible", ); cy.findByRole("form").should("not.exist"); - cy.findByRole("button", { name: "Submit" }).should("not.exist"); + cy.button(IMPLICIT_ACTION_NAME).should("not.exist"); + + queryWritableDB( + `SELECT * FROM ${WRITABLE_TEST_TABLE} WHERE id = 2`, + dialect, + ).then(result => { + const row = result.rows[0]; + + expect(row.score).to.equal(16); + expect(row.team_name).to.equal("Bouncy Bears"); + // should not mutate form fields that we don't touch + expect(row.status).to.not.be.a("null"); + }); }); cy.signInAsAdmin(); - cy.get("@modelId").then(modelId => { + cy.get("@writableModelId").then(modelId => { cy.visit(`/model/${modelId}/detail/actions`); cy.wait("@getModel"); }); @@ -261,56 +438,22 @@ describe( disableSharingFor(SAMPLE_QUERY_ACTION.name); disableSharingFor(IMPLICIT_ACTION_NAME); + cy.signOut(); + cy.get("@queryActionPublicUrl").then(url => { cy.visit(url); cy.findByRole("form").should("not.exist"); - cy.findByRole("button", { name: "Submit" }).should("not.exist"); - cy.findByText("An error occurred.").should("be.visible"); + cy.button(SAMPLE_QUERY_ACTION.name).should("not.exist"); }); cy.get("@implicitActionPublicUrl").then(url => { cy.visit(url); cy.findByRole("form").should("not.exist"); - cy.findByRole("button", { name: "Submit" }).should("not.exist"); - cy.findByText("An error occurred.").should("be.visible"); + cy.button(SAMPLE_QUERY_ACTION.name).should("not.exist"); }); }); - - it("should respect permissions", () => { - cy.get("@modelId").then(modelId => { - cy.request("POST", "/api/action", { - ...SAMPLE_QUERY_ACTION, - model_id: modelId, - }); - cy.signIn("readonly"); - cy.visit(`/model/${modelId}/detail/actions`); - cy.wait("@getModel"); - }); - - openActionMenuFor(SAMPLE_QUERY_ACTION.name); - popover().within(() => { - cy.findByText("Archive").should("not.exist"); - cy.findByText("View").click(); - }); - - cy.findByRole("dialog").within(() => { - cy.findByDisplayValue(SAMPLE_QUERY_ACTION.name).should("be.disabled"); - - cy.button("Save").should("not.exist"); - cy.button("Update").should("not.exist"); - - assertQueryEditorDisabled(); - - cy.findByRole("form").within(() => { - cy.icon("gear").should("not.exist"); - }); - - cy.findByLabelText("Action settings").click(); - cy.findByLabelText("Success message").should("be.disabled"); - }); - }); - }, -); + }); +}); function runActionFor(actionName) { cy.findByRole("listitem", { name: actionName }).within(() => { @@ -338,11 +481,6 @@ function assertQueryEditorDisabled() { cy.findByText("QWERTY").should("not.exist"); } -function fieldSettings() { - cy.findByTestId("action-form-editor").within(() => cy.icon("gear").click()); - return popover(); -} - function enableSharingFor(actionName, { publicUrlAlias }) { openActionEditorFor(actionName); @@ -372,3 +510,7 @@ function disableSharingFor(actionName) { cy.button("Cancel").click(); }); } + +function getArchiveListItem(itemName) { + return cy.findByTestId(`archive-item-${itemName}`); +} diff --git a/frontend/test/metabase/scenarios/models/models-metadata.cy.spec.js b/e2e/test/scenarios/models/models-metadata.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/models/models-metadata.cy.spec.js rename to e2e/test/scenarios/models/models-metadata.cy.spec.js index 8b40e3361bc4a..53d94eb246564 100644 --- a/frontend/test/metabase/scenarios/models/models-metadata.cy.spec.js +++ b/e2e/test/scenarios/models/models-metadata.cy.spec.js @@ -6,8 +6,8 @@ import { popover, openQuestionActions, questionInfoButton, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { startQuestionFromModel } from "./helpers/e2e-models-helpers"; import { openColumnOptions, @@ -152,6 +152,28 @@ describe("scenarios > models metadata", () => { cy.findByText("Pre-tax ($)"); }); + it("should allow setting column relations (metabase#29318)", () => { + cy.createNativeQuestion( + { + name: "Native Model", + dataset: true, + native: { + query: "SELECT * FROM ORDERS", + }, + }, + { visitQuestion: true }, + ); + openQuestionActions(); + cy.findByText("Edit metadata").click(); + openColumnOptions("USER_ID"); + setColumnType("No special type", "Foreign Key"); + cy.findByText("Select a target").click(); + cy.findByText("People → ID").click(); + cy.button("Save changes").click(); + // TODO: Not much to do with it at the moment beyond saving it. + // Check that the relation is automatically suggested in the notebook once it is implemented. + }); + it("should keep metadata in sync with the query", () => { cy.createNativeQuestion( { diff --git a/frontend/test/metabase/scenarios/models/models-query-editor.cy.spec.js b/e2e/test/scenarios/models/models-query-editor.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/models/models-query-editor.cy.spec.js rename to e2e/test/scenarios/models/models-query-editor.cy.spec.js index d38aa043fa566..e77a177559486 100644 --- a/frontend/test/metabase/scenarios/models/models-query-editor.cy.spec.js +++ b/e2e/test/scenarios/models/models-query-editor.cy.spec.js @@ -4,7 +4,7 @@ import { summarize, popover, openQuestionActions, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import { selectFromDropdown } from "./helpers/e2e-models-helpers"; diff --git a/frontend/test/metabase/scenarios/models/models-revision-history.cy.spec.js b/e2e/test/scenarios/models/models-revision-history.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/models/models-revision-history.cy.spec.js rename to e2e/test/scenarios/models/models-revision-history.cy.spec.js index 3e422b8bd941f..bb66613f8cd45 100644 --- a/frontend/test/metabase/scenarios/models/models-revision-history.cy.spec.js +++ b/e2e/test/scenarios/models/models-revision-history.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, questionInfoButton } from "__support__/e2e/helpers"; +import { restore, questionInfoButton } from "e2e/support/helpers"; describe("scenarios > models > revision history", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/models/models-with-aggregation-and-breakout.cy.spec.js b/e2e/test/scenarios/models/models-with-aggregation-and-breakout.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/models/models-with-aggregation-and-breakout.cy.spec.js rename to e2e/test/scenarios/models/models-with-aggregation-and-breakout.cy.spec.js index d98eca229d59b..6d62ba460b088 100644 --- a/frontend/test/metabase/scenarios/models/models-with-aggregation-and-breakout.cy.spec.js +++ b/e2e/test/scenarios/models/models-with-aggregation-and-breakout.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { turnIntoModel } from "./helpers/e2e-models-helpers"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/models/models.cy.spec.js b/e2e/test/scenarios/models/models.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/models/models.cy.spec.js rename to e2e/test/scenarios/models/models.cy.spec.js index 4b93504e349d2..b9e65ac727f2f 100644 --- a/frontend/test/metabase/scenarios/models/models.cy.spec.js +++ b/e2e/test/scenarios/models/models.cy.spec.js @@ -16,11 +16,11 @@ import { closeQuestionActions, visitCollection, undo, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { questionInfoButton } from "../../../__support__/e2e/helpers/e2e-ui-elements-helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { questionInfoButton } from "e2e/support/helpers/e2e-ui-elements-helpers"; import { turnIntoModel, @@ -522,7 +522,7 @@ describe("scenarios > models", () => { }); cy.get(".NativeQueryEditor .Icon-play").click(); cy.wait("@query"); - cy.get(".TableInteractive").within(() => { + cy.findByTestId("TableInteractive-root").within(() => { cy.findByText("USER_ID"); cy.findByText("PRODUCT_ID"); cy.findByText("TAX"); diff --git a/frontend/test/metabase/scenarios/models/reproductions/19180-native-model-results-disappear.cy.spec.js b/e2e/test/scenarios/models/reproductions/19180-native-model-results-disappear.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/models/reproductions/19180-native-model-results-disappear.cy.spec.js rename to e2e/test/scenarios/models/reproductions/19180-native-model-results-disappear.cy.spec.js index 0fd990dce80ec..3fb0e8e509a33 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/19180-native-model-results-disappear.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/19180-native-model-results-disappear.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; const QUESTION = { native: { query: "select * from products" }, diff --git a/frontend/test/metabase/scenarios/models/reproductions/19737-data-picker-not-showing-moved-model.cy.spec.js b/e2e/test/scenarios/models/reproductions/19737-data-picker-not-showing-moved-model.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/models/reproductions/19737-data-picker-not-showing-moved-model.cy.spec.js rename to e2e/test/scenarios/models/reproductions/19737-data-picker-not-showing-moved-model.cy.spec.js index bb1b2ab29f08d..ab7e20bd4d2ed 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/19737-data-picker-not-showing-moved-model.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/19737-data-picker-not-showing-moved-model.cy.spec.js @@ -3,7 +3,7 @@ import { modal, popover, navigationSidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const modelName = "Orders Model"; diff --git a/frontend/test/metabase/scenarios/models/reproductions/19776-data-picker-not-displayed-after-archiving-model.cy.spec.js b/e2e/test/scenarios/models/reproductions/19776-data-picker-not-displayed-after-archiving-model.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/models/reproductions/19776-data-picker-not-displayed-after-archiving-model.cy.spec.js rename to e2e/test/scenarios/models/reproductions/19776-data-picker-not-displayed-after-archiving-model.cy.spec.js index 6120dd39c72ce..c064ba538a4fc 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/19776-data-picker-not-displayed-after-archiving-model.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/19776-data-picker-not-displayed-after-archiving-model.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover } from "__support__/e2e/helpers"; +import { restore, popover } from "e2e/support/helpers"; const modelName = "Orders Model"; diff --git a/frontend/test/metabase/scenarios/models/reproductions/20042-nodata-user-blank-screen.cy.spec.js b/e2e/test/scenarios/models/reproductions/20042-nodata-user-blank-screen.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/models/reproductions/20042-nodata-user-blank-screen.cy.spec.js rename to e2e/test/scenarios/models/reproductions/20042-nodata-user-blank-screen.cy.spec.js index f8a526eab3e11..3b46eb66139ab 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/20042-nodata-user-blank-screen.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/20042-nodata-user-blank-screen.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("issue 20042", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/models/reproductions/20045-rerun-model-adds-hash.cy.spec.js b/e2e/test/scenarios/models/reproductions/20045-rerun-model-adds-hash.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/models/reproductions/20045-rerun-model-adds-hash.cy.spec.js rename to e2e/test/scenarios/models/reproductions/20045-rerun-model-adds-hash.cy.spec.js index 6f7011e47d4aa..f71801a0082c9 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/20045-rerun-model-adds-hash.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/20045-rerun-model-adds-hash.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("issue 20045", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/models/reproductions/20517-edit-metadata-empty-description.cy.spec.js b/e2e/test/scenarios/models/reproductions/20517-edit-metadata-empty-description.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/models/reproductions/20517-edit-metadata-empty-description.cy.spec.js rename to e2e/test/scenarios/models/reproductions/20517-edit-metadata-empty-description.cy.spec.js index 8793bc629ecde..1974a2fff8c33 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/20517-edit-metadata-empty-description.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/20517-edit-metadata-empty-description.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("issue 20517", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/models/reproductions/20624-model-metadata-should-override-column-settings.cy.spec.js b/e2e/test/scenarios/models/reproductions/20624-model-metadata-should-override-column-settings.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/models/reproductions/20624-model-metadata-should-override-column-settings.cy.spec.js rename to e2e/test/scenarios/models/reproductions/20624-model-metadata-should-override-column-settings.cy.spec.js index 78d35378678da..03b8ccfe83dd8 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/20624-model-metadata-should-override-column-settings.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/20624-model-metadata-should-override-column-settings.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; import { openDetailsSidebar } from "../helpers/e2e-models-helpers"; const renamedColumn = "TITLE renamed"; diff --git a/frontend/test/metabase/scenarios/models/reproductions/20963-can-not-convert-question-with-snippets-to-model.cy.spec.js b/e2e/test/scenarios/models/reproductions/20963-can-not-convert-question-with-snippets-to-model.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/models/reproductions/20963-can-not-convert-question-with-snippets-to-model.cy.spec.js rename to e2e/test/scenarios/models/reproductions/20963-can-not-convert-question-with-snippets-to-model.cy.spec.js index 763e7758d46d3..b1fc8e65e21bd 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/20963-can-not-convert-question-with-snippets-to-model.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/20963-can-not-convert-question-with-snippets-to-model.cy.spec.js @@ -4,7 +4,7 @@ import { openNativeEditor, popover, openQuestionActions, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const snippetName = `string 'test'`; const questionName = "Converting questions with snippets to models"; diff --git a/frontend/test/metabase/scenarios/models/reproductions/22517-add-remove-column-drops-metadata.cy.spec.js b/e2e/test/scenarios/models/reproductions/22517-add-remove-column-drops-metadata.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/models/reproductions/22517-add-remove-column-drops-metadata.cy.spec.js rename to e2e/test/scenarios/models/reproductions/22517-add-remove-column-drops-metadata.cy.spec.js index 8921ad6523dce..04a7876e413de 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/22517-add-remove-column-drops-metadata.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/22517-add-remove-column-drops-metadata.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openQuestionActions } from "__support__/e2e/helpers"; +import { restore, openQuestionActions } from "e2e/support/helpers"; describe("issue 22517", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/models/reproductions/22518.cy.spec.js b/e2e/test/scenarios/models/reproductions/22518.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/models/reproductions/22518.cy.spec.js rename to e2e/test/scenarios/models/reproductions/22518.cy.spec.js index 1aa7e0e296ad7..762ff01ca1703 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/22518.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/22518.cy.spec.js @@ -3,7 +3,7 @@ import { openQuestionActions, summarize, sidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 22518", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/models/reproductions/22519-casting-fails-query.cy.spec.js b/e2e/test/scenarios/models/reproductions/22519-casting-fails-query.cy.spec.js similarity index 83% rename from frontend/test/metabase/scenarios/models/reproductions/22519-casting-fails-query.cy.spec.js rename to e2e/test/scenarios/models/reproductions/22519-casting-fails-query.cy.spec.js index 89b88bb191142..c8a37ecb7a565 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/22519-casting-fails-query.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/22519-casting-fails-query.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { turnIntoModel } from "../helpers/e2e-models-helpers"; diff --git a/frontend/test/metabase/scenarios/models/reproductions/22715-remapped-values-override-column-identifier.cy.spec.js b/e2e/test/scenarios/models/reproductions/22715-remapped-values-override-column-identifier.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/models/reproductions/22715-remapped-values-override-column-identifier.cy.spec.js rename to e2e/test/scenarios/models/reproductions/22715-remapped-values-override-column-identifier.cy.spec.js index f0eac80afc578..6a1ae671cfe05 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/22715-remapped-values-override-column-identifier.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/22715-remapped-values-override-column-identifier.cy.spec.js @@ -1,9 +1,4 @@ -import { - restore, - visitQuestion, - popover, - filter, -} from "__support__/e2e/helpers"; +import { restore, visitQuestion, popover, filter } from "e2e/support/helpers"; describe("filtering based on the remapped column name should result in a correct query (metabase#22715)", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/models/reproductions/23024-cannot-apply-dash-filter-native-model.cy.spec.js b/e2e/test/scenarios/models/reproductions/23024-cannot-apply-dash-filter-native-model.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/models/reproductions/23024-cannot-apply-dash-filter-native-model.cy.spec.js rename to e2e/test/scenarios/models/reproductions/23024-cannot-apply-dash-filter-native-model.cy.spec.js index 1aae4f6c70d87..97f295dc53453 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/23024-cannot-apply-dash-filter-native-model.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/23024-cannot-apply-dash-filter-native-model.cy.spec.js @@ -3,8 +3,8 @@ import { popover, restore, visitDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { setModelMetadata } from "../helpers/e2e-models-metadata-helpers"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/models/reproductions/23421-visualization-settins-breaks-ui.cy.spec.js b/e2e/test/scenarios/models/reproductions/23421-visualization-settins-breaks-ui.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/models/reproductions/23421-visualization-settins-breaks-ui.cy.spec.js rename to e2e/test/scenarios/models/reproductions/23421-visualization-settins-breaks-ui.cy.spec.js index d6babb05f03d3..5f0ddc9f99827 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/23421-visualization-settins-breaks-ui.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/23421-visualization-settins-breaks-ui.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openQuestionActions } from "__support__/e2e/helpers"; +import { restore, openQuestionActions } from "e2e/support/helpers"; const query = 'SELECT 1 AS "id", current_timestamp::timestamp AS "created_at"'; diff --git a/frontend/test/metabase/scenarios/models/reproductions/23449-remapped-custom-value.cy.spec.js b/e2e/test/scenarios/models/reproductions/23449-remapped-custom-value.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/models/reproductions/23449-remapped-custom-value.cy.spec.js rename to e2e/test/scenarios/models/reproductions/23449-remapped-custom-value.cy.spec.js index 410ca85db15b4..78531a7b0af59 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/23449-remapped-custom-value.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/23449-remapped-custom-value.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, openQuestionActions } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, openQuestionActions } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/models/reproductions/25537-model-picker-locale.cy.spec.js b/e2e/test/scenarios/models/reproductions/25537-model-picker-locale.cy.spec.js similarity index 84% rename from frontend/test/metabase/scenarios/models/reproductions/25537-model-picker-locale.cy.spec.js rename to e2e/test/scenarios/models/reproductions/25537-model-picker-locale.cy.spec.js index b10d2b8cfca88..0e5933beaf6f9 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/25537-model-picker-locale.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/25537-model-picker-locale.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, startNewQuestion } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, startNewQuestion } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/models/reproductions/26091-new-models-picker.cy.spec.js b/e2e/test/scenarios/models/reproductions/26091-new-models-picker.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/models/reproductions/26091-new-models-picker.cy.spec.js rename to e2e/test/scenarios/models/reproductions/26091-new-models-picker.cy.spec.js index fab73f694500d..12ece5a2f1fdb 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/26091-new-models-picker.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/26091-new-models-picker.cy.spec.js @@ -1,5 +1,5 @@ -import { modal, popover, restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { modal, popover, restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { turnIntoModel } from "../helpers/e2e-models-helpers"; const { PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/models/reproductions/28193-cannot-use-custom-column.cy.spec.js b/e2e/test/scenarios/models/reproductions/28193-cannot-use-custom-column.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/models/reproductions/28193-cannot-use-custom-column.cy.spec.js rename to e2e/test/scenarios/models/reproductions/28193-cannot-use-custom-column.cy.spec.js index 20665060ecb6b..753c8250faa0f 100644 --- a/frontend/test/metabase/scenarios/models/reproductions/28193-cannot-use-custom-column.cy.spec.js +++ b/e2e/test/scenarios/models/reproductions/28193-cannot-use-custom-column.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, enterCustomColumnDetails } from "__support__/e2e/helpers"; +import { restore, enterCustomColumnDetails } from "e2e/support/helpers"; const ccName = "CTax"; diff --git a/e2e/test/scenarios/models/reproductions/28971-filters-model-new-model.cy.spec.js b/e2e/test/scenarios/models/reproductions/28971-filters-model-new-model.cy.spec.js new file mode 100644 index 0000000000000..a04553a4a93d2 --- /dev/null +++ b/e2e/test/scenarios/models/reproductions/28971-filters-model-new-model.cy.spec.js @@ -0,0 +1,38 @@ +import { + filter, + filterField, + filterFieldPopover, + modal, + popover, + restore, +} from "e2e/support/helpers"; + +describe("issue 28971", () => { + beforeEach(() => { + restore(); + cy.signInAsNormalUser(); + cy.intercept("POST", "/api/card").as("createCard"); + cy.intercept("POST", "/api/dataset").as("dataset"); + }); + + it("should be able to filter a newly created model (metabase#28971)", () => { + cy.visit("/"); + + cy.findByText("New").click(); + popover().within(() => cy.findByText("Model").click()); + cy.findByText("Use the notebook editor").click(); + popover().within(() => cy.findByText("Sample Database").click()); + popover().within(() => cy.findByText("Orders").click()); + cy.button("Save").click(); + modal().within(() => cy.button("Save").click()); + cy.wait("@createCard"); + + filter(); + filterField("Quantity", { operator: "equal to" }); + filterFieldPopover("Quantity").within(() => cy.findByText("20").click()); + cy.button("Apply Filters").click(); + cy.wait("@dataset"); + cy.findByText("Quantity is equal to 20").should("exist"); + cy.findByText("Showing 4 rows").should("exist"); + }); +}); diff --git a/e2e/test/scenarios/models/reproductions/29378-actions-search-crash.cy.spec.js b/e2e/test/scenarios/models/reproductions/29378-actions-search-crash.cy.spec.js new file mode 100644 index 0000000000000..b2f5c7aed1c80 --- /dev/null +++ b/e2e/test/scenarios/models/reproductions/29378-actions-search-crash.cy.spec.js @@ -0,0 +1,53 @@ +import { + createAction, + restore, + setActionsEnabledForDB, +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; + +const MODEL_ID = 1; + +const ACTION_DETAILS = { + name: "Update orders quantity", + description: "Set orders quantity to the same value", + type: "query", + model_id: MODEL_ID, + database_id: SAMPLE_DB_ID, + dataset_query: { + database: SAMPLE_DB_ID, + native: { + query: "UPDATE orders SET quantity = quantity", + }, + type: "native", + }, + parameters: [], + visualization_settings: { + type: "button", + }, +}; + +describe("issue 29378", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setActionsEnabledForDB(SAMPLE_DB_ID); + }); + + it("should not crash the model detail page after searching for an action (metabase#29378)", () => { + cy.request("PUT", `/api/card/${MODEL_ID}`, { dataset: true }); + createAction(ACTION_DETAILS); + + cy.visit(`/model/${MODEL_ID}/detail`); + cy.findByRole("tab", { name: "Actions" }).click(); + cy.findByText(ACTION_DETAILS.name).should("be.visible"); + cy.findByText(ACTION_DETAILS.dataset_query.native.query).should("be.visible"); + + cy.findByRole("tab", { name: "Used by" }).click(); + cy.findByPlaceholderText("Search…").type(ACTION_DETAILS.name); + cy.findByText(ACTION_DETAILS.name).should("be.visible"); + + cy.findByRole("tab", { name: "Actions" }).click(); + cy.findByText(ACTION_DETAILS.name).should("be.visible"); + cy.findByText(ACTION_DETAILS.dataset_query.native.query).should("be.visible"); + }); +}); diff --git a/e2e/test/scenarios/models/reproductions/29517-native-remapped-model-drill-through-click-behavior.cy.spec.js b/e2e/test/scenarios/models/reproductions/29517-native-remapped-model-drill-through-click-behavior.cy.spec.js new file mode 100644 index 0000000000000..b37c4cdb2d9b6 --- /dev/null +++ b/e2e/test/scenarios/models/reproductions/29517-native-remapped-model-drill-through-click-behavior.cy.spec.js @@ -0,0 +1,124 @@ +import { + restore, + popover, + visitQuestion, + visitDashboard, +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; + +const questionDetails = { + name: "29517", + dataset: true, + native: { + query: + 'Select Orders."ID" AS "ID",\nOrders."CREATED_AT" AS "CREATED_AT"\nFrom Orders', + "template-tags": {}, + }, +}; + +describe("issue 29517 - nested question based on native model with remapped values", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + cy.createNativeQuestion(questionDetails).then(({ body: { id } }) => { + cy.intercept("GET", `/api/database/${SAMPLE_DB_ID}/schema/PUBLIC`).as( + "schema", + ); + cy.visit(`/model/${id}/metadata`); + cy.wait("@schema"); + + mapModelColumnToDatabase({ table: "Orders", field: "ID" }); + selectModelColumn("CREATED_AT"); + mapModelColumnToDatabase({ table: "Orders", field: "Created At" }); + + cy.intercept("PUT", `/api/card/*`).as("updateModel"); + cy.button("Save changes").click(); + cy.wait("@updateModel"); + + const nestedQuestionDetails = { + query: { + "source-table": `card__${id}`, + aggregation: [["count"]], + breakout: [ + [ + "field", + "CREATED_AT", + { "temporal-unit": "month", "base-type": "type/DateTime" }, + ], + ], + }, + display: "line", + }; + + cy.createQuestionAndDashboard({ + questionDetails: nestedQuestionDetails, + }).then(({ body: card }) => { + const { card_id, dashboard_id } = card; + + cy.editDashboardCard(card, { + visualization_settings: { + click_behavior: { + type: "link", + linkType: "dashboard", + targetId: 1, // Orders in a dashboard + parameterMapping: {}, + }, + }, + }); + + cy.wrap(card_id).as("nestedQuestionId"); + cy.wrap(dashboard_id).as("dashboardId"); + }); + }); + }); + + it("drill-through should work (metabase#29517-1)", () => { + cy.get("@nestedQuestionId").then(id => { + visitQuestion(id); + }); + + cy.intercept("POST", "/api/dataset").as("dataset"); + // We can click on any circle; this index was chosen randomly + cy.get("circle").eq(25).click({ force: true }); + popover() + .findByText(/^View these/) + .click(); + cy.wait("@dataset"); + + cy.findByTestId("qb-filters-panel").should( + "contain", + "Created At is May, 2018", + ); + cy.findByTestId("view-footer").should("contain", "Showing 520 rows"); + }); + + it("click behavoir to custom destination should work (metabase#29517-2)", () => { + cy.get("@dashboardId").then(id => { + visitDashboard(id); + }); + + cy.intercept("GET", "/api/dashboard/1").as("loadTargetDashboard"); + cy.get("circle").eq(25).click({ force: true }); + cy.wait("@loadTargetDashboard"); + + cy.location("pathname").should("eq", "/dashboard/1"); + cy.get(".cellData").contains("37.65"); + }); +}); + +function mapModelColumnToDatabase({ table, field }) { + cy.findByText("Database column this maps to") + .closest("#formField-id") + .findByTestId("select-button") + .click(); + popover().findByRole("option", { name: table }).click(); + popover().findByRole("option", { name: field }).click(); + cy.contains(`${table} → ${field}`).should("be.visible"); + cy.findByDisplayValue(field); + cy.findByLabelText("Description").should("not.be.empty"); +} + +function selectModelColumn(column) { + cy.findAllByTestId("header-cell").contains(column).click(); +} diff --git a/e2e/test/scenarios/models/reproductions/29951-model-editor-results-metadata.cy.spec.js b/e2e/test/scenarios/models/reproductions/29951-model-editor-results-metadata.cy.spec.js new file mode 100644 index 0000000000000..065b543eee18e --- /dev/null +++ b/e2e/test/scenarios/models/reproductions/29951-model-editor-results-metadata.cy.spec.js @@ -0,0 +1,61 @@ +import { + getNotebookStep, + openQuestionActions, + restore, +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; + +const questionDetails = { + name: "29951", + query: { + "source-table": ORDERS_ID, + expressions: { + CC1: ["+", ["field", ORDERS.TOTAL], 1], + CC2: ["+", ["field", ORDERS.TOTAL], 1], + }, + }, + dataset: true, +}; + +describe("issue 29951", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + cy.intercept("POST", "/api/dataset").as("dataset"); + cy.intercept("PUT", "/api/card/*").as("updateCard"); + }); + + it("should allow to run the model query after changing custom columns (metabase#29951)", () => { + cy.createQuestion(questionDetails, { visitQuestion: true }); + cy.wait("@dataset"); + + openQuestionActions(); + cy.findByText("Edit query definition").click(); + removeExpression("CC2"); + cy.findByRole("button", { name: "Save changes" }).click(); + cy.wait("@updateCard"); + cy.wait("@dataset"); + + dragColumn(0, 100); + cy.findAllByRole("button", { name: "play icon" }).first().click(); + cy.wait("@dataset"); + cy.findByText("Showing first 2,000 rows").should("be.visible"); + }); +}); + +const removeExpression = name => { + getNotebookStep("expression") + .findByText(name) + .findByLabelText("close icon") + .click(); +}; + +const dragColumn = (index, distance) => { + cy.get(".react-draggable") + .eq(index) + .trigger("mousedown", 0, 0, { force: true }) + .trigger("mousemove", distance, 0, { force: true }) + .trigger("mouseup", distance, 0, { force: true }); +}; diff --git a/frontend/test/metabase/scenarios/native-filters/helpers/e2e-date-filter-helpers.js b/e2e/test/scenarios/native-filters/helpers/e2e-date-filter-helpers.js similarity index 96% rename from frontend/test/metabase/scenarios/native-filters/helpers/e2e-date-filter-helpers.js rename to e2e/test/scenarios/native-filters/helpers/e2e-date-filter-helpers.js index fe602dc90c12c..84b775c8b2051 100644 --- a/frontend/test/metabase/scenarios/native-filters/helpers/e2e-date-filter-helpers.js +++ b/e2e/test/scenarios/native-filters/helpers/e2e-date-filter-helpers.js @@ -1,4 +1,4 @@ -import { popover } from "__support__/e2e/helpers"; +import { popover } from "e2e/support/helpers"; const currentYearString = new Date().getFullYear().toString(); diff --git a/frontend/test/metabase/scenarios/native-filters/helpers/e2e-field-filter-data-objects.js b/e2e/test/scenarios/native-filters/helpers/e2e-field-filter-data-objects.js similarity index 100% rename from frontend/test/metabase/scenarios/native-filters/helpers/e2e-field-filter-data-objects.js rename to e2e/test/scenarios/native-filters/helpers/e2e-field-filter-data-objects.js diff --git a/frontend/test/metabase/scenarios/native-filters/helpers/e2e-field-filter-helpers.js b/e2e/test/scenarios/native-filters/helpers/e2e-field-filter-helpers.js similarity index 97% rename from frontend/test/metabase/scenarios/native-filters/helpers/e2e-field-filter-helpers.js rename to e2e/test/scenarios/native-filters/helpers/e2e-field-filter-helpers.js index 9b7578a0e0951..0adfebd1d5c42 100644 --- a/frontend/test/metabase/scenarios/native-filters/helpers/e2e-field-filter-helpers.js +++ b/e2e/test/scenarios/native-filters/helpers/e2e-field-filter-helpers.js @@ -1,4 +1,4 @@ -import { filterWidget, popover } from "__support__/e2e/helpers"; +import { filterWidget, popover } from "e2e/support/helpers"; // FILTER WIDGET TYPE @@ -33,7 +33,7 @@ export function setWidgetStringFilter(value) { } /** - * Selectes value from the field values list filter widget + * Selects value from the field values list filter widget * * @param {string} value */ diff --git a/frontend/test/metabase/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js b/e2e/test/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js similarity index 97% rename from frontend/test/metabase/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js rename to e2e/test/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js index 6d4a6dab0052a..0f56291b613a9 100644 --- a/frontend/test/metabase/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js +++ b/e2e/test/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js @@ -1,4 +1,4 @@ -import { filterWidget, popover } from "__support__/e2e/helpers"; +import { filterWidget, popover } from "e2e/support/helpers"; // FILTER TYPES diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/11480.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/11480.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/native-filters/reproductions/11480.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/11480.cy.spec.js index 30dd4ce316b90..d10185290d048 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/11480.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/11480.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/11580.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/11580.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/native-filters/reproductions/11580.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/11580.cy.spec.js index 99a52432c7364..0ce2329add46b 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/11580.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/11580.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/12228.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/12228.cy.spec.js similarity index 86% rename from frontend/test/metabase/scenarios/native-filters/reproductions/12228.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/12228.cy.spec.js index 54cefec9d6a81..3d0bd9410c2b0 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/12228.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/12228.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/12581.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/12581.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/native-filters/reproductions/12581.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/12581.cy.spec.js index b65868a182ef8..03e124ca90a87 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/12581.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/12581.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, modal, filterWidget } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, modal, filterWidget } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/13961.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/13961.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/native-filters/reproductions/13961.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/13961.cy.spec.js index d187d97402c7d..892ed67b2fe35 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/13961.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/13961.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/14145.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/14145.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/native-filters/reproductions/14145.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/14145.cy.spec.js index 04b0a34eb7cb0..45cb0c352ed57 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/14145.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/14145.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/14302.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/14302.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/native-filters/reproductions/14302.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/14302.cy.spec.js index da6f9d5e3d727..b603aaf9c06c3 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/14302.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/14302.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; const priceFilter = { id: "39b51ccd-47a7-9df6-a1c5-371918352c79", diff --git a/e2e/test/scenarios/native-filters/reproductions/15163.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/15163.cy.spec.js new file mode 100644 index 0000000000000..df52a0ca843c6 --- /dev/null +++ b/e2e/test/scenarios/native-filters/reproductions/15163.cy.spec.js @@ -0,0 +1,103 @@ +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { USER_GROUPS } from "e2e/support/cypress_data"; + +const { PRODUCTS } = SAMPLE_DATABASE; +const { COLLECTION_GROUP } = USER_GROUPS; + +const nativeFilter = { + id: "dd7f3e66-b659-7d1c-87b3-ab627317581c", + name: "cat", + "display-name": "Cat", + type: "dimension", + dimension: ["field-id", PRODUCTS.CATEGORY], + "widget-type": "category", + default: null, +}; + +const nativeQuery = { + name: "15163", + native: { + query: 'SELECT COUNT(*) FROM "PRODUCTS" WHERE {{cat}}', + "template-tags": { + cat: nativeFilter, + }, + }, +}; + +const dashboardFilter = { + name: "Category", + slug: "category", + id: "fd723065", + type: "category", +}; + +const dashboardDetails = { + parameters: [dashboardFilter], +}; + +["nodata+nosql", "nosql"].forEach(test => { + describe("issue 15163", () => { + beforeEach(() => { + cy.intercept("POST", "/api/card/*/query").as("cardQuery"); + + restore(); + cy.signInAsAdmin(); + + cy.createNativeQuestionAndDashboard({ + questionDetails: nativeQuery, + dashboardDetails, + }).then(({ body: { id, card_id, dashboard_id } }) => { + // Connect filter to the dashboard card + cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { + cards: [ + { + id, + card_id, + row: 0, + col: 0, + size_x: 10, + size_y: 8, + series: [], + visualization_settings: { + "card.title": "New Title", + }, + parameter_mappings: [ + { + parameter_id: dashboardFilter.id, + card_id, + target: ["dimension", ["template-tag", "cat"]], + }, + ], + }, + ], + }); + + if (test === "nosql") { + cy.updatePermissionsGraph({ + [COLLECTION_GROUP]: { + 1: { data: { schemas: "all", native: "none" } }, + }, + }); + } + + cy.signIn("nodata"); + + // Visit dashboard and set the filter through URL + cy.visit(`/dashboard/${dashboard_id}?category=Gizmo`); + }); + }); + + it(`${test.toUpperCase()} version:\n should be able to view SQL question when accessing via dashboard with filters connected to modified card without SQL permissions (metabase#15163)`, () => { + cy.findByText("New Title").click(); + + cy.wait("@cardQuery", { timeout: 5000 }).then(xhr => { + expect(xhr.response.body.error).not.to.exist; + }); + + cy.get(".ace_content").should("not.be.visible"); + cy.get(".cellData").should("contain", "51"); + cy.findByText("Showing 1 row"); + }); + }); +}); diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/15444.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/15444.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/native-filters/reproductions/15444.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/15444.cy.spec.js index ee4e87619d99a..6bc6bd21a9843 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/15444.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/15444.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor, popover } from "__support__/e2e/helpers"; +import { restore, openNativeEditor, popover } from "e2e/support/helpers"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; import * as FieldFilter from "../helpers/e2e-field-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/15460.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/15460.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/native-filters/reproductions/15460.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/15460.cy.spec.js index d34de09d14516..b9958339a9e9f 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/15460.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/15460.cy.spec.js @@ -3,10 +3,10 @@ import { popover, filterWidget, visitQuestionAdhoc, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/15700.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/15700.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/native-filters/reproductions/15700.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/15700.cy.spec.js index bdfdd47927406..3deb2230bb808 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/15700.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/15700.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; import * as FieldFilter from "../helpers/e2e-field-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/15981.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/15981.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/native-filters/reproductions/15981.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/15981.cy.spec.js index 7398180042af7..d07e8dd8c57b9 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/15981.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/15981.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/16739.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/16739.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/native-filters/reproductions/16739.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/16739.cy.spec.js index f7c0342690c7d..303f8979aa838 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/16739.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/16739.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestion } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestion } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/16756.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/16756.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/native-filters/reproductions/16756.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/16756.cy.spec.js index 07d7899b50e8d..393570dc6a41e 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/16756.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/16756.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, filterWidget, popover } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, filterWidget, popover } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { runQuery } from "../helpers/e2e-sql-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/17019.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/17019.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/native-filters/reproductions/17019.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/17019.cy.spec.js index 844a02a55fa41..240bfc5bbe5c3 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/17019.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/17019.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visitQuestion } from "__support__/e2e/helpers"; +import { restore, visitQuestion } from "e2e/support/helpers"; const question = { name: "17019", diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/17490.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/17490.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/native-filters/reproductions/17490.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/17490.cy.spec.js index c1cd3db9c90bd..f78e1b7b86262 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/17490.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/17490.cy.spec.js @@ -1,4 +1,4 @@ -import { openNativeEditor, restore } from "__support__/e2e/helpers"; +import { openNativeEditor, restore } from "e2e/support/helpers"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/21160.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/21160.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/native-filters/reproductions/21160.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/21160.cy.spec.js index bd8f27e870fee..de8fbb29672c7 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/21160.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/21160.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; const filterName = "Number comma"; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/21246.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/21246.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/native-filters/reproductions/21246.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/21246.cy.spec.js index 2362f26894ae4..a8019461e3fe2 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/21246.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/21246.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/27257.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/27257.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/native-filters/reproductions/27257.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/27257.cy.spec.js index 9db64ae74b238..037ffa23dd9c2 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/27257.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/27257.cy.spec.js @@ -3,7 +3,7 @@ import { openNativeEditor, popover, filterWidget, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; diff --git a/e2e/test/scenarios/native-filters/reproductions/29786.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/29786.cy.spec.js new file mode 100644 index 0000000000000..ddd3209b0e781 --- /dev/null +++ b/e2e/test/scenarios/native-filters/reproductions/29786.cy.spec.js @@ -0,0 +1,33 @@ +import { filterWidget, openNativeEditor, restore } from "e2e/support/helpers"; +import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; +import * as FieldFilter from "../helpers/e2e-field-filter-helpers"; + +const SQL_QUERY = "SELECT * FROM PRODUCTS WHERE {{f1}} AND {{f2}}"; + +describe("issue 29786", { tags: "@external" }, () => { + beforeEach(() => { + restore("mysql-8"); + cy.intercept("POST", "/api/dataset").as("dataset"); + cy.signInAsAdmin(); + }); + + it("should allow using field filters with null schema (metabase#29786)", () => { + openNativeEditor({ databaseName: "QA MySQL8" }); + SQLFilter.enterParameterizedQuery(SQL_QUERY); + + cy.findAllByText("Text").first().click(); + SQLFilter.chooseType("Field Filter"); + FieldFilter.mapTo({ table: "Products", field: "Category" }); + cy.findAllByText("Text").last().click(); + SQLFilter.chooseType("Field Filter"); + FieldFilter.mapTo({ table: "Products", field: "Vendor" }); + + filterWidget().first().click(); + FieldFilter.addWidgetStringFilter("Widget"); + filterWidget().last().click(); + FieldFilter.addWidgetStringFilter("Von-Gulgowski"); + + SQLFilter.runQuery(); + cy.findByText("1087115303928").should("be.visible"); + }); +}); diff --git a/frontend/test/metabase/scenarios/native-filters/reproductions/9357.cy.spec.js b/e2e/test/scenarios/native-filters/reproductions/9357.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/native-filters/reproductions/9357.cy.spec.js rename to e2e/test/scenarios/native-filters/reproductions/9357.cy.spec.js index b101e04f85a41..27dc16504d718 100644 --- a/frontend/test/metabase/scenarios/native-filters/reproductions/9357.cy.spec.js +++ b/e2e/test/scenarios/native-filters/reproductions/9357.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import * as SQLFilter from "../helpers/e2e-sql-filter-helpers"; describe("issue 9357", () => { diff --git a/frontend/test/metabase/scenarios/native-filters/sql-field-filter-date.cy.spec.js b/e2e/test/scenarios/native-filters/sql-field-filter-date.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/native-filters/sql-field-filter-date.cy.spec.js rename to e2e/test/scenarios/native-filters/sql-field-filter-date.cy.spec.js index 77c778d9e0ca8..017399b00dc59 100644 --- a/frontend/test/metabase/scenarios/native-filters/sql-field-filter-date.cy.spec.js +++ b/e2e/test/scenarios/native-filters/sql-field-filter-date.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import { DATE_FILTER_SUBTYPES } from "./helpers/e2e-field-filter-data-objects"; diff --git a/frontend/test/metabase/scenarios/native-filters/sql-field-filter-number.cy.spec.js b/e2e/test/scenarios/native-filters/sql-field-filter-number.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/native-filters/sql-field-filter-number.cy.spec.js rename to e2e/test/scenarios/native-filters/sql-field-filter-number.cy.spec.js index 88fd8370efdc1..cb3bb9d4b6fbf 100644 --- a/frontend/test/metabase/scenarios/native-filters/sql-field-filter-number.cy.spec.js +++ b/e2e/test/scenarios/native-filters/sql-field-filter-number.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import { NUMBER_FILTER_SUBTYPES } from "./helpers/e2e-field-filter-data-objects"; diff --git a/frontend/test/metabase/scenarios/native-filters/sql-field-filter-string.cy.spec.js b/e2e/test/scenarios/native-filters/sql-field-filter-string.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/native-filters/sql-field-filter-string.cy.spec.js rename to e2e/test/scenarios/native-filters/sql-field-filter-string.cy.spec.js index a083fb321ca64..7dcec6abf2ba6 100644 --- a/frontend/test/metabase/scenarios/native-filters/sql-field-filter-string.cy.spec.js +++ b/e2e/test/scenarios/native-filters/sql-field-filter-string.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import { STRING_FILTER_SUBTYPES } from "./helpers/e2e-field-filter-data-objects"; diff --git a/frontend/test/metabase/scenarios/native-filters/sql-field-filter.cy.spec.js b/e2e/test/scenarios/native-filters/sql-field-filter.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/native-filters/sql-field-filter.cy.spec.js rename to e2e/test/scenarios/native-filters/sql-field-filter.cy.spec.js index 9fc0509be9d3e..b57fe4941297d 100644 --- a/frontend/test/metabase/scenarios/native-filters/sql-field-filter.cy.spec.js +++ b/e2e/test/scenarios/native-filters/sql-field-filter.cy.spec.js @@ -3,9 +3,9 @@ import { openNativeEditor, filterWidget, popover, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import * as SQLFilter from "./helpers/e2e-sql-filter-helpers"; import * as FieldFilter from "./helpers/e2e-field-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native-filters/sql-filters-source.cy.spec.js b/e2e/test/scenarios/native-filters/sql-filters-source.cy.spec.js similarity index 73% rename from frontend/test/metabase/scenarios/native-filters/sql-filters-source.cy.spec.js rename to e2e/test/scenarios/native-filters/sql-filters-source.cy.spec.js index b2dd32b66e31f..727e9ca6638ba 100644 --- a/frontend/test/metabase/scenarios/native-filters/sql-filters-source.cy.spec.js +++ b/e2e/test/scenarios/native-filters/sql-filters-source.cy.spec.js @@ -11,9 +11,9 @@ import { visitEmbeddedPage, visitPublicQuestion, visitQuestion, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID, USER_GROUPS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID, USER_GROUPS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import * as SQLFilter from "./helpers/e2e-sql-filter-helpers"; import * as FieldFilter from "./helpers/e2e-field-filter-helpers"; @@ -153,7 +153,23 @@ describe("scenarios > filters > sql filters > values source", () => { cy.createQuestion(structuredSourceQuestion).then( ({ body: { id: sourceQuestionId } }) => { cy.createNativeQuestion( - getStructuredTargetQuestion(sourceQuestionId), + getStructuredDimensionTargetQuestion(sourceQuestionId), + ).then(({ body: { id: targetQuestionId } }) => { + visitEmbeddedPage(getQuestionResource(targetQuestionId)); + }); + }, + ); + + FieldFilter.openEntryForm(); + checkFilterValueNotInList("Doohickey"); + FieldFilter.selectFilterValueFromList("Gizmo"); + }); + + it("should be able to use a structured question source when embedded with a text tag", () => { + cy.createQuestion(structuredSourceQuestion).then( + ({ body: { id: sourceQuestionId } }) => { + cy.createNativeQuestion( + getStructuredTextTargetQuestion(sourceQuestionId), ).then(({ body: { id: targetQuestionId } }) => { visitEmbeddedPage(getQuestionResource(targetQuestionId)); }); @@ -169,7 +185,23 @@ describe("scenarios > filters > sql filters > values source", () => { cy.createQuestion(structuredSourceQuestion).then( ({ body: { id: sourceQuestionId } }) => { cy.createNativeQuestion( - getStructuredTargetQuestion(sourceQuestionId), + getStructuredDimensionTargetQuestion(sourceQuestionId), + ).then(({ body: { id: targetQuestionId } }) => { + visitPublicQuestion(targetQuestionId); + }); + }, + ); + + FieldFilter.openEntryForm(); + checkFilterValueNotInList("Doohickey"); + FieldFilter.selectFilterValueFromList("Gizmo"); + }); + + it("should be able to use a structured question source when public with a text tag", () => { + cy.createQuestion(structuredSourceQuestion).then( + ({ body: { id: sourceQuestionId } }) => { + cy.createNativeQuestion( + getStructuredTextTargetQuestion(sourceQuestionId), ).then(({ body: { id: targetQuestionId } }) => { visitPublicQuestion(targetQuestionId); }); @@ -204,7 +236,23 @@ describe("scenarios > filters > sql filters > values source", () => { cy.createNativeQuestion(nativeSourceQuestion).then( ({ body: { id: sourceQuestionId } }) => { cy.createNativeQuestion( - getNativeTargetQuestion(sourceQuestionId), + getNativeDimensionTargetQuestion(sourceQuestionId), + ).then(({ body: { id: targetQuestionId } }) => { + visitEmbeddedPage(getQuestionResource(targetQuestionId)); + }); + }, + ); + + FieldFilter.openEntryForm(); + checkFilterValueNotInList("0001664425970"); + FieldFilter.selectFilterValueFromList("1018947080336"); + }); + + it("should be able to use a native question source when embedded with a text tag", () => { + cy.createNativeQuestion(nativeSourceQuestion).then( + ({ body: { id: sourceQuestionId } }) => { + cy.createNativeQuestion( + getNativeTextTargetQuestion(sourceQuestionId), ).then(({ body: { id: targetQuestionId } }) => { visitEmbeddedPage(getQuestionResource(targetQuestionId)); }); @@ -220,7 +268,23 @@ describe("scenarios > filters > sql filters > values source", () => { cy.createNativeQuestion(nativeSourceQuestion).then( ({ body: { id: sourceQuestionId } }) => { cy.createNativeQuestion( - getNativeTargetQuestion(sourceQuestionId), + getNativeDimensionTargetQuestion(sourceQuestionId), + ).then(({ body: { id: targetQuestionId } }) => { + visitPublicQuestion(targetQuestionId); + }); + }, + ); + + FieldFilter.openEntryForm(); + checkFilterValueNotInList("0001664425970"); + FieldFilter.selectFilterValueFromList("1018947080336"); + }); + + it("should be able to use a native question source when public with a text tag", () => { + cy.createNativeQuestion(nativeSourceQuestion).then( + ({ body: { id: sourceQuestionId } }) => { + cy.createNativeQuestion( + getNativeTextTargetQuestion(sourceQuestionId), ).then(({ body: { id: targetQuestionId } }) => { visitPublicQuestion(targetQuestionId); }); @@ -250,7 +314,7 @@ describe("scenarios > filters > sql filters > values source", () => { }); it("should be able to use a static list source when embedded", () => { - cy.createNativeQuestion(getListTargetQuestion()).then( + cy.createNativeQuestion(getListDimensionTargetQuestion()).then( ({ body: { id: targetQuestionId } }) => { visitEmbeddedPage(getQuestionResource(targetQuestionId)); }, @@ -262,7 +326,7 @@ describe("scenarios > filters > sql filters > values source", () => { }); it("should be able to use a static list source when public", () => { - cy.createNativeQuestion(getListTargetQuestion()).then( + cy.createNativeQuestion(getListDimensionTargetQuestion()).then( ({ body: { id: targetQuestionId } }) => { visitPublicQuestion(targetQuestionId); }, @@ -302,7 +366,7 @@ describeEE("scenarios > filters > sql filters > values source", () => { cy.createQuestion(structuredSourceQuestion).then( ({ body: { id: sourceQuestionId } }) => { cy.createNativeQuestion( - getStructuredTargetQuestion(sourceQuestionId), + getStructuredDimensionTargetQuestion(sourceQuestionId), ).then(({ body: { id: targetQuestionId } }) => { cy.signOut(); cy.signInAsSandboxedUser(); @@ -332,17 +396,15 @@ const getQuestionResource = questionId => ({ params: {}, }); -const getTargetQuestion = ({ tag, parameter }) => ({ +const getTargetQuestion = ({ query, tag, parameter }) => ({ name: "Embedded", native: { - query: "SELECT * FROM PRODUCTS WHERE {{tag}}", + query, "template-tags": { tag: { id: "93961154-c3d5-7c93-7b59-f4e494fda499", name: "tag", "display-name": "Tag", - type: "dimension", - "widget-type": "string/=", ...tag, }, }, @@ -353,7 +415,6 @@ const getTargetQuestion = ({ tag, parameter }) => ({ name: "Tag", slug: "tag", type: "string/=", - target: ["dimension", ["template-tag", "tag"]], ...parameter, }, ], @@ -363,11 +424,24 @@ const getTargetQuestion = ({ tag, parameter }) => ({ }, }); -const getStructuredTargetQuestion = questionId => { +const getTextTargetQuestion = ({ query, tag, parameter }) => { return getTargetQuestion({ + query, tag: { - dimension: ["field", PRODUCTS.CATEGORY, null], + type: "text", + ...tag, }, + parameter: { + target: ["variable", ["template-tag", "tag"]], + values_query_type: "list", + ...parameter, + }, + }); +}; + +const getStructuredTextTargetQuestion = questionId => { + return getTextTargetQuestion({ + query: "SELECT * FROM PRODUCTS WHERE CATEGORY = {{tag}}", parameter: { values_source_type: "card", values_source_config: { @@ -378,8 +452,53 @@ const getStructuredTargetQuestion = questionId => { }); }; -const getNativeTargetQuestion = questionId => { +const getNativeTextTargetQuestion = questionId => { + return getTextTargetQuestion({ + query: "SELECT * FROM PRODUCTS WHERE EAN = {{tag}}", + parameter: { + values_source_type: "card", + values_source_config: { + card_id: questionId, + value_field: ["field", "EAN", { "base-type": "type/Text" }], + }, + }, + }); +}; + +const getDimensionTargetQuestion = ({ tag, parameter }) => { return getTargetQuestion({ + query: "SELECT * FROM PRODUCTS WHERE {{tag}}", + tag: { + type: "dimension", + "widget-type": "string/=", + dimension: ["field", PRODUCTS.CATEGORY, null], + ...tag, + }, + parameter: { + target: ["dimension", ["template-tag", "tag"]], + ...parameter, + }, + }); +}; + +const getStructuredDimensionTargetQuestion = questionId => { + return getDimensionTargetQuestion({ + tag: { + dimension: ["field", PRODUCTS.CATEGORY, null], + }, + parameter: { + target: ["dimension", ["template-tag", "tag"]], + values_source_type: "card", + values_source_config: { + card_id: questionId, + value_field: ["field", PRODUCTS.CATEGORY, null], + }, + }, + }); +}; + +const getNativeDimensionTargetQuestion = questionId => { + return getDimensionTargetQuestion({ tag: { dimension: ["field", PRODUCTS.EAN, null], }, @@ -393,8 +512,8 @@ const getNativeTargetQuestion = questionId => { }); }; -const getListTargetQuestion = () => { - return getTargetQuestion({ +const getListDimensionTargetQuestion = () => { + return getDimensionTargetQuestion({ tag: { dimension: ["field", PRODUCTS.EAN, null], }, diff --git a/frontend/test/metabase/scenarios/native-filters/sql-filters.cy.spec.js b/e2e/test/scenarios/native-filters/sql-filters.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/native-filters/sql-filters.cy.spec.js rename to e2e/test/scenarios/native-filters/sql-filters.cy.spec.js index bd3ef6f319341..edb67d76e2bb3 100644 --- a/frontend/test/metabase/scenarios/native-filters/sql-filters.cy.spec.js +++ b/e2e/test/scenarios/native-filters/sql-filters.cy.spec.js @@ -3,7 +3,7 @@ import { openNativeEditor, filterWidget, popover, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import * as SQLFilter from "./helpers/e2e-sql-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native/data_ref.cy.spec.js b/e2e/test/scenarios/native/data_ref.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/native/data_ref.cy.spec.js rename to e2e/test/scenarios/native/data_ref.cy.spec.js index 6390b87d6680c..ff1b06744cf56 100644 --- a/frontend/test/metabase/scenarios/native/data_ref.cy.spec.js +++ b/e2e/test/scenarios/native/data_ref.cy.spec.js @@ -2,7 +2,7 @@ import { restore, openNativeEditor, openQuestionActions, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > native question > data reference sidebar", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/native/native-mongo.cy.spec.js b/e2e/test/scenarios/native/native-mongo.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/native/native-mongo.cy.spec.js rename to e2e/test/scenarios/native/native-mongo.cy.spec.js index f7a38711d819a..e6432a92e64f4 100644 --- a/frontend/test/metabase/scenarios/native/native-mongo.cy.spec.js +++ b/e2e/test/scenarios/native/native-mongo.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, modal } from "__support__/e2e/helpers"; +import { restore, modal } from "e2e/support/helpers"; const MONGO_DB_NAME = "QA Mongo4"; diff --git a/frontend/test/metabase/scenarios/native/native-mysql.cy.spec.js b/e2e/test/scenarios/native/native-mysql.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/native/native-mysql.cy.spec.js rename to e2e/test/scenarios/native/native-mysql.cy.spec.js index de2b543d72aa4..2994553a1ea08 100644 --- a/frontend/test/metabase/scenarios/native/native-mysql.cy.spec.js +++ b/e2e/test/scenarios/native/native-mysql.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, modal, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, modal, openNativeEditor } from "e2e/support/helpers"; const MYSQL_DB_NAME = "QA MySQL8"; diff --git a/frontend/test/metabase/scenarios/native/native.cy.spec.js b/e2e/test/scenarios/native/native.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/native/native.cy.spec.js rename to e2e/test/scenarios/native/native.cy.spec.js index 30b9270f0fdaf..51c0502ef3260 100644 --- a/frontend/test/metabase/scenarios/native/native.cy.spec.js +++ b/e2e/test/scenarios/native/native.cy.spec.js @@ -4,13 +4,13 @@ import { openNativeEditor, visitQuestionAdhoc, summarize, - sidebar, + rightSidebar, filter, filterField, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; @@ -152,7 +152,7 @@ describe("scenarios > question > native", () => { cy.icon("close").click(); }); summarize(); - sidebar().within(() => { + rightSidebar().within(() => { cy.icon("close").click(); }); cy.findByText("Done").click(); @@ -166,7 +166,7 @@ describe("scenarios > question > native", () => { cy.findByTestId("sidebar-left") .as("sidebar") .contains(/hidden/i) - .siblings(".Icon-eye_outline") + .siblings("[data-testid$=hide-button]") .click(); cy.get("@editor").type("{movetoend}, 3 as added"); cy.get("@runQuery").click(); diff --git a/frontend/test/metabase/scenarios/native/native_subquery.cy.spec.js b/e2e/test/scenarios/native/native_subquery.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/native/native_subquery.cy.spec.js rename to e2e/test/scenarios/native/native_subquery.cy.spec.js index af8341dbc293e..c01d89c45698f 100644 --- a/frontend/test/metabase/scenarios/native/native_subquery.cy.spec.js +++ b/e2e/test/scenarios/native/native_subquery.cy.spec.js @@ -5,7 +5,7 @@ import { visitQuestion, startNewNativeQuestion, runNativeQuery, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; import * as SQLFilter from "../native-filters/helpers/e2e-sql-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native/reproductions/12439-click-on-legend-breaks-ui.cy.spec.js b/e2e/test/scenarios/native/reproductions/12439-click-on-legend-breaks-ui.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/native/reproductions/12439-click-on-legend-breaks-ui.cy.spec.js rename to e2e/test/scenarios/native/reproductions/12439-click-on-legend-breaks-ui.cy.spec.js index b096cff54cdcf..e3081701d4a84 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/12439-click-on-legend-breaks-ui.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/12439-click-on-legend-breaks-ui.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitQuestionAdhoc, sidebar } from "__support__/e2e/helpers"; +import { restore, visitQuestionAdhoc, sidebar } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const nativeQuery = ` SELECT "PRODUCTS__via__PRODUCT_ID"."CATEGORY" AS "CATEGORY", diff --git a/frontend/test/metabase/scenarios/native/reproductions/15029-sql-variable-dot.cy.spec.js b/e2e/test/scenarios/native/reproductions/15029-sql-variable-dot.cy.spec.js similarity index 86% rename from frontend/test/metabase/scenarios/native/reproductions/15029-sql-variable-dot.cy.spec.js rename to e2e/test/scenarios/native/reproductions/15029-sql-variable-dot.cy.spec.js index d8d5faa6c8d1a..bebc8dff3d594 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/15029-sql-variable-dot.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/15029-sql-variable-dot.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; describe("issue 15029", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/native/reproductions/15946-mongo-pre-select-table.cy.spec.js b/e2e/test/scenarios/native/reproductions/15946-mongo-pre-select-table.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/native/reproductions/15946-mongo-pre-select-table.cy.spec.js rename to e2e/test/scenarios/native/reproductions/15946-mongo-pre-select-table.cy.spec.js index ae3bb3537415f..16d1574d0813f 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/15946-mongo-pre-select-table.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/15946-mongo-pre-select-table.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, modal, startNewQuestion } from "__support__/e2e/helpers"; +import { restore, modal, startNewQuestion } from "e2e/support/helpers"; const MONGO_DB_NAME = "QA Mongo4"; diff --git a/frontend/test/metabase/scenarios/native/reproductions/16886.cy.spec.js b/e2e/test/scenarios/native/reproductions/16886.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/native/reproductions/16886.cy.spec.js rename to e2e/test/scenarios/native/reproductions/16886.cy.spec.js index b58575a82c582..942e3ab2664b4 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/16886.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/16886.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; const ORIGINAL_QUERY = "select 1 from orders"; const SELECTED_TEXT = "select 1"; diff --git a/frontend/test/metabase/scenarios/native/reproductions/16914.cy.spec.js b/e2e/test/scenarios/native/reproductions/16914.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/native/reproductions/16914.cy.spec.js rename to e2e/test/scenarios/native/reproductions/16914.cy.spec.js index b03fb9bcdabc7..c0867b45928cb 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/16914.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/16914.cy.spec.js @@ -1,8 +1,4 @@ -import { - restore, - openNativeEditor, - runNativeQuery, -} from "__support__/e2e/helpers"; +import { restore, openNativeEditor, runNativeQuery } from "e2e/support/helpers"; describe("issue 16914", () => { beforeEach(() => { @@ -23,7 +19,7 @@ describe("issue 16914", () => { cy.findByTestId("viz-settings-button").click(); cy.findByTestId("sidebar-left") .contains(/hidden/i) - .siblings(".Icon-eye_outline") + .siblings("[data-testid$=hide-button]") .click(); cy.button("Done").click(); diff --git a/frontend/test/metabase/scenarios/native/reproductions/17060.cy.spec.js b/e2e/test/scenarios/native/reproductions/17060.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/native/reproductions/17060.cy.spec.js rename to e2e/test/scenarios/native/reproductions/17060.cy.spec.js index 2a821479d47be..cba843152762b 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/17060.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/17060.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; import { runQuery } from "../../native-filters/helpers/e2e-sql-filter-helpers"; diff --git a/frontend/test/metabase/scenarios/native/reproductions/18148-save-button-before-it-is-possible-to-save.cy.spec.js b/e2e/test/scenarios/native/reproductions/18148-save-button-before-it-is-possible-to-save.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/native/reproductions/18148-save-button-before-it-is-possible-to-save.cy.spec.js rename to e2e/test/scenarios/native/reproductions/18148-save-button-before-it-is-possible-to-save.cy.spec.js index e16da4c9d7514..5d6dfe2a0af78 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/18148-save-button-before-it-is-possible-to-save.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/18148-save-button-before-it-is-possible-to-save.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; const dbName = "Sample2"; diff --git a/frontend/test/metabase/scenarios/native/reproductions/18418-saved-question-db-appears-in-db-picker.cy.spec.js b/e2e/test/scenarios/native/reproductions/18418-saved-question-db-appears-in-db-picker.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/native/reproductions/18418-saved-question-db-appears-in-db-picker.cy.spec.js rename to e2e/test/scenarios/native/reproductions/18418-saved-question-db-appears-in-db-picker.cy.spec.js index 70a33c11e129b..4f215a686ea6f 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/18418-saved-question-db-appears-in-db-picker.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/18418-saved-question-db-appears-in-db-picker.cy.spec.js @@ -2,7 +2,7 @@ import { restore, POPOVER_ELEMENT, openNativeEditor, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const questionDetails = { name: "REVIEWS SQL", diff --git a/frontend/test/metabase/scenarios/native/reproductions/19451.cy.spec.js b/e2e/test/scenarios/native/reproductions/19451.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/native/reproductions/19451.cy.spec.js rename to e2e/test/scenarios/native/reproductions/19451.cy.spec.js index f31d5f04a5a6f..e140dad8127d2 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/19451.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/19451.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/native/reproductions/20044-no-data-sees-explore-results.cy.spec.js b/e2e/test/scenarios/native/reproductions/20044-no-data-sees-explore-results.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/native/reproductions/20044-no-data-sees-explore-results.cy.spec.js rename to e2e/test/scenarios/native/reproductions/20044-no-data-sees-explore-results.cy.spec.js index ee2be6210a244..f79b2733e38d4 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/20044-no-data-sees-explore-results.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/20044-no-data-sees-explore-results.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visitQuestion } from "__support__/e2e/helpers"; +import { restore, visitQuestion } from "e2e/support/helpers"; const questionDetails = { name: "20044", diff --git a/frontend/test/metabase/scenarios/native/reproductions/20625-prefix-match.cy.spec.js b/e2e/test/scenarios/native/reproductions/20625-prefix-match.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/native/reproductions/20625-prefix-match.cy.spec.js rename to e2e/test/scenarios/native/reproductions/20625-prefix-match.cy.spec.js index a8d9803b07746..a525c3538eb55 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/20625-prefix-match.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/20625-prefix-match.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; describe("issue 20625", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/native/reproductions/21034-autocomplete-flicker.cy.spec.js b/e2e/test/scenarios/native/reproductions/21034-autocomplete-flicker.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/native/reproductions/21034-autocomplete-flicker.cy.spec.js rename to e2e/test/scenarios/native/reproductions/21034-autocomplete-flicker.cy.spec.js index 155d3c234a120..d2164cc320382 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/21034-autocomplete-flicker.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/21034-autocomplete-flicker.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, openNativeEditor } from "e2e/support/helpers"; describe("issue 21034", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/native/reproductions/21550-snippet-scrollbar.cy.spec.js b/e2e/test/scenarios/native/reproductions/21550-snippet-scrollbar.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/native/reproductions/21550-snippet-scrollbar.cy.spec.js rename to e2e/test/scenarios/native/reproductions/21550-snippet-scrollbar.cy.spec.js index cab8caca083db..6ac68ff15c2cb 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/21550-snippet-scrollbar.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/21550-snippet-scrollbar.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, modal, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, modal, openNativeEditor } from "e2e/support/helpers"; describe("issue 21550", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/native/reproductions/21597-query-build-card-save-modal.cy.spec.js b/e2e/test/scenarios/native/reproductions/21597-query-build-card-save-modal.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/native/reproductions/21597-query-build-card-save-modal.cy.spec.js rename to e2e/test/scenarios/native/reproductions/21597-query-build-card-save-modal.cy.spec.js index 83a5d13a1f230..8da75e0ac938a 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/21597-query-build-card-save-modal.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/21597-query-build-card-save-modal.cy.spec.js @@ -4,10 +4,10 @@ import { modal, openNativeEditor, addPostgresDatabase, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const databaseName = "Sample Database"; const databaseCopyName = `${databaseName} copy`; diff --git a/frontend/test/metabase/scenarios/native/reproductions/23510-load-data-reference-metadata.cy.spec.js b/e2e/test/scenarios/native/reproductions/23510-load-data-reference-metadata.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/native/reproductions/23510-load-data-reference-metadata.cy.spec.js rename to e2e/test/scenarios/native/reproductions/23510-load-data-reference-metadata.cy.spec.js index dcd5db2c6136f..9b38100d60d92 100644 --- a/frontend/test/metabase/scenarios/native/reproductions/23510-load-data-reference-metadata.cy.spec.js +++ b/e2e/test/scenarios/native/reproductions/23510-load-data-reference-metadata.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; describe("issue 23510", () => { diff --git a/frontend/test/metabase/scenarios/native/snippets/snippet-permissions.cy.spec.js b/e2e/test/scenarios/native/snippets/snippet-permissions.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/native/snippets/snippet-permissions.cy.spec.js rename to e2e/test/scenarios/native/snippets/snippet-permissions.cy.spec.js index 7ce8e6958162d..9a15872a4dc31 100644 --- a/frontend/test/metabase/scenarios/native/snippets/snippet-permissions.cy.spec.js +++ b/e2e/test/scenarios/native/snippets/snippet-permissions.cy.spec.js @@ -4,9 +4,10 @@ import { popover, describeEE, openNativeEditor, -} from "__support__/e2e/helpers"; + rightSidebar, +} from "e2e/support/helpers"; -import { USER_GROUPS } from "__support__/e2e/cypress_data"; +import { USER_GROUPS } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP } = USER_GROUPS; @@ -52,6 +53,8 @@ describeEE("scenarios > question > snippets", () => { collection_id: null, }); + cy.intercept("GET", "api/collection/*").as("collection"); + openNativeEditor(); // create folder @@ -74,8 +77,12 @@ describeEE("scenarios > question > snippets", () => { .parent() .within(() => { cy.icon("chevrondown").click({ force: true }); - cy.findByText("Edit").click(); }); + + rightSidebar().within(() => { + cy.findByText("Edit").click(); + }); + modal().within(() => cy.findByText("Top folder").click()); popover().within(() => cy.findByText("my favorite snippets").click()); cy.intercept("/api/collection/root/items?namespace=snippets").as( diff --git a/frontend/test/metabase/scenarios/native/snippets/snippets.cy.spec.js b/e2e/test/scenarios/native/snippets/snippets.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/native/snippets/snippets.cy.spec.js rename to e2e/test/scenarios/native/snippets/snippets.cy.spec.js index 54ebbe60dd942..8b9c12541d8ba 100644 --- a/frontend/test/metabase/scenarios/native/snippets/snippets.cy.spec.js +++ b/e2e/test/scenarios/native/snippets/snippets.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, modal, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, modal, openNativeEditor } from "e2e/support/helpers"; // HACK which lets us type (even very long words) without losing focus // this is needed for fields where autocomplete suggestions are enabled diff --git a/frontend/test/metabase/scenarios/onboarding/about.cy.spec.js b/e2e/test/scenarios/onboarding/about.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/onboarding/about.cy.spec.js rename to e2e/test/scenarios/onboarding/about.cy.spec.js index ab7869d3a4f75..faeb47def33dd 100644 --- a/frontend/test/metabase/scenarios/onboarding/about.cy.spec.js +++ b/e2e/test/scenarios/onboarding/about.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("scenarios > about Metabase", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/onboarding/auth/forgot_password.cy.spec.js b/e2e/test/scenarios/onboarding/auth/forgot_password.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/onboarding/auth/forgot_password.cy.spec.js rename to e2e/test/scenarios/onboarding/auth/forgot_password.cy.spec.js index 8a722782b40e6..97bd092dbec98 100644 --- a/frontend/test/metabase/scenarios/onboarding/auth/forgot_password.cy.spec.js +++ b/e2e/test/scenarios/onboarding/auth/forgot_password.cy.spec.js @@ -1,5 +1,5 @@ -import { getInbox, restore, setupSMTP } from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { getInbox, restore, setupSMTP } from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const { admin } = USERS; diff --git a/frontend/test/metabase/scenarios/onboarding/auth/signin.cy.spec.js b/e2e/test/scenarios/onboarding/auth/signin.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/onboarding/auth/signin.cy.spec.js rename to e2e/test/scenarios/onboarding/auth/signin.cy.spec.js index 0337354bc10c8..01f5cf030c667 100644 --- a/frontend/test/metabase/scenarios/onboarding/auth/signin.cy.spec.js +++ b/e2e/test/scenarios/onboarding/auth/signin.cy.spec.js @@ -1,5 +1,5 @@ -import { browse, restore } from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { browse, restore } from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const sizes = [ [1280, 800], diff --git a/frontend/test/metabase/scenarios/onboarding/auth/sso.cy.spec.js b/e2e/test/scenarios/onboarding/auth/sso.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/onboarding/auth/sso.cy.spec.js rename to e2e/test/scenarios/onboarding/auth/sso.cy.spec.js index b05ca1ca8c607..4ecc8a0f3de2a 100644 --- a/frontend/test/metabase/scenarios/onboarding/auth/sso.cy.spec.js +++ b/e2e/test/scenarios/onboarding/auth/sso.cy.spec.js @@ -2,8 +2,8 @@ import { describeEE, restore, mockCurrentUserProperty, -} from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const { admin } = USERS; diff --git a/frontend/test/metabase/scenarios/onboarding/home/activity-page.cy.spec.js b/e2e/test/scenarios/onboarding/home/activity-page.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/onboarding/home/activity-page.cy.spec.js rename to e2e/test/scenarios/onboarding/home/activity-page.cy.spec.js index 75c502fb731b6..ec322e402fac5 100644 --- a/frontend/test/metabase/scenarios/onboarding/home/activity-page.cy.spec.js +++ b/e2e/test/scenarios/onboarding/home/activity-page.cy.spec.js @@ -7,9 +7,9 @@ import { saveDashboard, visitDashboard, getFullName, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const { normal } = USERS; diff --git a/frontend/test/metabase/scenarios/onboarding/home/browse.cy.spec.js b/e2e/test/scenarios/onboarding/home/browse.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/onboarding/home/browse.cy.spec.js rename to e2e/test/scenarios/onboarding/home/browse.cy.spec.js index 275e7afec7b54..3b0ceb0c58d17 100644 --- a/frontend/test/metabase/scenarios/onboarding/home/browse.cy.spec.js +++ b/e2e/test/scenarios/onboarding/home/browse.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("scenarios > browse data", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/onboarding/home/homepage.cy.spec.js b/e2e/test/scenarios/onboarding/home/homepage.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/onboarding/home/homepage.cy.spec.js rename to e2e/test/scenarios/onboarding/home/homepage.cy.spec.js index 29244cfd402a5..6a46fb481b283 100644 --- a/frontend/test/metabase/scenarios/onboarding/home/homepage.cy.spec.js +++ b/e2e/test/scenarios/onboarding/home/homepage.cy.spec.js @@ -1,4 +1,4 @@ -import { popover, restore, visitDashboard } from "__support__/e2e/helpers"; +import { popover, restore, visitDashboard } from "e2e/support/helpers"; describe("scenarios > home > homepage", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/onboarding/navbar/new-menu.cy.spec.js b/e2e/test/scenarios/onboarding/navbar/new-menu.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/onboarding/navbar/new-menu.cy.spec.js rename to e2e/test/scenarios/onboarding/navbar/new-menu.cy.spec.js index 13feb8fc7c4c1..2689342cd42e6 100644 --- a/frontend/test/metabase/scenarios/onboarding/navbar/new-menu.cy.spec.js +++ b/e2e/test/scenarios/onboarding/navbar/new-menu.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover, modal } from "__support__/e2e/helpers"; +import { restore, popover, modal } from "e2e/support/helpers"; describe("metabase > scenarios > navbar > new menu", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/onboarding/notifications.cy.spec.js b/e2e/test/scenarios/onboarding/notifications.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/onboarding/notifications.cy.spec.js rename to e2e/test/scenarios/onboarding/notifications.cy.spec.js index 754ab56eca0c3..4d0a46a1fac71 100644 --- a/frontend/test/metabase/scenarios/onboarding/notifications.cy.spec.js +++ b/e2e/test/scenarios/onboarding/notifications.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers/e2e-setup-helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { modal } from "__support__/e2e/helpers/e2e-ui-elements-helpers"; +import { restore } from "e2e/support/helpers/e2e-setup-helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { modal } from "e2e/support/helpers/e2e-ui-elements-helpers"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/onboarding/reference/databases.cy.spec.js b/e2e/test/scenarios/onboarding/reference/databases.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/onboarding/reference/databases.cy.spec.js rename to e2e/test/scenarios/onboarding/reference/databases.cy.spec.js index d3734180aec28..85599bf06524e 100644 --- a/frontend/test/metabase/scenarios/onboarding/reference/databases.cy.spec.js +++ b/e2e/test/scenarios/onboarding/reference/databases.cy.spec.js @@ -1,4 +1,4 @@ -import { popover, restore, startNewQuestion } from "__support__/e2e/helpers"; +import { popover, restore, startNewQuestion } from "e2e/support/helpers"; describe("scenarios > reference > databases", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/onboarding/reference/metrics.cy.spec.js b/e2e/test/scenarios/onboarding/reference/metrics.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/onboarding/reference/metrics.cy.spec.js rename to e2e/test/scenarios/onboarding/reference/metrics.cy.spec.js index 64a6984ce24a7..342ef0bdaf34c 100644 --- a/frontend/test/metabase/scenarios/onboarding/reference/metrics.cy.spec.js +++ b/e2e/test/scenarios/onboarding/reference/metrics.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js b/e2e/test/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js rename to e2e/test/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js index 5eeddf8f2abd0..7cae36037bd35 100644 --- a/frontend/test/metabase/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js +++ b/e2e/test/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js @@ -1,4 +1,4 @@ -import { popover, restore } from "__support__/e2e/helpers"; +import { popover, restore } from "e2e/support/helpers"; describe("issue 5276", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/onboarding/search/recently-viewed.cy.spec.js b/e2e/test/scenarios/onboarding/search/recently-viewed.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/onboarding/search/recently-viewed.cy.spec.js rename to e2e/test/scenarios/onboarding/search/recently-viewed.cy.spec.js index 3edee05bc5648..840a455d4856e 100644 --- a/frontend/test/metabase/scenarios/onboarding/search/recently-viewed.cy.spec.js +++ b/e2e/test/scenarios/onboarding/search/recently-viewed.cy.spec.js @@ -4,10 +4,10 @@ import { visitDashboard, openPeopleTable, describeEE, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/onboarding/search/reproductions/16785-do-not-display-hidden-tables.cy.spec.js b/e2e/test/scenarios/onboarding/search/reproductions/16785-do-not-display-hidden-tables.cy.spec.js similarity index 80% rename from frontend/test/metabase/scenarios/onboarding/search/reproductions/16785-do-not-display-hidden-tables.cy.spec.js rename to e2e/test/scenarios/onboarding/search/reproductions/16785-do-not-display-hidden-tables.cy.spec.js index aa6466de19775..e5d5dbe8cd0c9 100644 --- a/frontend/test/metabase/scenarios/onboarding/search/reproductions/16785-do-not-display-hidden-tables.cy.spec.js +++ b/e2e/test/scenarios/onboarding/search/reproductions/16785-do-not-display-hidden-tables.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/onboarding/search/search-pagination.cy.spec.js b/e2e/test/scenarios/onboarding/search/search-pagination.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/onboarding/search/search-pagination.cy.spec.js rename to e2e/test/scenarios/onboarding/search/search-pagination.cy.spec.js index 5d9351638789a..ab3d0f0776d34 100644 --- a/frontend/test/metabase/scenarios/onboarding/search/search-pagination.cy.spec.js +++ b/e2e/test/scenarios/onboarding/search/search-pagination.cy.spec.js @@ -1,6 +1,6 @@ import _ from "underscore"; -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/onboarding/search/search-typeahead.cy.spec.js b/e2e/test/scenarios/onboarding/search/search-typeahead.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/onboarding/search/search-typeahead.cy.spec.js rename to e2e/test/scenarios/onboarding/search/search-typeahead.cy.spec.js index 087190e520714..4d2b897264d7e 100644 --- a/frontend/test/metabase/scenarios/onboarding/search/search-typeahead.cy.spec.js +++ b/e2e/test/scenarios/onboarding/search/search-typeahead.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { restore } from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; ["admin", "normal"].forEach(user => { describe(`search > ${user} user`, () => { diff --git a/frontend/test/metabase/scenarios/onboarding/search/search.cy.spec.js b/e2e/test/scenarios/onboarding/search/search.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/onboarding/search/search.cy.spec.js rename to e2e/test/scenarios/onboarding/search/search.cy.spec.js index d62b180ca1d9e..fecf9d0a7bfdd 100644 --- a/frontend/test/metabase/scenarios/onboarding/search/search.cy.spec.js +++ b/e2e/test/scenarios/onboarding/search/search.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("scenarios > auth > search", () => { beforeEach(restore); diff --git a/frontend/test/metabase/scenarios/onboarding/setup/setup.cy.spec.js b/e2e/test/scenarios/onboarding/setup/setup.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/onboarding/setup/setup.cy.spec.js rename to e2e/test/scenarios/onboarding/setup/setup.cy.spec.js index 649460d6dca09..919d0f14ac340 100644 --- a/frontend/test/metabase/scenarios/onboarding/setup/setup.cy.spec.js +++ b/e2e/test/scenarios/onboarding/setup/setup.cy.spec.js @@ -5,9 +5,9 @@ import { expectNoBadSnowplowEvents, resetSnowplow, restore, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const { admin } = USERS; @@ -121,7 +121,7 @@ describe("scenarios > setup", () => { cy.findByLabelText("Display name").type("Metabase H2"); cy.findByText("Connect database").closest("button").should("be.disabled"); - const dbFilename = "frontend/test/__runner__/empty.db"; + const dbFilename = "e2e/runner/empty.db"; const dbPath = Cypress.config("fileServerFolder") + "/" + dbFilename; cy.findByLabelText("Connection String").type(`file:${dbPath}`); cy.findByText("Connect database") @@ -191,7 +191,7 @@ describe("scenarios > setup", () => { cy.findByText("H2").click(); cy.findByLabelText("Display name").type("Metabase H2"); - const dbFilename = "frontend/test/__runner__/empty.db"; + const dbFilename = "e2e/runner/empty.db"; const dbPath = Cypress.config("fileServerFolder") + "/" + dbFilename; cy.findByLabelText("Connection String").type(`file:${dbPath}`); cy.button("Connect database").click(); diff --git a/frontend/test/metabase/scenarios/onboarding/setup/user_settings.cy.spec.js b/e2e/test/scenarios/onboarding/setup/user_settings.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/onboarding/setup/user_settings.cy.spec.js rename to e2e/test/scenarios/onboarding/setup/user_settings.cy.spec.js index 0869a1c7e2933..5ae4b4901119e 100644 --- a/frontend/test/metabase/scenarios/onboarding/setup/user_settings.cy.spec.js +++ b/e2e/test/scenarios/onboarding/setup/user_settings.cy.spec.js @@ -1,6 +1,5 @@ -// Migrated from frontend/test/metabase/user/UserSettings.integ.spec.js -import { restore, popover, getFullName } from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { restore, popover, getFullName } from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const { normal } = USERS; @@ -85,7 +84,7 @@ describe("user > settings", () => { cy.visit("/account/password"); // Validate common passwords - cy.findByLabelText("Create a password") + cy.findByLabelText(/Create a password/i) .as("passwordInput") .type("qwerty123") .blur(); diff --git a/frontend/test/metabase/scenarios/onboarding/urls.cy.spec.js b/e2e/test/scenarios/onboarding/urls.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/onboarding/urls.cy.spec.js rename to e2e/test/scenarios/onboarding/urls.cy.spec.js index 59eaa06b2435d..ee634d89562f5 100644 --- a/frontend/test/metabase/scenarios/onboarding/urls.cy.spec.js +++ b/e2e/test/scenarios/onboarding/urls.cy.spec.js @@ -3,8 +3,8 @@ import { navigationSidebar, popover, getFullName, -} from "__support__/e2e/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "metabase-lib/metadata/utils/saved-questions"; diff --git a/frontend/test/metabase/scenarios/organization/bookmarks-collection.cy.spec.js b/e2e/test/scenarios/organization/bookmarks-collection.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/organization/bookmarks-collection.cy.spec.js rename to e2e/test/scenarios/organization/bookmarks-collection.cy.spec.js index 7ac1be8ccc7ce..1583b05b25028 100644 --- a/frontend/test/metabase/scenarios/organization/bookmarks-collection.cy.spec.js +++ b/e2e/test/scenarios/organization/bookmarks-collection.cy.spec.js @@ -4,10 +4,10 @@ import { popover, navigationSidebar, visitCollection, -} from "__support__/e2e/helpers"; -import { USERS, SAMPLE_DB_TABLES } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS, SAMPLE_DB_TABLES } from "e2e/support/cypress_data"; -import { getSidebarSectionTitle as getSectionTitle } from "__support__/e2e/helpers/e2e-collection-helpers"; +import { getSidebarSectionTitle as getSectionTitle } from "e2e/support/helpers/e2e-collection-helpers"; const adminFullName = USERS.admin.first_name + " " + USERS.admin.last_name; const adminPersonalCollectionName = adminFullName + "'s Personal Collection"; diff --git a/frontend/test/metabase/scenarios/organization/bookmarks-question.cy.spec.js b/e2e/test/scenarios/organization/bookmarks-question.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/organization/bookmarks-question.cy.spec.js rename to e2e/test/scenarios/organization/bookmarks-question.cy.spec.js index dca47988cb44f..08c8b1c2ca6e1 100644 --- a/frontend/test/metabase/scenarios/organization/bookmarks-question.cy.spec.js +++ b/e2e/test/scenarios/organization/bookmarks-question.cy.spec.js @@ -4,8 +4,8 @@ import { openQuestionActions, openNavigationSidebar, visitQuestion, -} from "__support__/e2e/helpers"; -import { getSidebarSectionTitle as getSectionTitle } from "__support__/e2e/helpers/e2e-collection-helpers"; +} from "e2e/support/helpers"; +import { getSidebarSectionTitle as getSectionTitle } from "e2e/support/helpers/e2e-collection-helpers"; describe("scenarios > question > bookmarks", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/organization/edit-history-metadata.cy.spec.js b/e2e/test/scenarios/organization/edit-history-metadata.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/organization/edit-history-metadata.cy.spec.js rename to e2e/test/scenarios/organization/edit-history-metadata.cy.spec.js index 5ea7a3977a258..2ea84696b2f4e 100644 --- a/frontend/test/metabase/scenarios/organization/edit-history-metadata.cy.spec.js +++ b/e2e/test/scenarios/organization/edit-history-metadata.cy.spec.js @@ -1,9 +1,5 @@ -import { - restore, - visitQuestion, - visitDashboard, -} from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { restore, visitQuestion, visitDashboard } from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; describe("scenarios > collection items metadata", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/organization/moderation-collection.cy.spec.js b/e2e/test/scenarios/organization/moderation-collection.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/organization/moderation-collection.cy.spec.js rename to e2e/test/scenarios/organization/moderation-collection.cy.spec.js index 2baad009c6002..beaf37c8e88bc 100644 --- a/frontend/test/metabase/scenarios/organization/moderation-collection.cy.spec.js +++ b/e2e/test/scenarios/organization/moderation-collection.cy.spec.js @@ -10,9 +10,9 @@ import { getCollectionActions, popover, openCollectionMenu, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -264,6 +264,7 @@ function assertSearchResultBadge(itemName, opts) { const { expectBadge } = opts; cy.findByText(itemName, opts) .parentsUntil("[data-testid=search-result-item]") + .last() .within(() => { cy.icon("badge").should(expectBadge ? "exist" : "not.exist"); }); diff --git a/frontend/test/metabase/scenarios/organization/moderation-question.cy.spec.js b/e2e/test/scenarios/organization/moderation-question.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/organization/moderation-question.cy.spec.js rename to e2e/test/scenarios/organization/moderation-question.cy.spec.js index 446f3b9f00196..cc465f3dd8006 100644 --- a/frontend/test/metabase/scenarios/organization/moderation-question.cy.spec.js +++ b/e2e/test/scenarios/organization/moderation-question.cy.spec.js @@ -5,9 +5,9 @@ import { openQuestionActions, questionInfoButton, getFullName, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const { admin } = USERS; const adminFullName = getFullName(admin); @@ -147,7 +147,7 @@ function verifyQuestion() { openQuestionActions(); cy.findByTextEnsureVisible("Verify this question").click(); - cy.wait("@loadCard").should(({ response: { body } }) => { + cy.wait("@loadCard").then(({ response: { body } }) => { const { moderation_reviews } = body; /** diff --git a/frontend/test/metabase/scenarios/organization/timelines-collection.cy.spec.js b/e2e/test/scenarios/organization/timelines-collection.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/organization/timelines-collection.cy.spec.js rename to e2e/test/scenarios/organization/timelines-collection.cy.spec.js index 5c2b2e8111b60..b89bb066ff0e7 100644 --- a/frontend/test/metabase/scenarios/organization/timelines-collection.cy.spec.js +++ b/e2e/test/scenarios/organization/timelines-collection.cy.spec.js @@ -7,9 +7,9 @@ import { restore, getFullName, popover, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const { admin } = USERS; diff --git a/frontend/test/metabase/scenarios/organization/timelines-question.cy.spec.js b/e2e/test/scenarios/organization/timelines-question.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/organization/timelines-question.cy.spec.js rename to e2e/test/scenarios/organization/timelines-question.cy.spec.js index 5dd068ec1c230..a131efe736b9a 100644 --- a/frontend/test/metabase/scenarios/organization/timelines-question.cy.spec.js +++ b/e2e/test/scenarios/organization/timelines-question.cy.spec.js @@ -1,11 +1,11 @@ import { restore, visitQuestion, - sidebar, + rightSidebar, visitQuestionAdhoc, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -99,7 +99,7 @@ describe("scenarios > organization > timelines > question", () => { cy.icon("calendar").click(); cy.findByText("Releases").should("be.visible"); - sidebar().within(() => cy.icon("ellipsis").click()); + rightSidebar().within(() => cy.icon("ellipsis").click()); cy.findByText("Edit event").click(); cy.findByLabelText("Event name").clear().type("RC2"); @@ -125,7 +125,7 @@ describe("scenarios > organization > timelines > question", () => { cy.icon("calendar").click(); cy.findByText("Builds").should("be.visible"); - sidebar().within(() => cy.icon("ellipsis").click()); + rightSidebar().within(() => cy.icon("ellipsis").click()); cy.findByText("Move event").click(); cy.findByText("Releases").click(); cy.button("Move").click(); @@ -147,7 +147,7 @@ describe("scenarios > organization > timelines > question", () => { cy.icon("calendar").click(); cy.findByText("Releases").should("be.visible"); - sidebar().within(() => cy.icon("ellipsis").click()); + rightSidebar().within(() => cy.icon("ellipsis").click()); cy.findByText("Archive event").click(); cy.wait("@updateEvent"); cy.findByText("RC1").should("not.exist"); @@ -385,7 +385,7 @@ describe("scenarios > organization > timelines > question", () => { cy.icon("calendar").click(); cy.findByText("Releases").should("be.visible"); cy.findByText("Add an event").should("not.exist"); - sidebar().within(() => cy.icon("ellipsis").should("not.exist")); + rightSidebar().within(() => cy.icon("ellipsis").should("not.exist")); }); }); }); diff --git a/frontend/test/metabase/scenarios/permissions/admin-permissions.cy.spec.js b/e2e/test/scenarios/permissions/admin-permissions.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/permissions/admin-permissions.cy.spec.js rename to e2e/test/scenarios/permissions/admin-permissions.cy.spec.js index b68b1c370d606..eb6ad1e852663 100644 --- a/frontend/test/metabase/scenarios/permissions/admin-permissions.cy.spec.js +++ b/e2e/test/scenarios/permissions/admin-permissions.cy.spec.js @@ -11,10 +11,10 @@ import { isPermissionDisabled, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID, USER_GROUPS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID, USER_GROUPS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/permissions/application-permissions.cy.spec.js b/e2e/test/scenarios/permissions/application-permissions.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/permissions/application-permissions.cy.spec.js rename to e2e/test/scenarios/permissions/application-permissions.cy.spec.js index 6b3d99edabb68..d10b609464799 100644 --- a/frontend/test/metabase/scenarios/permissions/application-permissions.cy.spec.js +++ b/e2e/test/scenarios/permissions/application-permissions.cy.spec.js @@ -6,10 +6,10 @@ import { getFullName, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { USERS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/permissions/data-model-permissions.cy.spec.js b/e2e/test/scenarios/permissions/data-model-permissions.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/permissions/data-model-permissions.cy.spec.js rename to e2e/test/scenarios/permissions/data-model-permissions.cy.spec.js index 9f1b8e0a4fc1e..5a9ee5c9ccbc2 100644 --- a/frontend/test/metabase/scenarios/permissions/data-model-permissions.cy.spec.js +++ b/e2e/test/scenarios/permissions/data-model-permissions.cy.spec.js @@ -4,10 +4,10 @@ import { describeEE, assertPermissionForItem, modifyPermission, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/permissions/database-details-permissions.cy.spec.js b/e2e/test/scenarios/permissions/database-details-permissions.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/permissions/database-details-permissions.cy.spec.js rename to e2e/test/scenarios/permissions/database-details-permissions.cy.spec.js index 3537c6441aa10..08a98c0468f94 100644 --- a/frontend/test/metabase/scenarios/permissions/database-details-permissions.cy.spec.js +++ b/e2e/test/scenarios/permissions/database-details-permissions.cy.spec.js @@ -4,9 +4,9 @@ import { describeEE, assertPermissionForItem, modifyPermission, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const DATA_ACCESS_PERMISSION_INDEX = 0; const DETAILS_PERMISSION_INDEX = 4; diff --git a/frontend/test/metabase/scenarios/permissions/download-permissions.cy.spec.js b/e2e/test/scenarios/permissions/download-permissions.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/permissions/download-permissions.cy.spec.js rename to e2e/test/scenarios/permissions/download-permissions.cy.spec.js index b0de2a27899e5..f3ea4e64c26ac 100644 --- a/frontend/test/metabase/scenarios/permissions/download-permissions.cy.spec.js +++ b/e2e/test/scenarios/permissions/download-permissions.cy.spec.js @@ -9,11 +9,11 @@ import { sidebar, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; -import { SAMPLE_DB_ID, USER_GROUPS } from "__support__/e2e/cypress_data"; +import { SAMPLE_DB_ID, USER_GROUPS } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP } = USER_GROUPS; diff --git a/frontend/test/metabase/scenarios/permissions/permissions-baseline.cy.spec.js b/e2e/test/scenarios/permissions/permissions-baseline.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/permissions/permissions-baseline.cy.spec.js rename to e2e/test/scenarios/permissions/permissions-baseline.cy.spec.js index 6d07a46553702..b9b5d872996e4 100644 --- a/frontend/test/metabase/scenarios/permissions/permissions-baseline.cy.spec.js +++ b/e2e/test/scenarios/permissions/permissions-baseline.cy.spec.js @@ -2,8 +2,8 @@ import { restore, visitQuestion, visitQuestionAdhoc, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; describe("scenarios > permissions", () => { beforeEach(restore); diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/13347-cannot-select-saved-question-in-database-without-data-permissions.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/13347-cannot-select-saved-question-in-database-without-data-permissions.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/permissions/reproductions/13347-cannot-select-saved-question-in-database-without-data-permissions.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/13347-cannot-select-saved-question-in-database-without-data-permissions.cy.spec.js index e5d407c1ecbc5..acdac3c4e9ed1 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/13347-cannot-select-saved-question-in-database-without-data-permissions.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/13347-cannot-select-saved-question-in-database-without-data-permissions.cy.spec.js @@ -1,9 +1,5 @@ -import { - restore, - withDatabase, - startNewQuestion, -} from "__support__/e2e/helpers"; -import { USER_GROUPS } from "__support__/e2e/cypress_data"; +import { restore, withDatabase, startNewQuestion } from "e2e/support/helpers"; +import { USER_GROUPS } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP } = USER_GROUPS; const PG_DB_ID = 2; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/14873-regextract-in-sandboxed-table.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/14873-regextract-in-sandboxed-table.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/permissions/reproductions/14873-regextract-in-sandboxed-table.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/14873-regextract-in-sandboxed-table.cy.spec.js index 4e00877d8a0de..d4c04734bceb7 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/14873-regextract-in-sandboxed-table.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/14873-regextract-in-sandboxed-table.cy.spec.js @@ -3,8 +3,8 @@ import { withDatabase, describeEE, visitQuestion, -} from "__support__/e2e/helpers"; -import { USER_GROUPS } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USER_GROUPS } from "e2e/support/cypress_data"; const PG_DB_ID = 2; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/17763-cannot-edit-granular-after-block.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/17763-cannot-edit-granular-after-block.cy.spec.js similarity index 86% rename from frontend/test/metabase/scenarios/permissions/reproductions/17763-cannot-edit-granular-after-block.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/17763-cannot-edit-granular-after-block.cy.spec.js index 2e7bb042a5c79..c4245fa5ca76a 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/17763-cannot-edit-granular-after-block.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/17763-cannot-edit-granular-after-block.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover, describeEE } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID, USER_GROUPS } from "__support__/e2e/cypress_data"; +import { restore, popover, describeEE } from "e2e/support/helpers"; +import { SAMPLE_DB_ID, USER_GROUPS } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP } = USER_GROUPS; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/17777-hidden-tables-not-available.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/17777-hidden-tables-not-available.cy.spec.js similarity index 84% rename from frontend/test/metabase/scenarios/permissions/reproductions/17777-hidden-tables-not-available.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/17777-hidden-tables-not-available.cy.spec.js index 28d93a715324c..34c0d912777d3 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/17777-hidden-tables-not-available.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/17777-hidden-tables-not-available.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, popover } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; -import { USER_GROUPS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, popover } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { USER_GROUPS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP } = USER_GROUPS; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/19603-archived-sub-collection-shows-up-in-permissions.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/19603-archived-sub-collection-shows-up-in-permissions.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/permissions/reproductions/19603-archived-sub-collection-shows-up-in-permissions.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/19603-archived-sub-collection-shows-up-in-permissions.cy.spec.js index 83f95e1f6fc36..da7c941c4d24c 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/19603-archived-sub-collection-shows-up-in-permissions.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/19603-archived-sub-collection-shows-up-in-permissions.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("issue 19603", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/20436-incorrect-display-of-database-permissions-level.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/20436-incorrect-display-of-database-permissions-level.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/permissions/reproductions/20436-incorrect-display-of-database-permissions-level.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/20436-incorrect-display-of-database-permissions-level.cy.spec.js index 8588115b7bef4..0ff46b2364107 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/20436-incorrect-display-of-database-permissions-level.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/20436-incorrect-display-of-database-permissions-level.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover } from "__support__/e2e/helpers"; -import { USER_GROUPS } from "__support__/e2e/cypress_data"; +import { restore, popover } from "e2e/support/helpers"; +import { USER_GROUPS } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP } = USER_GROUPS; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/22447-illogical-UI-elements-for-nodata.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/22447-illogical-UI-elements-for-nodata.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/permissions/reproductions/22447-illogical-UI-elements-for-nodata.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/22447-illogical-UI-elements-for-nodata.cy.spec.js index 3e5ca45dbd882..173fc8fb5eb16 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/22447-illogical-UI-elements-for-nodata.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/22447-illogical-UI-elements-for-nodata.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestion, isEE, popover } from "__support__/e2e/helpers"; -import { USER_GROUPS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestion, isEE, popover } from "e2e/support/helpers"; +import { USER_GROUPS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP, COLLECTION_GROUP } = USER_GROUPS; diff --git a/e2e/test/scenarios/permissions/reproductions/22473-cannot-unsubscribe-from-notifications-without-collection-perms.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/22473-cannot-unsubscribe-from-notifications-without-collection-perms.cy.spec.js new file mode 100644 index 0000000000000..1c5130de7306c --- /dev/null +++ b/e2e/test/scenarios/permissions/reproductions/22473-cannot-unsubscribe-from-notifications-without-collection-perms.cy.spec.js @@ -0,0 +1,38 @@ +import { restore, setupSMTP, sidebar } from "e2e/support/helpers"; +import { modal } from "e2e/support/helpers/e2e-ui-elements-helpers"; + +import { USERS } from "e2e/support/cypress_data"; +const { nocollection } = USERS; + +describe("issue 22473", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setupSMTP(); + }); + + it("nocollection user should be able to view and unsubscribe themselves from a subscription", () => { + cy.visit(`/dashboard/1`); + cy.icon("subscription").click(); + cy.findByText("Email it").click(); + cy.findByPlaceholderText("Enter user names or email addresses") + .click() + .type(`${nocollection.first_name} ${nocollection.last_name}{enter}`) + .blur(); + sidebar().within(() => { + cy.button("Done").click() + }); + + cy.signIn("nocollection"); + cy.visit("/account/notifications"); + + cy.findByText("Orders in a dashboard").should("exist"); + cy.findByTestId("notifications-list").within(() => { + cy.findByLabelText("close icon").click(); + }); + modal().within(() => { + cy.button("Unsubscribe").click() + }); + cy.findByText("Orders in a dashboard").should("not.exist"); + }) +}) diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/22695-search-databases-no-permissions.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/22695-search-databases-no-permissions.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/permissions/reproductions/22695-search-databases-no-permissions.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/22695-search-databases-no-permissions.cy.spec.js index 447d27a634a1b..5456c47c326c7 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/22695-search-databases-no-permissions.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/22695-search-databases-no-permissions.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, describeEE } from "__support__/e2e/helpers"; -import { USER_GROUPS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, describeEE } from "e2e/support/helpers"; +import { USER_GROUPS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP, DATA_GROUP } = USER_GROUPS; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/22726-readonly-collection-duplicate-question.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/22726-readonly-collection-duplicate-question.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/permissions/reproductions/22726-readonly-collection-duplicate-question.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/22726-readonly-collection-duplicate-question.cy.spec.js index 5d2a0d864679c..dd8332309c35e 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/22726-readonly-collection-duplicate-question.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/22726-readonly-collection-duplicate-question.cy.spec.js @@ -4,8 +4,8 @@ import { visitQuestion, openQuestionActions, getFullName, -} from "__support__/e2e/helpers"; -import { USERS, USER_GROUPS } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS, USER_GROUPS } from "e2e/support/cypress_data"; const { nocollection } = USERS; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/22727-readonly-collection-offered-on-save.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/22727-readonly-collection-offered-on-save.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/permissions/reproductions/22727-readonly-collection-offered-on-save.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/22727-readonly-collection-offered-on-save.cy.spec.js index 9b08f905eaac7..0b111e4dd35d2 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/22727-readonly-collection-offered-on-save.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/22727-readonly-collection-offered-on-save.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestion, popover } from "__support__/e2e/helpers"; -import { USER_GROUPS } from "__support__/e2e/cypress_data"; +import { restore, visitQuestion, popover } from "e2e/support/helpers"; +import { USER_GROUPS } from "e2e/support/cypress_data"; const { ALL_USERS_GROUP } = USER_GROUPS; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/23981-root-collection-breadcrumbs.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/23981-root-collection-breadcrumbs.cy.spec.js similarity index 85% rename from frontend/test/metabase/scenarios/permissions/reproductions/23981-root-collection-breadcrumbs.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/23981-root-collection-breadcrumbs.cy.spec.js index 52daffc2290dc..d38f33f48766b 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/23981-root-collection-breadcrumbs.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/23981-root-collection-breadcrumbs.cy.spec.js @@ -3,9 +3,9 @@ import { restore, visitQuestionAdhoc, getFullName, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID, USERS, USER_GROUPS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID, USERS, USER_GROUPS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ALL_USERS_GROUP } = USER_GROUPS; const { PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/permissions/reproductions/24966-saved-native-q-field-values.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/24966-saved-native-q-field-values.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/permissions/reproductions/24966-saved-native-q-field-values.cy.spec.js rename to e2e/test/scenarios/permissions/reproductions/24966-saved-native-q-field-values.cy.spec.js index 6395d0b3ebb6a..cf2ee4f836a32 100644 --- a/frontend/test/metabase/scenarios/permissions/reproductions/24966-saved-native-q-field-values.cy.spec.js +++ b/e2e/test/scenarios/permissions/reproductions/24966-saved-native-q-field-values.cy.spec.js @@ -4,9 +4,9 @@ import { visitDashboard, filterWidget, describeEE, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/permissions/sandboxes.cy.spec.js b/e2e/test/scenarios/permissions/sandboxes.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/permissions/sandboxes.cy.spec.js rename to e2e/test/scenarios/permissions/sandboxes.cy.spec.js index d658123c8aabd..fe3e0acea65f5 100644 --- a/frontend/test/metabase/scenarios/permissions/sandboxes.cy.spec.js +++ b/e2e/test/scenarios/permissions/sandboxes.cy.spec.js @@ -15,11 +15,11 @@ import { visitDashboard, startNewQuestion, sendEmailAndAssert, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USER_GROUPS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { USER_GROUPS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, diff --git a/frontend/test/metabase/scenarios/question/caching.cy.spec.js b/e2e/test/scenarios/question/caching.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/question/caching.cy.spec.js rename to e2e/test/scenarios/question/caching.cy.spec.js index 138376e18a4f7..6fb5f18ec904c 100644 --- a/frontend/test/metabase/scenarios/question/caching.cy.spec.js +++ b/e2e/test/scenarios/question/caching.cy.spec.js @@ -5,7 +5,7 @@ import { questionInfoButton, rightSidebar, popover, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describeEE("scenarios > question > caching", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/nested.cy.spec.js b/e2e/test/scenarios/question/nested.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/question/nested.cy.spec.js rename to e2e/test/scenarios/question/nested.cy.spec.js index d9267dc7ece1c..e68d79fe285bc 100644 --- a/frontend/test/metabase/scenarios/question/nested.cy.spec.js +++ b/e2e/test/scenarios/question/nested.cy.spec.js @@ -10,10 +10,10 @@ import { summarize, filter, filterField, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE } = SAMPLE_DATABASE; @@ -601,5 +601,10 @@ function visitNestedQueryAdHoc(id) { } function openHeaderCellContextMenu(cell) { - cy.findAllByTestId("header-cell").should("be.visible").contains(cell).click(); + cy.findByTestId("TableInteractive-root").within(() => { + cy.findAllByTestId("header-cell") + .should("be.visible") + .contains(cell) + .click(); + }); } diff --git a/frontend/test/metabase/scenarios/question/new.cy.spec.js b/e2e/test/scenarios/question/new.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/question/new.cy.spec.js rename to e2e/test/scenarios/question/new.cy.spec.js index be4d70f3af444..bc8ce2ee9e6b8 100644 --- a/frontend/test/metabase/scenarios/question/new.cy.spec.js +++ b/e2e/test/scenarios/question/new.cy.spec.js @@ -8,10 +8,10 @@ import { getCollectionIdFromSlug, saveQuestion, getPersonalCollectionName, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID, USERS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID, USERS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/question/notebook.cy.spec.js b/e2e/test/scenarios/question/notebook.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/question/notebook.cy.spec.js rename to e2e/test/scenarios/question/notebook.cy.spec.js index f7c073b93bb07..494ecca3390ce 100644 --- a/frontend/test/metabase/scenarios/question/notebook.cy.spec.js +++ b/e2e/test/scenarios/question/notebook.cy.spec.js @@ -15,10 +15,10 @@ import { summarize, visitQuestionAdhoc, visualize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PEOPLE, PEOPLE_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -97,7 +97,7 @@ describe("scenarios > question > notebook", () => { cy.findByText("ID between 96 97").click(); cy.findByText("Between").click(); - popover().within(() => { + cy.findByTestId("operator-select-list").within(() => { cy.contains("Is not"); cy.contains("Greater than"); cy.contains("Less than"); diff --git a/frontend/test/metabase/scenarios/question/nulls.cy.spec.js b/e2e/test/scenarios/question/nulls.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/question/nulls.cy.spec.js rename to e2e/test/scenarios/question/nulls.cy.spec.js index cc37faafb76f4..7611e195acd9e 100644 --- a/frontend/test/metabase/scenarios/question/nulls.cy.spec.js +++ b/e2e/test/scenarios/question/nulls.cy.spec.js @@ -2,12 +2,12 @@ import { restore, openOrdersTable, popover, - sidebar, summarize, visitDashboard, -} from "__support__/e2e/helpers"; + rightSidebar, +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -205,7 +205,7 @@ describe("scenarios > question > null", () => { openOrdersTable(); summarize(); - sidebar().within(() => { + rightSidebar().within(() => { // remove pre-selected "Count" cy.icon("close").click(); }); diff --git a/frontend/test/metabase/scenarios/question/query-external.cy.spec.js b/e2e/test/scenarios/question/query-external.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/question/query-external.cy.spec.js rename to e2e/test/scenarios/question/query-external.cy.spec.js index deb21fa85cdc3..9b6de4fc8af2a 100644 --- a/frontend/test/metabase/scenarios/question/query-external.cy.spec.js +++ b/e2e/test/scenarios/question/query-external.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, startNewQuestion, visualize } from "__support__/e2e/helpers"; +import { restore, startNewQuestion, visualize } from "e2e/support/helpers"; const supportedDatabases = [ { diff --git a/frontend/test/metabase/scenarios/question/question-management.cy.spec.js b/e2e/test/scenarios/question/question-management.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/question/question-management.cy.spec.js rename to e2e/test/scenarios/question/question-management.cy.spec.js index cb28106325bf9..2caffea8257cd 100644 --- a/frontend/test/metabase/scenarios/question/question-management.cy.spec.js +++ b/e2e/test/scenarios/question/question-management.cy.spec.js @@ -9,9 +9,9 @@ import { openQuestionActions, questionInfoButton, getPersonalCollectionName, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const PERMISSIONS = { curate: ["admin", "normal", "nodata"], diff --git a/frontend/test/metabase/scenarios/question/reproductions/13097-mongo-apply-distinct-count-multiple-columns.cy.spec.js b/e2e/test/scenarios/question/reproductions/13097-mongo-apply-distinct-count-multiple-columns.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/question/reproductions/13097-mongo-apply-distinct-count-multiple-columns.cy.spec.js rename to e2e/test/scenarios/question/reproductions/13097-mongo-apply-distinct-count-multiple-columns.cy.spec.js index fd549d1f33a68..d498abf12ab58 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/13097-mongo-apply-distinct-count-multiple-columns.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/13097-mongo-apply-distinct-count-multiple-columns.cy.spec.js @@ -4,7 +4,7 @@ import { withDatabase, adhocQuestionHash, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const MONGO_DB_ID = 2; diff --git a/frontend/test/metabase/scenarios/question/reproductions/13263-postgres-show-row-details-on-pk-click.cy.spec.js b/e2e/test/scenarios/question/reproductions/13263-postgres-show-row-details-on-pk-click.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/question/reproductions/13263-postgres-show-row-details-on-pk-click.cy.spec.js rename to e2e/test/scenarios/question/reproductions/13263-postgres-show-row-details-on-pk-click.cy.spec.js index 87d84a266a90b..78a7c4d26298b 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/13263-postgres-show-row-details-on-pk-click.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/13263-postgres-show-row-details-on-pk-click.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, startNewQuestion, visualize } from "__support__/e2e/helpers"; +import { restore, startNewQuestion, visualize } from "e2e/support/helpers"; const PG_DB_NAME = "QA Postgres12"; diff --git a/frontend/test/metabase/scenarios/question/reproductions/14957-unable-to-save-question-before-query-executed.cy.spec.js b/e2e/test/scenarios/question/reproductions/14957-unable-to-save-question-before-query-executed.cy.spec.js similarity index 86% rename from frontend/test/metabase/scenarios/question/reproductions/14957-unable-to-save-question-before-query-executed.cy.spec.js rename to e2e/test/scenarios/question/reproductions/14957-unable-to-save-question-before-query-executed.cy.spec.js index dbf2f24d4e05d..153415402f9a1 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/14957-unable-to-save-question-before-query-executed.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/14957-unable-to-save-question-before-query-executed.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, modal, openNativeEditor } from "__support__/e2e/helpers"; +import { restore, modal, openNativeEditor } from "e2e/support/helpers"; const PG_DB_NAME = "QA Postgres12"; diff --git a/frontend/test/metabase/scenarios/question/reproductions/15714-cc-postgres-percentile-accepts-two-params.cy.spec.js b/e2e/test/scenarios/question/reproductions/15714-cc-postgres-percentile-accepts-two-params.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/question/reproductions/15714-cc-postgres-percentile-accepts-two-params.cy.spec.js rename to e2e/test/scenarios/question/reproductions/15714-cc-postgres-percentile-accepts-two-params.cy.spec.js index 6c40065c7e68d..2ae5271bddf92 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/15714-cc-postgres-percentile-accepts-two-params.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/15714-cc-postgres-percentile-accepts-two-params.cy.spec.js @@ -2,7 +2,7 @@ import { enterCustomColumnDetails, restore, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const PG_DB_NAME = "QA Postgres12"; diff --git a/frontend/test/metabase/scenarios/question/reproductions/15876-postgres-cast-time.cy.spec.js b/e2e/test/scenarios/question/reproductions/15876-postgres-cast-time.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/question/reproductions/15876-postgres-cast-time.cy.spec.js rename to e2e/test/scenarios/question/reproductions/15876-postgres-cast-time.cy.spec.js index 11982320b8ce4..d745e3aae1f90 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/15876-postgres-cast-time.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/15876-postgres-cast-time.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; const PG_DB_ID = 2; diff --git a/frontend/test/metabase/scenarios/question/reproductions/17512.cy.spec.js b/e2e/test/scenarios/question/reproductions/17512.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/question/reproductions/17512.cy.spec.js rename to e2e/test/scenarios/question/reproductions/17512.cy.spec.js index cb53226d5654f..e01c613fe0671 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/17512.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/17512.cy.spec.js @@ -4,7 +4,7 @@ import { popover, visualize, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 17512", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/17514-ui-overlay.cy.spec.js b/e2e/test/scenarios/question/reproductions/17514-ui-overlay.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/question/reproductions/17514-ui-overlay.cy.spec.js rename to e2e/test/scenarios/question/reproductions/17514-ui-overlay.cy.spec.js index 3a3d5e7de1ba2..0ca2f08448f3b 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/17514-ui-overlay.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/17514-ui-overlay.cy.spec.js @@ -6,9 +6,9 @@ import { editDashboard, visualize, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { setAdHocFilter } from "../../native-filters/helpers/e2e-date-filter-helpers"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -154,7 +154,7 @@ function openVisualizationOptions() { function hideColumn(columnName) { cy.findByTestId("chartsettings-sidebar").within(() => { - cy.findByText(columnName).siblings(".Icon-eye_outline").click(); + cy.findByText(columnName).siblings("[data-testid$=hide-button]").click(); }); } diff --git a/frontend/test/metabase/scenarios/question/reproductions/17910-revision-history-update.cy.spec.js b/e2e/test/scenarios/question/reproductions/17910-revision-history-update.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/question/reproductions/17910-revision-history-update.cy.spec.js rename to e2e/test/scenarios/question/reproductions/17910-revision-history-update.cy.spec.js index 2930ff58ab29e..f1c2b18c43d48 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/17910-revision-history-update.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/17910-revision-history-update.cy.spec.js @@ -4,7 +4,7 @@ import { modal, questionInfoButton, rightSidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 17910", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/17963-mongo-filter-expression-compare-two-fields.cy.spec.js b/e2e/test/scenarios/question/reproductions/17963-mongo-filter-expression-compare-two-fields.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/question/reproductions/17963-mongo-filter-expression-compare-two-fields.cy.spec.js rename to e2e/test/scenarios/question/reproductions/17963-mongo-filter-expression-compare-two-fields.cy.spec.js index 256c61b8f9527..822d5972d2242 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/17963-mongo-filter-expression-compare-two-fields.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/17963-mongo-filter-expression-compare-two-fields.cy.spec.js @@ -3,7 +3,7 @@ import { popover, visualize, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 17963", { tags: "@external" }, () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/18207-string-min-max.cy.spec.js b/e2e/test/scenarios/question/reproductions/18207-string-min-max.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/question/reproductions/18207-string-min-max.cy.spec.js rename to e2e/test/scenarios/question/reproductions/18207-string-min-max.cy.spec.js index 6cc3e99669b2e..fe4efa9980f2f 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/18207-string-min-max.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/18207-string-min-max.cy.spec.js @@ -5,8 +5,8 @@ import { visualize, openProductsTable, summarize, - sidebar, -} from "__support__/e2e/helpers"; + leftSidebar, +} from "e2e/support/helpers"; describe("issue 18207", () => { beforeEach(() => { @@ -66,7 +66,7 @@ describe("issue 18207", () => { // Why is it not a table? cy.contains("Visualization").click(); - sidebar().within(() => { + leftSidebar().within(() => { cy.icon("table").click(); cy.findByTestId("Table-button").realHover(); cy.icon("gear").click(); diff --git a/frontend/test/metabase/scenarios/question/reproductions/18978-18977-nested-question-nodata-user.cy.spec.js b/e2e/test/scenarios/question/reproductions/18978-18977-nested-question-nodata-user.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/question/reproductions/18978-18977-nested-question-nodata-user.cy.spec.js rename to e2e/test/scenarios/question/reproductions/18978-18977-nested-question-nodata-user.cy.spec.js index ca23421c1e78f..ffe2a4062bce9 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/18978-18977-nested-question-nodata-user.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/18978-18977-nested-question-nodata-user.cy.spec.js @@ -3,10 +3,10 @@ import { appBar, popover, openNavigationSidebar, - sidebar, + leftSidebar, visitQuestion, POPOVER_ELEMENT, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("11914, 18978, 18977", () => { beforeEach(() => { @@ -75,7 +75,7 @@ describe("11914, 18978, 18977", () => { function setVisualizationTo(vizName) { cy.findByTestId("viz-type-button").click(); - sidebar().within(() => { + leftSidebar().within(() => { cy.icon(vizName).click(); cy.icon(vizName).realHover(); cy.icon("gear").click(); @@ -83,17 +83,17 @@ function setVisualizationTo(vizName) { }); selectFromDropdown("Created At"); - sidebar().within(() => { + leftSidebar().within(() => { cy.findByText("Y-axis").parent().findByText("Select a field").click(); }); selectFromDropdown("Quantity"); - sidebar().findByText("Done").click(); + leftSidebar().findByText("Done").click(); } function addGoalLine() { cy.findByTestId("viz-settings-button").click(); - sidebar().within(() => { + leftSidebar().within(() => { cy.findByText("Display").click(); cy.findByText("Goal line").parent().find("input").click(); cy.findByText("Done").click(); diff --git a/frontend/test/metabase/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js b/e2e/test/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js rename to e2e/test/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js index 1ca4b3713bec0..94aa7079da313 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/19341-disabled-nested-queries.cy.spec.js @@ -3,7 +3,7 @@ import { mockSessionProperty, popover, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 19341", () => { const TEST_NATIVE_QUESTION_NAME = "Native"; diff --git a/frontend/test/metabase/scenarios/question/reproductions/19742-data-picker-closes-after-hiding-table.cy.spec.js b/e2e/test/scenarios/question/reproductions/19742-data-picker-closes-after-hiding-table.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/question/reproductions/19742-data-picker-closes-after-hiding-table.cy.spec.js rename to e2e/test/scenarios/question/reproductions/19742-data-picker-closes-after-hiding-table.cy.spec.js index ac628a3571f30..f64d631d4c3f1 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/19742-data-picker-closes-after-hiding-table.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/19742-data-picker-closes-after-hiding-table.cy.spec.js @@ -1,8 +1,4 @@ -import { - restore, - popover, - openNavigationSidebar, -} from "__support__/e2e/helpers"; +import { restore, popover, openNavigationSidebar } from "e2e/support/helpers"; describe("issue 19742", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/20627-nested-long-names-wrong-aliases.cy.spec.js b/e2e/test/scenarios/question/reproductions/20627-nested-long-names-wrong-aliases.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/question/reproductions/20627-nested-long-names-wrong-aliases.cy.spec.js rename to e2e/test/scenarios/question/reproductions/20627-nested-long-names-wrong-aliases.cy.spec.js index 31abb6fec617d..2cd8aea575d63 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/20627-nested-long-names-wrong-aliases.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/20627-nested-long-names-wrong-aliases.cy.spec.js @@ -4,9 +4,9 @@ import { popover, enterCustomColumnDetails, visualize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/question/reproductions/20809-nesting-explicit-implicit-filter.cy.spec.js b/e2e/test/scenarios/question/reproductions/20809-nesting-explicit-implicit-filter.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/question/reproductions/20809-nesting-explicit-implicit-filter.cy.spec.js rename to e2e/test/scenarios/question/reproductions/20809-nesting-explicit-implicit-filter.cy.spec.js index 6681bb3ee3108..f87918db2cb0d 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/20809-nesting-explicit-implicit-filter.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/20809-nesting-explicit-implicit-filter.cy.spec.js @@ -3,10 +3,10 @@ import { visualize, visitQuestionAdhoc, enterCustomColumnDetails, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS, REVIEWS_ID, PRODUCTS, ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/question/reproductions/22247-timeseries-filter-all-time.cy.spec.js b/e2e/test/scenarios/question/reproductions/22247-timeseries-filter-all-time.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/question/reproductions/22247-timeseries-filter-all-time.cy.spec.js rename to e2e/test/scenarios/question/reproductions/22247-timeseries-filter-all-time.cy.spec.js index dacdb9ba95818..2f39274414d56 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/22247-timeseries-filter-all-time.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/22247-timeseries-filter-all-time.cy.spec.js @@ -4,7 +4,7 @@ import { openProductsTable, summarize, sidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("time-series filter widget", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/22285-schema-picker.cy.spec.js b/e2e/test/scenarios/question/reproductions/22285-schema-picker.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/question/reproductions/22285-schema-picker.cy.spec.js rename to e2e/test/scenarios/question/reproductions/22285-schema-picker.cy.spec.js index a1530366f451e..7a60b2edc14d2 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/22285-schema-picker.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/22285-schema-picker.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, startNewQuestion, popover } from "__support__/e2e/helpers"; +import { restore, startNewQuestion, popover } from "e2e/support/helpers"; describe("issue 22285", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/23023-preview-shows-hidden-columns.cy.spec.js b/e2e/test/scenarios/question/reproductions/23023-preview-shows-hidden-columns.cy.spec.js similarity index 83% rename from frontend/test/metabase/scenarios/question/reproductions/23023-preview-shows-hidden-columns.cy.spec.js rename to e2e/test/scenarios/question/reproductions/23023-preview-shows-hidden-columns.cy.spec.js index 23b45fa16a22a..b2889bd4c97dd 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/23023-preview-shows-hidden-columns.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/23023-preview-shows-hidden-columns.cy.spec.js @@ -1,10 +1,6 @@ -import { - restore, - visitQuestionAdhoc, - openNotebook, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestionAdhoc, openNotebook } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/question/reproductions/24839-summarize-source-question-with-summarization.cy.spec.js b/e2e/test/scenarios/question/reproductions/24839-summarize-source-question-with-summarization.cy.spec.js similarity index 87% rename from frontend/test/metabase/scenarios/question/reproductions/24839-summarize-source-question-with-summarization.cy.spec.js rename to e2e/test/scenarios/question/reproductions/24839-summarize-source-question-with-summarization.cy.spec.js index bfcf68b3d0453..18b6fd36df40e 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/24839-summarize-source-question-with-summarization.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/24839-summarize-source-question-with-summarization.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitQuestionAdhoc, popover } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestionAdhoc, popover } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/question/reproductions/25016-column-filter-multi-stage-query.cy.spec.js b/e2e/test/scenarios/question/reproductions/25016-column-filter-multi-stage-query.cy.spec.js similarity index 85% rename from frontend/test/metabase/scenarios/question/reproductions/25016-column-filter-multi-stage-query.cy.spec.js rename to e2e/test/scenarios/question/reproductions/25016-column-filter-multi-stage-query.cy.spec.js index 4e18f03fcf7fb..9384a27ad3e92 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/25016-column-filter-multi-stage-query.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/25016-column-filter-multi-stage-query.cy.spec.js @@ -1,6 +1,6 @@ -import { popover, restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { popover, restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/question/reproductions/25144-saved-questions-first-question.cy.spec.js b/e2e/test/scenarios/question/reproductions/25144-saved-questions-first-question.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/question/reproductions/25144-saved-questions-first-question.cy.spec.js rename to e2e/test/scenarios/question/reproductions/25144-saved-questions-first-question.cy.spec.js index cdd327eef0185..f4e532a9ff608 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/25144-saved-questions-first-question.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/25144-saved-questions-first-question.cy.spec.js @@ -1,4 +1,4 @@ -import { modal, popover, restore } from "__support__/e2e/helpers"; +import { modal, popover, restore } from "e2e/support/helpers"; describe("issue 25144", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/27462-no-field-options-for-double-aggregations.cy.spec.js b/e2e/test/scenarios/question/reproductions/27462-no-field-options-for-double-aggregations.cy.spec.js similarity index 81% rename from frontend/test/metabase/scenarios/question/reproductions/27462-no-field-options-for-double-aggregations.cy.spec.js rename to e2e/test/scenarios/question/reproductions/27462-no-field-options-for-double-aggregations.cy.spec.js index 1a1a1dd9d17b0..a96e7e67fbfac 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/27462-no-field-options-for-double-aggregations.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/27462-no-field-options-for-double-aggregations.cy.spec.js @@ -1,7 +1,7 @@ -import { visitQuestionAdhoc, restore, popover } from "__support__/e2e/helpers"; +import { visitQuestionAdhoc, restore, popover } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/question/reproductions/28221-missing-custom-field-metadata.cy.spec.js b/e2e/test/scenarios/question/reproductions/28221-missing-custom-field-metadata.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/question/reproductions/28221-missing-custom-field-metadata.cy.spec.js rename to e2e/test/scenarios/question/reproductions/28221-missing-custom-field-metadata.cy.spec.js index ac6d7d94af34f..de9d2bfd578ae 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/28221-missing-custom-field-metadata.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/28221-missing-custom-field-metadata.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID, PRODUCTS, ORDERS_ID, ORDERS } = SAMPLE_DATABASE; diff --git a/e2e/test/scenarios/question/reproductions/28874-notebook-pivot.cy.spec.js b/e2e/test/scenarios/question/reproductions/28874-notebook-pivot.cy.spec.js new file mode 100644 index 0000000000000..cf0cafc8da333 --- /dev/null +++ b/e2e/test/scenarios/question/reproductions/28874-notebook-pivot.cy.spec.js @@ -0,0 +1,39 @@ +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; + +const questionDetails = { + name: "28874", + display: "pivot", + dataset_query: { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [ + ["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }], + ["field", ORDERS.PRODUCT_ID, null], + ], + }, + }, +}; + +describe("issue 28874", () => { + beforeEach(() => { + restore(); + cy.signInAsNormalUser(); + }); + + it("should allow to modify a pivot question in the notebook (metabase#28874)", () => { + visitQuestionAdhoc(questionDetails, { mode: "notebook" }); + + cy.findByText("Product ID") + .parent() + .within(() => cy.icon("close").click()); + + cy.findByText("Product ID").should("not.exist"); + }); +}); diff --git a/e2e/test/scenarios/question/reproductions/29082-quick-filter-null.cy.spec.js b/e2e/test/scenarios/question/reproductions/29082-quick-filter-null.cy.spec.js new file mode 100644 index 0000000000000..2e0d8f8962585 --- /dev/null +++ b/e2e/test/scenarios/question/reproductions/29082-quick-filter-null.cy.spec.js @@ -0,0 +1,47 @@ +import { popover, restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; + +const questionDetails = { + name: "22788", + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + filter: ["=", ["field", ORDERS.USER_ID, null], 1], + }, + }, +}; + +describe("issue 29082", () => { + beforeEach(() => { + restore(); + cy.signInAsNormalUser(); + cy.intercept("POST", "/api/dataset").as("dataset"); + }); + + it("should handle nulls in quick filters (metabase#29082)", () => { + visitQuestionAdhoc(questionDetails); + cy.wait("@dataset"); + cy.findByText("Showing 11 rows").should("exist"); + + cy.get(".TableInteractive-emptyCell").first().click(); + popover().within(() => cy.findByText("=").click()); + cy.wait("@dataset"); + cy.findByText("Showing 8 rows").should("exist"); + cy.findByText("Discount is empty").should("exist"); + + cy.findByText("Discount is empty").within(() => cy.icon("close").click()); + cy.wait("@dataset"); + cy.findByText("Showing 11 rows").should("exist"); + + cy.get(".TableInteractive-emptyCell").first().click(); + popover().within(() => cy.findByText("≠").click()); + cy.wait("@dataset"); + cy.findByText("Showing 3 rows").should("exist"); + cy.findByText("Discount is not empty").should("exist"); + }); +}); diff --git a/frontend/test/metabase/scenarios/question/reproductions/4482-temporal-min-max.cy.spec.js b/e2e/test/scenarios/question/reproductions/4482-temporal-min-max.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/question/reproductions/4482-temporal-min-max.cy.spec.js rename to e2e/test/scenarios/question/reproductions/4482-temporal-min-max.cy.spec.js index bb7c81fff24f8..62b9ac0f12237 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/4482-temporal-min-max.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/4482-temporal-min-max.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visualize, startNewQuestion } from "__support__/e2e/helpers"; +import { restore, visualize, startNewQuestion } from "e2e/support/helpers"; describe("issue 4482", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/6239-sort-using-cust-exp.cy.spec.js b/e2e/test/scenarios/question/reproductions/6239-sort-using-cust-exp.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/question/reproductions/6239-sort-using-cust-exp.cy.spec.js rename to e2e/test/scenarios/question/reproductions/6239-sort-using-cust-exp.cy.spec.js index 7954a80bb8f47..8e33c7e9dee51 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/6239-sort-using-cust-exp.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/6239-sort-using-cust-exp.cy.spec.js @@ -4,7 +4,7 @@ import { restore, visualize, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("issue 6239", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/reproductions/9027-new-questions-not-in-saved-questions-immediately.cy.spec.js b/e2e/test/scenarios/question/reproductions/9027-new-questions-not-in-saved-questions-immediately.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/question/reproductions/9027-new-questions-not-in-saved-questions-immediately.cy.spec.js rename to e2e/test/scenarios/question/reproductions/9027-new-questions-not-in-saved-questions-immediately.cy.spec.js index e52ed32dd52c3..d7ba6c075d56a 100644 --- a/frontend/test/metabase/scenarios/question/reproductions/9027-new-questions-not-in-saved-questions-immediately.cy.spec.js +++ b/e2e/test/scenarios/question/reproductions/9027-new-questions-not-in-saved-questions-immediately.cy.spec.js @@ -5,7 +5,7 @@ import { startNewQuestion, openNavigationSidebar, navigationSidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const QUESTION_NAME = "Foo"; diff --git a/frontend/test/metabase/scenarios/question/saved.cy.spec.js b/e2e/test/scenarios/question/saved.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/question/saved.cy.spec.js rename to e2e/test/scenarios/question/saved.cy.spec.js index e9271c8dc0e72..91b58013ff808 100644 --- a/frontend/test/metabase/scenarios/question/saved.cy.spec.js +++ b/e2e/test/scenarios/question/saved.cy.spec.js @@ -10,7 +10,7 @@ import { rightSidebar, appbar, getCollectionIdFromSlug, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > question > saved", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/question/settings.cy.spec.js b/e2e/test/scenarios/question/settings.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/question/settings.cy.spec.js rename to e2e/test/scenarios/question/settings.cy.spec.js index 231a831a766d0..8868611e7d89c 100644 --- a/frontend/test/metabase/scenarios/question/settings.cy.spec.js +++ b/e2e/test/scenarios/question/settings.cy.spec.js @@ -6,10 +6,10 @@ import { visitQuestionAdhoc, popover, sidebar, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -38,14 +38,14 @@ describe("scenarios > question > settings", () => { cy.get("@tableOptions") .contains("Total") .scrollIntoView() - .nextAll(".Icon-eye_outline") + .siblings("[data-testid$=hide-button]") .click(); // Add people.category cy.get("@tableOptions") .contains("Category") .scrollIntoView() - .nextAll(".Icon-add") + .siblings("[data-testid$=add-button]") .click(); // wait a Category value to appear in the table, so we know the query completed @@ -55,7 +55,7 @@ describe("scenarios > question > settings", () => { cy.get("@tableOptions") .contains("Ean") .scrollIntoView() - .nextAll(".Icon-add") + .siblings("[data-testid$=add-button]") .click(); // wait a Ean value to appear in the table, so we know the query completed @@ -185,7 +185,7 @@ describe("scenarios > question > settings", () => { cy.findByText("Date style"); // shows created_at column settings }); - it.skip("should respect renamed column names in the settings sidebar (metabase#18476)", () => { + it("should respect renamed column names in the settings sidebar (metabase#18476)", () => { const newColumnTitle = "Pre-tax"; const questionDetails = { diff --git a/frontend/test/metabase/scenarios/question/summarization.cy.spec.js b/e2e/test/scenarios/question/summarization.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/question/summarization.cy.spec.js rename to e2e/test/scenarios/question/summarization.cy.spec.js index 01f92e8eb82e9..937f591e0b663 100644 --- a/frontend/test/metabase/scenarios/question/summarization.cy.spec.js +++ b/e2e/test/scenarios/question/summarization.cy.spec.js @@ -10,9 +10,9 @@ import { openOrdersTable, enterCustomColumnDetails, visualize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -71,7 +71,7 @@ describe("scenarios > question > summarize sidebar", () => { }); }); - it("selected dimensions from another table includes the table name when becomes pinned to the top", () => { + it("selected dimensions from another table includes the table alias when becomes pinned to the top", () => { getDimensionByName({ name: "State" }).click(); cy.button("Done").click(); @@ -79,16 +79,16 @@ describe("scenarios > question > summarize sidebar", () => { summarize(); cy.findByTestId("pinned-dimensions").within(() => { - getDimensionByName({ name: "People → State" }).should( + getDimensionByName({ name: "User → State" }).should( "have.attr", "aria-selected", "true", ); }); - getRemoveDimensionButton({ name: "People → State" }).click(); + getRemoveDimensionButton({ name: "User → State" }).click(); - cy.findByText("People → State").should("not.exist"); + cy.findByText("User → State").should("not.exist"); }); it("selecting a binning adds a dimension", () => { @@ -174,19 +174,24 @@ describe("scenarios > question > summarize sidebar", () => { cy.findByText("318.7"); }); - it.skip("should keep manually entered parenthesis intact (metabase#13306)", () => { - const FORMULA = - "Sum([Total]) / (Sum([Product → Price]) * Average([Quantity]))"; - + it("should keep manually entered parenthesis intact if they affect the result (metabase#13306)", () => { openOrdersTable({ mode: "notebook" }); summarize({ mode: "notebook" }); + popover().contains("Custom Expression").click(); popover().within(() => { - cy.get(".ace_text-input").type(FORMULA).blur(); + enterCustomColumnDetails({ + formula: + "sum([Total]) / (sum([Product → Price]) * average([Quantity]))", + }); + cy.get("@formula").blur(); + }); - cy.log("Fails after blur in v0.36.6"); - // Implicit assertion - cy.contains(FORMULA); + popover().within(() => { + cy.get(".ace_text-layer").should( + "have.text", + "Sum([Total]) / (Sum([Product → Price]) * Average([Quantity]))", + ); }); }); @@ -201,7 +206,7 @@ describe("scenarios > question > summarize sidebar", () => { "**The point of failure for ANY non-numeric value reported in v0.36.4**", ); // the default type for "Reviewer" is "No semantic type" - popover().within(() => { + cy.findByTestId("expression-suggestions-list").within(() => { cy.contains("Reviewer"); }); }); diff --git a/frontend/test/metabase/scenarios/sharing/alert/alert-permissions.cy.spec.js b/e2e/test/scenarios/sharing/alert/alert-permissions.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/sharing/alert/alert-permissions.cy.spec.js rename to e2e/test/scenarios/sharing/alert/alert-permissions.cy.spec.js index 78bf0a8401e8e..1e0da81124a8b 100644 --- a/frontend/test/metabase/scenarios/sharing/alert/alert-permissions.cy.spec.js +++ b/e2e/test/scenarios/sharing/alert/alert-permissions.cy.spec.js @@ -3,8 +3,8 @@ import { setupSMTP, visitQuestion, getFullName, -} from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const { normal, admin } = USERS; diff --git a/frontend/test/metabase/scenarios/sharing/alert/alert-types.cy.spec.js b/e2e/test/scenarios/sharing/alert/alert-types.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/sharing/alert/alert-types.cy.spec.js rename to e2e/test/scenarios/sharing/alert/alert-types.cy.spec.js index f59d30be8440b..78d06295ec931 100644 --- a/frontend/test/metabase/scenarios/sharing/alert/alert-types.cy.spec.js +++ b/e2e/test/scenarios/sharing/alert/alert-types.cy.spec.js @@ -2,10 +2,10 @@ import { restore, setupSMTP, visitQuestion, - sidebar, -} from "__support__/e2e/helpers"; + leftSidebar, +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; @@ -73,7 +73,7 @@ describe("scenarios > alert > types", { tags: "@external" }, () => { visitQuestion(timeSeriesQuestionId); cy.findByText("Visualization").click(); - sidebar().within(() => { + leftSidebar().within(() => { cy.icon("line").realHover(); cy.icon("gear").click(); }); diff --git a/frontend/test/metabase/scenarios/sharing/alert/alert.cy.spec.js b/e2e/test/scenarios/sharing/alert/alert.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/sharing/alert/alert.cy.spec.js rename to e2e/test/scenarios/sharing/alert/alert.cy.spec.js index e69a950fafba3..304e0bb55131f 100644 --- a/frontend/test/metabase/scenarios/sharing/alert/alert.cy.spec.js +++ b/e2e/test/scenarios/sharing/alert/alert.cy.spec.js @@ -3,7 +3,7 @@ import { setupSMTP, mockSlackConfigured, visitQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const channels = { slack: mockSlackConfigured, email: setupSMTP }; diff --git a/frontend/test/metabase/scenarios/sharing/alert/email-alert.cy.spec.js b/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/sharing/alert/email-alert.cy.spec.js rename to e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js index 7696db5ca25b2..1d5ffc8e4effc 100644 --- a/frontend/test/metabase/scenarios/sharing/alert/email-alert.cy.spec.js +++ b/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, setupSMTP, visitQuestion } from "__support__/e2e/helpers"; +import { restore, setupSMTP, visitQuestion } from "e2e/support/helpers"; describe("scenarios > alert > email_alert", { tags: "@external" }, () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/sharing/approved-domains.cy.spec.js b/e2e/test/scenarios/sharing/approved-domains.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/sharing/approved-domains.cy.spec.js rename to e2e/test/scenarios/sharing/approved-domains.cy.spec.js index d9d5677dae2fa..6c6bd78234eee 100644 --- a/frontend/test/metabase/scenarios/sharing/approved-domains.cy.spec.js +++ b/e2e/test/scenarios/sharing/approved-domains.cy.spec.js @@ -5,7 +5,7 @@ import { sidebar, visitQuestion, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const allowedDomain = "metabase.test"; const deniedDomain = "metabase.example"; diff --git a/frontend/test/metabase/scenarios/sharing/public-question.cy.spec.js b/e2e/test/scenarios/sharing/public-question.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/sharing/public-question.cy.spec.js rename to e2e/test/scenarios/sharing/public-question.cy.spec.js index b3152f27801b5..dd9a3f13ae288 100644 --- a/frontend/test/metabase/scenarios/sharing/public-question.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-question.cy.spec.js @@ -4,8 +4,8 @@ import { visitQuestion, downloadAndAssert, assertSheetRowsCount, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/sharing/public.cy.spec.js b/e2e/test/scenarios/sharing/public.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/sharing/public.cy.spec.js rename to e2e/test/scenarios/sharing/public.cy.spec.js index b7101b0ae2e9d..e939e03b2573b 100644 --- a/frontend/test/metabase/scenarios/sharing/public.cy.spec.js +++ b/e2e/test/scenarios/sharing/public.cy.spec.js @@ -5,9 +5,9 @@ import { visitQuestion, visitDashboard, openQuestionActions, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/sharing/pulse.cy.spec.js b/e2e/test/scenarios/sharing/pulse.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/sharing/pulse.cy.spec.js rename to e2e/test/scenarios/sharing/pulse.cy.spec.js index 2b5f6dcad0d63..a01a7d6e48104 100644 --- a/frontend/test/metabase/scenarios/sharing/pulse.cy.spec.js +++ b/e2e/test/scenarios/sharing/pulse.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, setupSMTP } from "__support__/e2e/helpers"; +import { restore, setupSMTP } from "e2e/support/helpers"; describe("scenarios > pulse", { tags: "@external" }, () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/16108-missing-tooltip.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/16108-missing-tooltip.cy.spec.js similarity index 86% rename from frontend/test/metabase/scenarios/sharing/reproductions/16108-missing-tooltip.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/16108-missing-tooltip.cy.spec.js index f8f490ae7e5f9..96626ff8858cd 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/16108-missing-tooltip.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/16108-missing-tooltip.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visitQuestion } from "__support__/e2e/helpers"; +import { restore, visitQuestion } from "e2e/support/helpers"; describe("issue 16108", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/16918.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/16918.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/sharing/reproductions/16918.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/16918.cy.spec.js index 162cbc0efbf66..b12ade1b47e5b 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/16918.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/16918.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/17547.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/17547.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/sharing/reproductions/17547.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/17547.cy.spec.js index 0d0b0830774cf..09cea080302e1 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/17547.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/17547.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, popover, visitQuestion } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, popover, visitQuestion } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/17657.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/17657.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/sharing/reproductions/17657.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/17657.cy.spec.js index 0fc9ddb888b80..1b74137d6f096 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/17657.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/17657.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, sidebar, visitDashboard } from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { restore, sidebar, visitDashboard } from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const { admin: { first_name, last_name }, diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/17658.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/17658.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/sharing/reproductions/17658.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/17658.cy.spec.js index eb08de0bc4b7e..61c47b47c10f1 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/17658.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/17658.cy.spec.js @@ -3,9 +3,9 @@ import { setupSMTP, visitDashboard, getFullName, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const { admin } = USERS; diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/18009-nodata-creates-subscription-receives-error.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/18009-nodata-creates-subscription-receives-error.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/sharing/reproductions/18009-nodata-creates-subscription-receives-error.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/18009-nodata-creates-subscription-receives-error.cy.spec.js index 9a7f837ace51b..c91941bf7d6de 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/18009-nodata-creates-subscription-receives-error.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/18009-nodata-creates-subscription-receives-error.cy.spec.js @@ -4,9 +4,9 @@ import { setupSMTP, visitDashboard, sendEmailAndAssert, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -describe.skip("issue 18009", { tags: "@external" }, () => { +describe("issue 18009", { tags: "@external" }, () => { beforeEach(() => { restore(); cy.signInAsAdmin(); diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/18344-subscription-shows-original-question-name.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/18344-subscription-shows-original-question-name.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/sharing/reproductions/18344-subscription-shows-original-question-name.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/18344-subscription-shows-original-question-name.cy.spec.js index a9595192c4bd2..23cbff560adab 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/18344-subscription-shows-original-question-name.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/18344-subscription-shows-original-question-name.cy.spec.js @@ -5,9 +5,9 @@ import { setupSMTP, visitDashboard, sendEmailAndAssert, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +import { USERS } from "e2e/support/cypress_data"; const { admin: { first_name, last_name }, diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/18352-subscription-int64-value-card.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/18352-subscription-int64-value-card.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/sharing/reproductions/18352-subscription-int64-value-card.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/18352-subscription-int64-value-card.cy.spec.js index aadaa93882507..93422f0d4baba 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/18352-subscription-int64-value-card.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/18352-subscription-int64-value-card.cy.spec.js @@ -4,8 +4,8 @@ import { visitQuestion, visitDashboard, sendEmailAndAssert, -} from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const { admin: { first_name, last_name }, diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/18669-test-email-with-parameters.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/18669-test-email-with-parameters.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/sharing/reproductions/18669-test-email-with-parameters.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/18669-test-email-with-parameters.cy.spec.js index d78e39212bb69..648ee33f24f39 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/18669-test-email-with-parameters.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/18669-test-email-with-parameters.cy.spec.js @@ -6,10 +6,10 @@ import { sidebar, visitDashboard, clickSend, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { admin } = USERS; const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/20393-public-dashboard-nested-card-with-parameters.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/20393-public-dashboard-nested-card-with-parameters.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/sharing/reproductions/20393-public-dashboard-nested-card-with-parameters.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/20393-public-dashboard-nested-card-with-parameters.cy.spec.js index f368ee1d8330a..d713035a9a96f 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/20393-public-dashboard-nested-card-with-parameters.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/20393-public-dashboard-nested-card-with-parameters.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover, visitDashboard } from "__support__/e2e/helpers"; +import { restore, popover, visitDashboard } from "e2e/support/helpers"; describe("issue 20393", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/21559-subscription-bar-sent-as-scalar.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/21559-subscription-bar-sent-as-scalar.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/sharing/reproductions/21559-subscription-bar-sent-as-scalar.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/21559-subscription-bar-sent-as-scalar.cy.spec.js index 4c9b034117f1f..3c28e26f13fb7 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/21559-subscription-bar-sent-as-scalar.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/21559-subscription-bar-sent-as-scalar.cy.spec.js @@ -5,10 +5,10 @@ import { saveDashboard, setupSMTP, sendEmailAndAssert, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { USERS } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { admin } = USERS; diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/22524-public-dashboard-updates-after-changing-parameters.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/22524-public-dashboard-updates-after-changing-parameters.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/sharing/reproductions/22524-public-dashboard-updates-after-changing-parameters.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/22524-public-dashboard-updates-after-changing-parameters.cy.spec.js index 14936b8003f61..3b5c7f6bbf004 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/22524-public-dashboard-updates-after-changing-parameters.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/22524-public-dashboard-updates-after-changing-parameters.cy.spec.js @@ -5,7 +5,7 @@ import { saveDashboard, editDashboard, setFilter, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const questionDetails = { name: "22524 question", diff --git a/frontend/test/metabase/scenarios/sharing/reproductions/25473-dashboard-text-filter-asking-for-number.cy.spec.js b/e2e/test/scenarios/sharing/reproductions/25473-dashboard-text-filter-asking-for-number.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/sharing/reproductions/25473-dashboard-text-filter-asking-for-number.cy.spec.js rename to e2e/test/scenarios/sharing/reproductions/25473-dashboard-text-filter-asking-for-number.cy.spec.js index 916ec8300fa2e..f07bf817eddea 100644 --- a/frontend/test/metabase/scenarios/sharing/reproductions/25473-dashboard-text-filter-asking-for-number.cy.spec.js +++ b/e2e/test/scenarios/sharing/reproductions/25473-dashboard-text-filter-asking-for-number.cy.spec.js @@ -3,9 +3,9 @@ import { visitEmbeddedPage, filterWidget, visitPublicDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/sharing/subscriptions.cy.spec.js b/e2e/test/scenarios/sharing/subscriptions.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/sharing/subscriptions.cy.spec.js rename to e2e/test/scenarios/sharing/subscriptions.cy.spec.js index 1a5e567f1e368..32cb12f4e819e 100644 --- a/frontend/test/metabase/scenarios/sharing/subscriptions.cy.spec.js +++ b/e2e/test/scenarios/sharing/subscriptions.cy.spec.js @@ -8,8 +8,8 @@ import { isOSS, visitDashboard, sendEmailAndAssert, -} from "__support__/e2e/helpers"; -import { USERS } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { USERS } from "e2e/support/cypress_data"; const { admin } = USERS; diff --git a/frontend/test/metabase/scenarios/visualizations/bar_chart.cy.spec.js b/e2e/test/scenarios/visualizations/bar_chart.cy.spec.js similarity index 75% rename from frontend/test/metabase/scenarios/visualizations/bar_chart.cy.spec.js rename to e2e/test/scenarios/visualizations/bar_chart.cy.spec.js index 5054bd7016b8e..bb5e314dbc8f5 100644 --- a/frontend/test/metabase/scenarios/visualizations/bar_chart.cy.spec.js +++ b/e2e/test/scenarios/visualizations/bar_chart.cy.spec.js @@ -5,12 +5,12 @@ import { getDraggableElements, moveColumnDown, popover, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; -const { ORDERS, ORDERS_ID, PEOPLE, PRODUCTS } = SAMPLE_DATABASE; +const { ORDERS, ORDERS_ID, PEOPLE, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; describe("scenarios > visualizations > bar chart", () => { beforeEach(() => { @@ -224,4 +224,65 @@ describe("scenarios > visualizations > bar chart", () => { getDraggableElements().eq(3).should("have.text", "Gadget"); }); }); + + describe("with stacked bars", () => { + it("should drill-through correctly when stacking", () => { + visitQuestionAdhoc({ + dataset_query: { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": PRODUCTS_ID, + aggregation: [["count"]], + breakout: [ + ["field", PRODUCTS.CATEGORY], + ["field", PRODUCTS.CREATED_AT, { "temporal-unit": "month" }], + ], + }, + }, + display: "bar", + visualization_settings: { "stackable.stack_type": "stacked" }, + }); + + cy.findAllByTestId("legend-item").findByText("Doohickey").click(); + cy.findByText("View these Products").click(); + + cy.findByText("Category is Doohickey").should("be.visible"); + }); + }); + + it("supports up to 100 series (metabase#28796)", () => { + visitQuestionAdhoc({ + display: "bar", + dataset_query: { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + filter: ["and", ["<", ["field", ORDERS.ID, null], 101]], + breakout: [ + ["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }], + ["field", ORDERS.ID], + ], + }, + }, + visualization_settings: { + "graph.dimensions": ["CREATED_AT", "SUBTOTAL"], + "graph.metrics": ["count"], + }, + }); + + cy.findByTestId("viz-settings-button").click(); + cy.get("[data-testid^=draggable-item]").should("have.length", 100); + + cy.findByText("ID is less than 101").click(); + cy.findByDisplayValue("101").type("{backspace}2"); + cy.findByText("Update filter").click(); + + cy.findByText( + "This chart type doesn't support more than 100 series of data.", + ); + cy.get("[data-testid^=draggable-item]").should("have.length", 0); + }); }); diff --git a/frontend/test/metabase/scenarios/visualizations/combo.cy.spec.js b/e2e/test/scenarios/visualizations/combo.cy.spec.js similarity index 81% rename from frontend/test/metabase/scenarios/visualizations/combo.cy.spec.js rename to e2e/test/scenarios/visualizations/combo.cy.spec.js index 6eadb566aa8f9..bd3238528966d 100644 --- a/frontend/test/metabase/scenarios/visualizations/combo.cy.spec.js +++ b/e2e/test/scenarios/visualizations/combo.cy.spec.js @@ -1,7 +1,7 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js b/e2e/test/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js rename to e2e/test/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js index 5ef8ecc90218a..923c74165cf08 100644 --- a/frontend/test/metabase/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js +++ b/e2e/test/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js @@ -10,10 +10,10 @@ import { visitQuestion, visitDashboard, startNewQuestion, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USER_GROUPS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { USER_GROUPS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/drillthroughs/dash_drill.cy.spec.js b/e2e/test/scenarios/visualizations/drillthroughs/dash_drill.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/visualizations/drillthroughs/dash_drill.cy.spec.js rename to e2e/test/scenarios/visualizations/drillthroughs/dash_drill.cy.spec.js index 36a59db9332bc..d0b5f15974458 100644 --- a/frontend/test/metabase/scenarios/visualizations/drillthroughs/dash_drill.cy.spec.js +++ b/e2e/test/scenarios/visualizations/drillthroughs/dash_drill.cy.spec.js @@ -1,8 +1,8 @@ // Imported from drillthroughs.e2e.spec.js -import { restore, visitDashboard } from "__support__/e2e/helpers"; +import { restore, visitDashboard } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/funnel.cy.spec.js b/e2e/test/scenarios/visualizations/funnel.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/visualizations/funnel.cy.spec.js rename to e2e/test/scenarios/visualizations/funnel.cy.spec.js index 1ed50c5142920..1e9a254cff617 100644 --- a/frontend/test/metabase/scenarios/visualizations/funnel.cy.spec.js +++ b/e2e/test/scenarios/visualizations/funnel.cy.spec.js @@ -5,10 +5,10 @@ import { getDraggableElements, moveColumnDown, popover, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE_ID, PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/gauge.cy.spec.js b/e2e/test/scenarios/visualizations/gauge.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/visualizations/gauge.cy.spec.js rename to e2e/test/scenarios/visualizations/gauge.cy.spec.js index d0768043e7366..dd90e08411eb8 100644 --- a/frontend/test/metabase/scenarios/visualizations/gauge.cy.spec.js +++ b/e2e/test/scenarios/visualizations/gauge.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/line-bar-tooltips.cy.spec.js b/e2e/test/scenarios/visualizations/line-bar-tooltips.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/visualizations/line-bar-tooltips.cy.spec.js rename to e2e/test/scenarios/visualizations/line-bar-tooltips.cy.spec.js index 48b1c64236ae8..c16a1bc7e4d53 100644 --- a/frontend/test/metabase/scenarios/visualizations/line-bar-tooltips.cy.spec.js +++ b/e2e/test/scenarios/visualizations/line-bar-tooltips.cy.spec.js @@ -3,9 +3,9 @@ import { popover, visitDashboard, saveDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/line_chart.cy.spec.js b/e2e/test/scenarios/visualizations/line_chart.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/visualizations/line_chart.cy.spec.js rename to e2e/test/scenarios/visualizations/line_chart.cy.spec.js index bd86f0f62711a..8d0eaf8868df0 100644 --- a/frontend/test/metabase/scenarios/visualizations/line_chart.cy.spec.js +++ b/e2e/test/scenarios/visualizations/line_chart.cy.spec.js @@ -4,10 +4,11 @@ import { popover, visitDashboard, openSeriesSettings, -} from "__support__/e2e/helpers"; + queryBuilderMain, +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -415,8 +416,13 @@ describe("scenarios > visualizations > line chart", () => { cy.findByText("Category is Doohickey"); }); - it.skip("should not drop the chart legend (metabase#4995)", () => { + it("should not drop the chart legend (metabase#4995)", () => { cy.findAllByTestId("legend-item").should("contain", "Doohickey"); + + cy.log("Ensure that legend is hidden when not dealing with multi series"); + cy.findByTestId("viz-settings-button").click(); + cy.findByTestId("remove-CATEGORY").click(); + queryBuilderMain().should("not.contain", "Doohickey"); }); it("should display correct axis labels (metabase#12782)", () => { diff --git a/frontend/test/metabase/scenarios/visualizations/maps.cy.spec.js b/e2e/test/scenarios/visualizations/maps.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/visualizations/maps.cy.spec.js rename to e2e/test/scenarios/visualizations/maps.cy.spec.js index 50f4f9edf56c1..02d102821d43c 100644 --- a/frontend/test/metabase/scenarios/visualizations/maps.cy.spec.js +++ b/e2e/test/scenarios/visualizations/maps.cy.spec.js @@ -3,10 +3,10 @@ import { popover, visitQuestionAdhoc, openNativeEditor, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; @@ -113,7 +113,7 @@ describe("scenarios > visualizations > maps", () => { cy.findByText("State:"); // column name key cy.findByText("Texas"); // feature name as value - // open actions menu and drill within it + // open drill-through menu and drill within it cy.get("@texas").click(); cy.findByText(/View these People/i).click(); diff --git a/frontend/test/metabase/scenarios/visualizations/object_detail.cy.spec.js b/e2e/test/scenarios/visualizations/object_detail.cy.spec.js similarity index 58% rename from frontend/test/metabase/scenarios/visualizations/object_detail.cy.spec.js rename to e2e/test/scenarios/visualizations/object_detail.cy.spec.js index 7a11f27c709b7..6b5ce217f5e9a 100644 --- a/frontend/test/metabase/scenarios/visualizations/object_detail.cy.spec.js +++ b/e2e/test/scenarios/visualizations/object_detail.cy.spec.js @@ -4,11 +4,20 @@ import { openOrdersTable, openPeopleTable, openProductsTable, -} from "__support__/e2e/helpers"; + visitQuestionAdhoc, + resetTestTable, + resyncDatabase, + getTableId, + visitPublicQuestion, + visitPublicDashboard, +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { WRITABLE_DB_ID, SAMPLE_DB_ID } from "e2e/support/cypress_data"; -const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE, PEOPLE_ID } = + SAMPLE_DATABASE; const FIRST_ORDER_ID = 9676; const SECOND_ORDER_ID = 10874; @@ -178,6 +187,49 @@ describe("scenarios > question > object details", () => { cy.location("search").should("eq", "?objectId=Rustic%20Paper%20Wallet"); cy.findByTestId("object-detail").contains("Rustic Paper Wallet"); }); + + it("should work as a viz display type", () => { + const questionDetails = { + display: "object", + dataset_query: { + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + + joins: [ + { + fields: "all", + "source-table": PRODUCTS_ID, + condition: [ + "=", + ["field", ORDERS.PRODUCT_ID, null], + ["field", PRODUCTS.ID, { "join-alias": "Products" }], + ], + alias: "Products", + }, + { + fields: "all", + "source-table": PEOPLE_ID, + condition: [ + "=", + ["field", ORDERS.USER_ID, null], + ["field", PEOPLE.ID, { "join-alias": "People" }], + ], + alias: "People", + }, + ], + }, + type: "query", + }, + }; + visitQuestionAdhoc(questionDetails); + + cy.findByTestId("object-detail"); + + cy.log("metabase(#29023)"); + cy.findByText("People → Name").scrollIntoView().should("be.visible"); + cy.findByText(/Item 1 of/i).should("be.visible"); + }); }); function drillPK({ id }) { @@ -223,3 +275,99 @@ function changeSorting(columnName, direction) { }); cy.wait("@dataset"); } + +['postgres', 'mysql'].forEach(dialect => { + describe(`Object Detail > composite keys (${dialect})`, { tags: ['@external'] }, () => { + const TEST_TABLE = "composite_pk_table"; + + beforeEach(() => { + resetTestTable({ type: dialect, table: TEST_TABLE }); + restore(`${dialect}-writable`); + cy.signInAsAdmin(); + resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TEST_TABLE }); + }); + + it('can show object detail modal for items with composite keys', () => { + getTableId({ name: TEST_TABLE }).then(tableId => { + cy.visit(`/question#?db=${WRITABLE_DB_ID}&table=${tableId}`); + }); + + cy.icon('expand').first().click(); + + cy.findByRole('dialog').within(() => { + cy.findAllByText("Duck").should('have.length', 2); + cy.icon('chevrondown').click(); + cy.findAllByText("Horse").should('have.length', 2); + }); + }); + }); + + describe(`Object Detail > no primary keys (${dialect})`, { tags: ['@external'] }, () => { + const TEST_TABLE = "no_pk_table"; + + beforeEach(() => { + resetTestTable({ type: dialect, table: TEST_TABLE }); + restore(`${dialect}-writable`); + cy.signInAsAdmin(); + resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TEST_TABLE }); + }); + + it('can show object detail modal for items with no primary key', () => { + getTableId({ name: TEST_TABLE }).then(tableId => { + cy.visit(`/question#?db=${WRITABLE_DB_ID}&table=${tableId}`); + }); + + cy.icon('expand').first().click(); + + cy.findByRole('dialog').within(() => { + cy.findAllByText("Duck").should('have.length', 2); + cy.icon('chevrondown').click(); + cy.findAllByText("Horse").should('have.length', 2); + }); + }); + }); +}); + +describe(`Object Detail > public`, () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + it('can view a public object detail question', () => { + + cy.createQuestion({ ...TEST_QUESTION, display: 'object' }).then( + ({ body: { id: questionId } }) => { + visitPublicQuestion(questionId); + }, + ); + cy.icon('warning').should('not.exist'); + + cy.findByTestId('object-detail').within(() => { + cy.findByText('User ID').should('be.visible'); + cy.findByText('1283').should('be.visible'); + }); + + cy.findByTestId('pagination-footer').within(() => { + cy.findByText("Item 1 of 3").should('be.visible'); + }); + }); + + it('can view an object detail question on a public dashboard', () => { + cy.createQuestionAndDashboard({ questionDetails: { ...TEST_QUESTION, display: 'object' } }).then( + ({ body: { dashboard_id } }) => { + visitPublicDashboard(dashboard_id); + }); + + cy.icon('warning').should('not.exist'); + + cy.findByTestId('object-detail').within(() => { + cy.findByText('User ID').should('be.visible'); + cy.findByText('1283').should('be.visible'); + }); + + cy.findByTestId('pagination-footer').within(() => { + cy.findByText("Item 1 of 3").should('be.visible'); + }); + }); +}); diff --git a/frontend/test/metabase/scenarios/visualizations/pie_chart.cy.spec.js b/e2e/test/scenarios/visualizations/pie_chart.cy.spec.js similarity index 83% rename from frontend/test/metabase/scenarios/visualizations/pie_chart.cy.spec.js rename to e2e/test/scenarios/visualizations/pie_chart.cy.spec.js index 48c9335f57ad4..fb51a34b02cf1 100644 --- a/frontend/test/metabase/scenarios/visualizations/pie_chart.cy.spec.js +++ b/e2e/test/scenarios/visualizations/pie_chart.cy.spec.js @@ -1,7 +1,7 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js b/e2e/test/scenarios/visualizations/pivot_tables.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js rename to e2e/test/scenarios/visualizations/pivot_tables.cy.spec.js index 79f8be07ab8f0..b1e1535f09d21 100644 --- a/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js +++ b/e2e/test/scenarios/visualizations/pivot_tables.cy.spec.js @@ -6,10 +6,12 @@ import { visitQuestion, visitDashboard, visitIframe, -} from "__support__/e2e/helpers"; + dragField, + leftSidebar, +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, @@ -83,7 +85,7 @@ describe("scenarios > visualizations > pivot tables", () => { it("should allow drill through on cells", () => { createAndVisitTestQuestion(); - // open actions menu + // open drill-through menu cy.findByText("783").click(); // drill through to orders list cy.findByText("View these Orders").click(); @@ -96,7 +98,7 @@ describe("scenarios > visualizations > pivot tables", () => { it("should allow drill through on left/top header values", () => { createAndVisitTestQuestion(); - // open actions menu and filter to that value + // open drill-through menu and filter to that value cy.findByText("Doohickey").click(); popover().within(() => cy.findByText("=").click()); // filter is applied @@ -214,6 +216,57 @@ describe("scenarios > visualizations > pivot tables", () => { cy.findByText("294").should("not.exist"); // the other one is still hidden }); + it("should show standalone values when collapsed to the sub-level grouping (metabase#25250)", () => { + const questionDetails = { + name: "25250", + dataset_query: { + type: "query", + query: { + "source-table": ORDERS_ID, + filter: ["<", ["field", ORDERS.CREATED_AT, null], "2016-06-01"], + aggregation: [["count"]], + breakout: [ + ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }], + ["field", ORDERS.USER_ID, null], + ["field", ORDERS.PRODUCT_ID, null], + ], + }, + database: SAMPLE_DB_ID, + }, + display: "pivot", + visualization_settings: { + "pivot_table.column_split": { + rows: [ + ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }], + ["field", ORDERS.USER_ID, null], + ["field", ORDERS.PRODUCT_ID, null], + ], + columns: [], + values: [["aggregation", 0]], + }, + "pivot_table.collapsed_rows": { + value: [], + rows: [ + ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }], + ["field", ORDERS.USER_ID, null], + ["field", ORDERS.PRODUCT_ID, null], + ], + }, + }, + }; + + visitQuestionAdhoc(questionDetails); + cy.findByText("1162").should("be.visible"); + // Collapse "User ID" column + cy.findByText("User ID").parent().find(".Icon-dash").click(); + cy.findByText("Totals for 1162").should("be.visible"); + + //Expanding the grouped column should still work + cy.findByText("Totals for 1162").parent().find(".Icon-add").click(); + cy.findByText("1162").should("be.visible"); + cy.findByText("34").should("be.visible"); + }); + it("should allow hiding subtotals", () => { visitQuestionAdhoc({ dataset_query: testQuery, @@ -488,7 +541,7 @@ describe("scenarios > visualizations > pivot tables", () => { it("should allow filtering drill through (metabase#14632)", () => { assertOnPivotFields(); - cy.findByText("Google").click(); // open actions menu + cy.findByText("Google").click(); // open drill-through menu popover().within(() => cy.findByText("=").click()); // drill with additional filter cy.findByText("Source is Google"); // filter was added cy.findByText("Row totals"); // it's still a pivot table @@ -667,7 +720,7 @@ describe("scenarios > visualizations > pivot tables", () => { }); cy.findByText("Visualization").click(); - sidebar().within(() => { + leftSidebar().within(() => { // This part is still failing. Uncomment when fixed. // cy.findByText("Pivot Table") // .parent() @@ -993,46 +1046,6 @@ function dragColumnHeader(el, xDistance = 50) { }); } -// Rely on native drag events, rather than on the coordinates -// We have 3 "drag-handles" in this test. Their indexes are 0-based. -function dragField(startIndex, dropIndex) { - cy.get(".Icon-grabber2").should("be.visible").as("dragHandle"); - - const BUTTON_INDEX = 0; - const SLOPPY_CLICK_THRESHOLD = 10; - cy.get("@dragHandle") - .eq(dropIndex) - .then($target => { - const coordsDrop = $target[0].getBoundingClientRect(); - cy.get("@dragHandle") - .eq(startIndex) - .then(subject => { - const coordsDrag = subject[0].getBoundingClientRect(); - cy.wrap(subject) - .trigger("mousedown", { - button: BUTTON_INDEX, - clientX: coordsDrag.x, - clientY: coordsDrag.y, - force: true, - }) - .trigger("mousemove", { - button: BUTTON_INDEX, - clientX: coordsDrag.x + SLOPPY_CLICK_THRESHOLD, - clientY: coordsDrag.y, - force: true, - }); - cy.get("body") - .trigger("mousemove", { - button: BUTTON_INDEX, - clientX: coordsDrop.x, - clientY: coordsDrop.y, - force: true, - }) - .trigger("mouseup"); - }); - }); -} - function getIframeBody(selector = "iframe") { return cy .get(selector) @@ -1044,5 +1057,8 @@ function getIframeBody(selector = "iframe") { } function openColumnSettings(columnName) { - sidebar().findByText(columnName).siblings(".Icon-ellipsis").click(); + sidebar() + .findByText(columnName) + .siblings("[data-testid$=settings-button]") + .click(); } diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/11249-add-more-series-no-columns.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/11249-add-more-series-no-columns.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/visualizations/reproductions/11249-add-more-series-no-columns.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/11249-add-more-series-no-columns.cy.spec.js index 204478726f97e..234e21dd10b07 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/11249-add-more-series-no-columns.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/11249-add-more-series-no-columns.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/11435-time-tooltip-native.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/11435-time-tooltip-native.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/visualizations/reproductions/11435-time-tooltip-native.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/11435-time-tooltip-native.cy.spec.js index 476bd0bd29229..da8c0cc08f9d1 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/11435-time-tooltip-native.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/11435-time-tooltip-native.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover } from "__support__/e2e/helpers"; +import { restore, popover } from "e2e/support/helpers"; const questionDetails = { name: "11435", diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/11727-cancel-native-query-shortcut.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/11727-cancel-native-query-shortcut.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/visualizations/reproductions/11727-cancel-native-query-shortcut.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/11727-cancel-native-query-shortcut.cy.spec.js index a8469fdbe2b85..3c6a5b959ad66 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/11727-cancel-native-query-shortcut.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/11727-cancel-native-query-shortcut.cy.spec.js @@ -3,7 +3,7 @@ import { withDatabase, adhocQuestionHash, runNativeQuery, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const PG_DB_ID = 2; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/13504-post-aggregation-drill.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/13504-post-aggregation-drill.cy.spec.js similarity index 89% rename from frontend/test/metabase/scenarios/visualizations/reproductions/13504-post-aggregation-drill.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/13504-post-aggregation-drill.cy.spec.js index b35d221f7d1ce..f8917cebd1305 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/13504-post-aggregation-drill.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/13504-post-aggregation-drill.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/14148-pivot-table-postgres.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/14148-pivot-table-postgres.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/visualizations/reproductions/14148-pivot-table-postgres.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/14148-pivot-table-postgres.cy.spec.js index ef40f1b8b5a53..724839c56aaf6 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/14148-pivot-table-postgres.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/14148-pivot-table-postgres.cy.spec.js @@ -1,8 +1,4 @@ -import { - restore, - withDatabase, - visitQuestionAdhoc, -} from "__support__/e2e/helpers"; +import { restore, withDatabase, visitQuestionAdhoc } from "e2e/support/helpers"; const PG_DB_ID = 2; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/15353-pivot-settings-change-name-values.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/15353-pivot-settings-change-name-values.cy.spec.js similarity index 77% rename from frontend/test/metabase/scenarios/visualizations/reproductions/15353-pivot-settings-change-name-values.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/15353-pivot-settings-change-name-values.cy.spec.js index 9f7aba4d86faa..20949c73fd670 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/15353-pivot-settings-change-name-values.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/15353-pivot-settings-change-name-values.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, sidebar } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, sidebar } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -25,7 +25,10 @@ describe("issue 15353", () => { it("should be able to change field name used for values (metabase#15353)", () => { cy.findByTestId("viz-settings-button").click(); - sidebar().contains("Count").siblings(".Icon-ellipsis").click(); + sidebar() + .contains("Count") + .siblings("[data-testid$=settings-button]") + .click(); cy.findByDisplayValue("Count").type(" renamed").blur(); diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/16170-line-mongo-replace-missing-values.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/16170-line-mongo-replace-missing-values.cy.spec.js similarity index 98% rename from frontend/test/metabase/scenarios/visualizations/reproductions/16170-line-mongo-replace-missing-values.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/16170-line-mongo-replace-missing-values.cy.spec.js index 3350691011ec5..058a04648c245 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/16170-line-mongo-replace-missing-values.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/16170-line-mongo-replace-missing-values.cy.spec.js @@ -3,7 +3,7 @@ import { withDatabase, popover, openSeriesSettings, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const externalDatabaseId = 2; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/17524.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/17524.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/visualizations/reproductions/17524.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/17524.cy.spec.js index 7ed8d515fba7f..e6fccee92baa0 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/17524.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/17524.cy.spec.js @@ -3,8 +3,8 @@ import { filterWidget, filter, filterField, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/18061-maps-only-nulls-crash-frontend.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/18061-maps-only-nulls-crash-frontend.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/visualizations/reproductions/18061-maps-only-nulls-crash-frontend.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/18061-maps-only-nulls-crash-frontend.cy.spec.js index dfae1857f5015..42cae9eda0447 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/18061-maps-only-nulls-crash-frontend.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/18061-maps-only-nulls-crash-frontend.cy.spec.js @@ -3,9 +3,9 @@ import { visitAlias, popover, filterWidget, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js index 28a473c70cf01..381b45a0375dc 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover } from "__support__/e2e/helpers"; +import { restore, popover } from "e2e/support/helpers"; const questionDetails = { name: "18063", diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/18776-timeseries-hidden-axis-freeze.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/18776-timeseries-hidden-axis-freeze.cy.spec.js similarity index 84% rename from frontend/test/metabase/scenarios/visualizations/reproductions/18776-timeseries-hidden-axis-freeze.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/18776-timeseries-hidden-axis-freeze.cy.spec.js index c7d634330bc3b..95ef1bdeeb4c3 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/18776-timeseries-hidden-axis-freeze.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/18776-timeseries-hidden-axis-freeze.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const questionDetails = { dataset_query: { diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/18976-pivot-table-columns.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/18976-pivot-table-columns.cy.spec.js similarity index 82% rename from frontend/test/metabase/scenarios/visualizations/reproductions/18976-pivot-table-columns.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/18976-pivot-table-columns.cy.spec.js index b604a83db8120..f63a1ade2678e 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/18976-pivot-table-columns.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/18976-pivot-table-columns.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const questionDetails = { display: "table", diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/18996-table-image-pagination.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/18996-table-image-pagination.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/visualizations/reproductions/18996-table-image-pagination.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/18996-table-image-pagination.cy.spec.js index db52bb99cfb30..6a6b8bc2f58ed 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/18996-table-image-pagination.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/18996-table-image-pagination.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visitDashboard } from "__support__/e2e/helpers"; +import { restore, visitDashboard } from "e2e/support/helpers"; const questionDetails = { name: "18996", diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/19373-pivot-wrong-distinct-value-totals.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/19373-pivot-wrong-distinct-value-totals.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/visualizations/reproductions/19373-pivot-wrong-distinct-value-totals.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/19373-pivot-wrong-distinct-value-totals.cy.spec.js index 4695d5ecd6bfe..4846352bcfd55 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/19373-pivot-wrong-distinct-value-totals.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/19373-pivot-wrong-distinct-value-totals.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/20548-bar-duplicate-y-axis-after-changing-metrics.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/20548-bar-duplicate-y-axis-after-changing-metrics.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/visualizations/reproductions/20548-bar-duplicate-y-axis-after-changing-metrics.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/20548-bar-duplicate-y-axis-after-changing-metrics.cy.spec.js index d21113d22ed4f..3dc6a4bc5f870 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/20548-bar-duplicate-y-axis-after-changing-metrics.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/20548-bar-duplicate-y-axis-after-changing-metrics.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, summarize, popover, sidebar } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, summarize, popover, sidebar } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -18,7 +18,7 @@ const questionDetails = { }, }; -describe.skip("issue 20548", () => { +describe("issue 20548", () => { beforeEach(() => { cy.intercept("POST", "/api/dataset").as("dataset"); @@ -42,7 +42,7 @@ describe.skip("issue 20548", () => { cy.findByTestId("viz-settings-button").click(); // Implicit assertion - it would fail if it finds more than one "Count" in the sidebar - sidebar().findByDisplayValue("Count"); + sidebar().findAllByText("Count").should("have.length", 1); }); }); diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/21392-chart-many-columns-freeze.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/21392-chart-many-columns-freeze.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/visualizations/reproductions/21392-chart-many-columns-freeze.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/21392-chart-many-columns-freeze.cy.spec.js index 8b03a1f4651ce..e6a69f6b205bc 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/21392-chart-many-columns-freeze.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/21392-chart-many-columns-freeze.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const TEST_QUERY = { type: "native", diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/21452-xhr-on-every-char-for-rename.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/21452-xhr-on-every-char-for-rename.cy.spec.js similarity index 92% rename from frontend/test/metabase/scenarios/visualizations/reproductions/21452-xhr-on-every-char-for-rename.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/21452-xhr-on-every-char-for-rename.cy.spec.js index ee49892ca9770..40953708406d0 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/21452-xhr-on-every-char-for-rename.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/21452-xhr-on-every-char-for-rename.cy.spec.js @@ -3,8 +3,8 @@ import { visitQuestionAdhoc, popover, openSeriesSettings, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +} from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/21504-pie-settings-formatting.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/21504-pie-settings-formatting.cy.spec.js similarity index 77% rename from frontend/test/metabase/scenarios/visualizations/reproductions/21504-pie-settings-formatting.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/21504-pie-settings-formatting.cy.spec.js index eeb386eca7c22..9d7ab0b1a0d37 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/21504-pie-settings-formatting.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/21504-pie-settings-formatting.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/21615-convert-to-sql.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/21615-convert-to-sql.cy.spec.js similarity index 85% rename from frontend/test/metabase/scenarios/visualizations/reproductions/21615-convert-to-sql.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/21615-convert-to-sql.cy.spec.js index 94beff806ef21..fac408d250444 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/21615-convert-to-sql.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/21615-convert-to-sql.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/21665-multi-series-frontend-reload.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/21665-multi-series-frontend-reload.cy.spec.js similarity index 97% rename from frontend/test/metabase/scenarios/visualizations/reproductions/21665-multi-series-frontend-reload.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/21665-multi-series-frontend-reload.cy.spec.js index 59e562ea33f35..08639166b4573 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/21665-multi-series-frontend-reload.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/21665-multi-series-frontend-reload.cy.spec.js @@ -3,7 +3,7 @@ import { visitDashboard, editDashboard, saveDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const Q1 = { name: "21665 Q1", diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/22206-add-remove-column.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/22206-add-remove-column.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/visualizations/reproductions/22206-add-remove-column.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/22206-add-remove-column.cy.spec.js index e015f221fb064..7c9780037ee33 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/22206-add-remove-column.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/22206-add-remove-column.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openOrdersTable } from "__support__/e2e/helpers"; +import { restore, openOrdersTable } from "e2e/support/helpers"; describe("#22206 adding and removing columns doesn't duplicate columns", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/22527-scatter-negative-values-not-rendered.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/22527-scatter-negative-values-not-rendered.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/visualizations/reproductions/22527-scatter-negative-values-not-rendered.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/22527-scatter-negative-values-not-rendered.cy.spec.js index 711bf0fcdd6ee..0e210d963d0c7 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/22527-scatter-negative-values-not-rendered.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/22527-scatter-negative-values-not-rendered.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover } from "__support__/e2e/helpers"; +import { restore, popover } from "e2e/support/helpers"; const questionDetails = { native: { diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/23076.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/23076.cy.spec.js similarity index 91% rename from frontend/test/metabase/scenarios/visualizations/reproductions/23076.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/23076.cy.spec.js index 0e8cc599679ab..c26260d396808 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/23076.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/23076.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/25007-week-tooltip-native.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/25007-week-tooltip-native.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/visualizations/reproductions/25007-week-tooltip-native.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/25007-week-tooltip-native.cy.spec.js index 613f459a3f659..6d35dbcdbbb97 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/25007-week-tooltip-native.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/25007-week-tooltip-native.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover } from "__support__/e2e/helpers"; +import { restore, popover } from "e2e/support/helpers"; const questionDetails = { name: "11435", diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/25156-invalid-x-axis-series.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/25156-invalid-x-axis-series.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/visualizations/reproductions/25156-invalid-x-axis-series.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/25156-invalid-x-axis-series.cy.spec.js index d9f1cf787e98e..23def07308c6c 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/25156-invalid-x-axis-series.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/25156-invalid-x-axis-series.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { REVIEWS, REVIEWS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/27279-sorting-does-not-apply-to-x-axis.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/27279-sorting-does-not-apply-to-x-axis.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/visualizations/reproductions/27279-sorting-does-not-apply-to-x-axis.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/27279-sorting-does-not-apply-to-x-axis.cy.spec.js index 0054c2baa04ac..fd20e80ebdcfb 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/27279-sorting-does-not-apply-to-x-axis.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/27279-sorting-does-not-apply-to-x-axis.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestionAdhoc, popover } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestionAdhoc, popover } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; const questionDetails = { name: "27279", diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/27427-static-viz-divide-by-zero.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/27427-static-viz-divide-by-zero.cy.spec.js similarity index 95% rename from frontend/test/metabase/scenarios/visualizations/reproductions/27427-static-viz-divide-by-zero.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/27427-static-viz-divide-by-zero.cy.spec.js index 75b74764d5579..0bc04a24fea6a 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/27427-static-viz-divide-by-zero.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/27427-static-viz-divide-by-zero.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; const questionDetails = { name: "27427", diff --git a/e2e/test/scenarios/visualizations/reproductions/28304-table-columns-unknown.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/28304-table-columns-unknown.cy.spec.js new file mode 100644 index 0000000000000..b63b761513154 --- /dev/null +++ b/e2e/test/scenarios/visualizations/reproductions/28304-table-columns-unknown.cy.spec.js @@ -0,0 +1,78 @@ +import { + restore, + visitQuestionAdhoc, + getDraggableElements, +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; + +const questionDetails = { + name: "28304", + dataset_query: { + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }]], + }, + database: SAMPLE_DB_ID, + }, + display: "table", + visualization_settings: { + "table.columns": [ + { + fieldRef: ["field", ORDERS.ID, null], + enabled: true, + }, + { + fieldRef: ["field", ORDERS.USER_ID, null], + enabled: true, + }, + { + fieldRef: ["field", ORDERS.PRODUCT_ID, null], + enabled: true, + }, + { + fieldRef: ["field", ORDERS.SUBTOTAL, null], + enabled: true, + }, + { + fieldRef: ["field", ORDERS.TAX, null], + enabled: true, + }, + { + fieldRef: ["field", ORDERS.DISCOUNT, null], + enabled: true, + }, + ], + column_settings: { + '["name","count"]': { show_mini_bar: true }, + }, + }, +}; + +describe("issue 28304", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + visitQuestionAdhoc(questionDetails); + }); + + it("table should should generate default columns when table.columns entries do not match data.cols (metabase#28304)", () => { + cy.findByText("Count by Created At: Month").should("be.visible"); + + cy.findByTestId("viz-settings-button").click(); + leftSidebar().should("not.contain", "[Unknown]"); + leftSidebar().should("contain", "Created At"); + leftSidebar().should("contain", "Count"); + cy.findAllByTestId("mini-bar").should("have.length.greaterThan", 0); + getDraggableElements().should("have.length", 2); + }); +}); + +function leftSidebar() { + return cy.findAllByTestId("sidebar-left"); +} diff --git a/e2e/test/scenarios/visualizations/reproductions/28311-sorting-table-columns.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/28311-sorting-table-columns.cy.spec.js new file mode 100644 index 0000000000000..fea376e5608c5 --- /dev/null +++ b/e2e/test/scenarios/visualizations/reproductions/28311-sorting-table-columns.cy.spec.js @@ -0,0 +1,74 @@ +import { + restore, + visitQuestionAdhoc, + getDraggableElements, +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; + +const questionDetails = { + name: "28311", + dataset_query: { + type: "query", + query: { + "source-table": ORDERS_ID, + }, + database: SAMPLE_DB_ID, + }, + display: "table", + visualization_settings: { + "table.columns": [ + { + fieldRef: ["field", ORDERS.ID, null], + enabled: true, + }, + { + fieldRef: ["field", ORDERS.USER_ID, null], + enabled: true, + }, + { + fieldRef: ["field", ORDERS.PRODUCT_ID, null], + enabled: true, + }, + { + fieldRef: ["field", ORDERS.SUBTOTAL, null], + enabled: false, + }, + { + fieldRef: ["field", ORDERS.TAX, null], + enabled: false, + }, + { + fieldRef: ["field", ORDERS.DISCOUNT, null], + enabled: false, + }, + ], + }, +}; + +describe("issue 25250", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + visitQuestionAdhoc(questionDetails); + }); + + it("pivot table should show standalone values when collapsed to the sub-level grouping (metabase#25250)", () => { + cy.findByText("Product ID").should("be.visible"); + + cy.findByTestId("viz-settings-button").click(); + moveColumnUp(getDraggableElements().contains("Product ID"), 2); + getDraggableElements().eq(0).should("contain", "Product ID"); + }); +}); + +function moveColumnUp(column, distance) { + column + .trigger("mousedown", 0, 0, { force: true }) + .trigger("mousemove", 5, -5, { force: true }) + .trigger("mousemove", 0, distance * -50, { force: true }) + .trigger("mouseup", 0, distance * -50, { force: true }); +} diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js similarity index 90% rename from frontend/test/metabase/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js rename to e2e/test/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js index 0a49feb184a42..a7cc339e7a17d 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js +++ b/e2e/test/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestion } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestion } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/rows.cy.spec.js b/e2e/test/scenarios/visualizations/rows.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/visualizations/rows.cy.spec.js rename to e2e/test/scenarios/visualizations/rows.cy.spec.js index fbda8e90cb8e8..9a9a736218d73 100644 --- a/frontend/test/metabase/scenarios/visualizations/rows.cy.spec.js +++ b/e2e/test/scenarios/visualizations/rows.cy.spec.js @@ -1,4 +1,4 @@ -import { restore } from "__support__/e2e/helpers"; +import { restore } from "e2e/support/helpers"; describe("scenarios > visualizations > rows", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/visualizations/scalar.cy.spec.js b/e2e/test/scenarios/visualizations/scalar.cy.spec.js similarity index 93% rename from frontend/test/metabase/scenarios/visualizations/scalar.cy.spec.js rename to e2e/test/scenarios/visualizations/scalar.cy.spec.js index 3e77b4a2211cd..33a0ff22e4afe 100644 --- a/frontend/test/metabase/scenarios/visualizations/scalar.cy.spec.js +++ b/e2e/test/scenarios/visualizations/scalar.cy.spec.js @@ -2,10 +2,10 @@ import { restore, visitQuestionAdhoc, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/scatter.cy.spec.js b/e2e/test/scenarios/visualizations/scatter.cy.spec.js similarity index 94% rename from frontend/test/metabase/scenarios/visualizations/scatter.cy.spec.js rename to e2e/test/scenarios/visualizations/scatter.cy.spec.js index 3961f13550bad..a7c3a7268f49c 100644 --- a/frontend/test/metabase/scenarios/visualizations/scatter.cy.spec.js +++ b/e2e/test/scenarios/visualizations/scatter.cy.spec.js @@ -1,7 +1,7 @@ -import { restore, visitQuestionAdhoc, popover } from "__support__/e2e/helpers"; +import { restore, visitQuestionAdhoc, popover } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase/scenarios/visualizations/smartscalar-trend.cy.spec.js b/e2e/test/scenarios/visualizations/smartscalar-trend.cy.spec.js similarity index 52% rename from frontend/test/metabase/scenarios/visualizations/smartscalar-trend.cy.spec.js rename to e2e/test/scenarios/visualizations/smartscalar-trend.cy.spec.js index 218d254fa9c78..b6d9545b5c7d2 100644 --- a/frontend/test/metabase/scenarios/visualizations/smartscalar-trend.cy.spec.js +++ b/e2e/test/scenarios/visualizations/smartscalar-trend.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; @@ -31,25 +31,4 @@ describe("scenarios > visualizations > scalar", () => { cy.get(".ScalarValue").contains("100"); cy.findByText("Nothing to compare for the previous month."); }); - - it.skip("should display correct trend percentage (metabase#20488)", () => { - const questionDetails = { - native: { - query: - "SELECT parsedatetime('2020-12-31', 'yyyy-MM-dd'), 1000\nUNION ALL\nSELECT parsedatetime('2021-12-31', 'yyyy-MM-dd'), 1", - "template-tags": {}, - }, - display: "smartscalar", - }; - - cy.createNativeQuestion(questionDetails, { visitQuestion: true }); - - cy.get(".ScalarValue").invoke("text").should("eq", "1"); - - cy.icon("arrow_down"); - - cy.get(".SmartWrapper") - .should("contain", "99,900%") - .and("contain", "was 1,000 last year"); - }); }); diff --git a/frontend/test/metabase/scenarios/visualizations/table.cy.spec.js b/e2e/test/scenarios/visualizations/table.cy.spec.js similarity index 99% rename from frontend/test/metabase/scenarios/visualizations/table.cy.spec.js rename to e2e/test/scenarios/visualizations/table.cy.spec.js index 0f91dd85d7f19..e63f586c1635b 100644 --- a/frontend/test/metabase/scenarios/visualizations/table.cy.spec.js +++ b/e2e/test/scenarios/visualizations/table.cy.spec.js @@ -7,7 +7,7 @@ import { enterCustomColumnDetails, visualize, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; describe("scenarios > visualizations > table", () => { beforeEach(() => { diff --git a/frontend/test/metabase/scenarios/visualizations/trendline.cy.spec.js b/e2e/test/scenarios/visualizations/trendline.cy.spec.js similarity index 88% rename from frontend/test/metabase/scenarios/visualizations/trendline.cy.spec.js rename to e2e/test/scenarios/visualizations/trendline.cy.spec.js index a2d2dd40c3c4d..edafc762f44fb 100644 --- a/frontend/test/metabase/scenarios/visualizations/trendline.cy.spec.js +++ b/e2e/test/scenarios/visualizations/trendline.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, sidebar } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, leftSidebar } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; @@ -35,7 +35,7 @@ describe("scenarios > question > trendline", () => { cy.get("rect"); // Remove sum of total - sidebar().within(() => { + leftSidebar().within(() => { cy.findByText("Data").click(); cy.icon("close").last().click(); cy.findByText("Done").click(); diff --git a/frontend/test/metabase/scenarios/visualizations/waterfall.cy.spec.js b/e2e/test/scenarios/visualizations/waterfall.cy.spec.js similarity index 96% rename from frontend/test/metabase/scenarios/visualizations/waterfall.cy.spec.js rename to e2e/test/scenarios/visualizations/waterfall.cy.spec.js index 772cb9639dd0a..4c822801b8d1c 100644 --- a/frontend/test/metabase/scenarios/visualizations/waterfall.cy.spec.js +++ b/e2e/test/scenarios/visualizations/waterfall.cy.spec.js @@ -5,10 +5,10 @@ import { openNativeEditor, visualize, summarize, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; @@ -284,9 +284,7 @@ describe("scenarios > visualizations > waterfall", () => { cy.get(".Visualization .value-label").should("not.exist"); cy.contains("Show values on data points").next().click(); - cy.get(".Visualization .value-label").within(() => { - cy.findByText("(4.56)"); // negative in parentheses - }); + cy.get(".Visualization .value-label").contains(4.56).should("be.visible"); }); }); }); diff --git a/frontend/test/metabase-visual/account/notifications.cy.spec.js b/e2e/test/visual/account/notifications.cy.spec.js similarity index 90% rename from frontend/test/metabase-visual/account/notifications.cy.spec.js rename to e2e/test/visual/account/notifications.cy.spec.js index f1839a32ed7c2..2d90e67754c7a 100644 --- a/frontend/test/metabase-visual/account/notifications.cy.spec.js +++ b/e2e/test/visual/account/notifications.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers/e2e-setup-helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers/e2e-setup-helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/admin/colors.cy.spec.js b/e2e/test/visual/admin/colors.cy.spec.js similarity index 88% rename from frontend/test/metabase-visual/admin/colors.cy.spec.js rename to e2e/test/visual/admin/colors.cy.spec.js index 29b826ffd8665..440db7dd4b234 100644 --- a/frontend/test/metabase-visual/admin/colors.cy.spec.js +++ b/e2e/test/visual/admin/colors.cy.spec.js @@ -1,10 +1,6 @@ -import { - describeEE, - restore, - visitQuestionAdhoc, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { describeEE, restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/admin/fonts.cy.spec.js b/e2e/test/visual/admin/fonts.cy.spec.js similarity index 96% rename from frontend/test/metabase-visual/admin/fonts.cy.spec.js rename to e2e/test/visual/admin/fonts.cy.spec.js index e57198f60f026..191b573778f4b 100644 --- a/frontend/test/metabase-visual/admin/fonts.cy.spec.js +++ b/e2e/test/visual/admin/fonts.cy.spec.js @@ -2,7 +2,7 @@ import { restore, describeEE, typeAndBlurUsingLabel, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; const CUSTOM_FONT_URL = "https://fonts.gstatic.com/s/robotomono/v21/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW-AJi8SJQt.woff"; diff --git a/frontend/test/metabase-visual/admin/permissions.cy.spec.js b/e2e/test/visual/admin/permissions.cy.spec.js similarity index 89% rename from frontend/test/metabase-visual/admin/permissions.cy.spec.js rename to e2e/test/visual/admin/permissions.cy.spec.js index 7c7811f4a2af1..3a6c826a11dc5 100644 --- a/frontend/test/metabase-visual/admin/permissions.cy.spec.js +++ b/e2e/test/visual/admin/permissions.cy.spec.js @@ -1,6 +1,6 @@ -import { restore } from "__support__/e2e/helpers"; -import { USER_GROUPS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { USER_GROUPS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ALL_USERS_GROUP } = USER_GROUPS; const { PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/collections/bookmarks.cy.spec.js b/e2e/test/visual/collections/bookmarks.cy.spec.js similarity index 84% rename from frontend/test/metabase-visual/collections/bookmarks.cy.spec.js rename to e2e/test/visual/collections/bookmarks.cy.spec.js index 7f6a533807d8b..c0b9747ea1b63 100644 --- a/frontend/test/metabase-visual/collections/bookmarks.cy.spec.js +++ b/e2e/test/visual/collections/bookmarks.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, navigationSidebar } from "__support__/e2e/helpers"; -import { getSidebarSectionTitle as getSectionTitle } from "__support__/e2e/helpers/e2e-collection-helpers"; +import { restore, navigationSidebar } from "e2e/support/helpers"; +import { getSidebarSectionTitle as getSectionTitle } from "e2e/support/helpers/e2e-collection-helpers"; describe("Bookmarks in a collection page", () => { beforeEach(() => { diff --git a/frontend/test/metabase-visual/collections/timelines.cy.spec.js b/e2e/test/visual/collections/timelines.cy.spec.js similarity index 93% rename from frontend/test/metabase-visual/collections/timelines.cy.spec.js rename to e2e/test/visual/collections/timelines.cy.spec.js index a40ecf014ac79..8bde00c5b0b7f 100644 --- a/frontend/test/metabase-visual/collections/timelines.cy.spec.js +++ b/e2e/test/visual/collections/timelines.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/dashboard/dashboard-layout.cy.spec.js b/e2e/test/visual/dashboard/dashboard-layout.cy.spec.js similarity index 92% rename from frontend/test/metabase-visual/dashboard/dashboard-layout.cy.spec.js rename to e2e/test/visual/dashboard/dashboard-layout.cy.spec.js index 50bf75f904a8e..365858347db24 100644 --- a/frontend/test/metabase-visual/dashboard/dashboard-layout.cy.spec.js +++ b/e2e/test/visual/dashboard/dashboard-layout.cy.spec.js @@ -1,5 +1,5 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/dashboard/fullscreen.cy.spec.js b/e2e/test/visual/dashboard/fullscreen.cy.spec.js similarity index 84% rename from frontend/test/metabase-visual/dashboard/fullscreen.cy.spec.js rename to e2e/test/visual/dashboard/fullscreen.cy.spec.js index 613d66c79c7d8..da0f325dc7c47 100644 --- a/frontend/test/metabase-visual/dashboard/fullscreen.cy.spec.js +++ b/e2e/test/visual/dashboard/fullscreen.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitDashboard } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; @@ -14,15 +14,17 @@ const filter = { type: "category", }; +const dashboardDetails = { + parameters: [filter], +}; + describe("visual tests > dashboard > fullscreen", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); - cy.createQuestionAndDashboard({ questionDetails }).then( + cy.createQuestionAndDashboard({ questionDetails, dashboardDetails }).then( ({ body: { id, card_id, dashboard_id } }) => { - cy.addFilterToDashboard({ filter, dashboard_id }); - // Connect filter to the card cy.request("PUT", `/api/dashboard/${dashboard_id}/cards`, { cards: [ diff --git a/frontend/test/metabase-visual/dashboard/parameters-widget.cy.spec.js b/e2e/test/visual/dashboard/parameters-widget.cy.spec.js similarity index 97% rename from frontend/test/metabase-visual/dashboard/parameters-widget.cy.spec.js rename to e2e/test/visual/dashboard/parameters-widget.cy.spec.js index efa4875e2b666..dbef39b327700 100644 --- a/frontend/test/metabase-visual/dashboard/parameters-widget.cy.spec.js +++ b/e2e/test/visual/dashboard/parameters-widget.cy.spec.js @@ -1,9 +1,5 @@ -import { - restore, - visitDashboard, - editDashboard, -} from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore, visitDashboard, editDashboard } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/models/editor.cy.spec.js b/e2e/test/visual/models/editor.cy.spec.js similarity index 87% rename from frontend/test/metabase-visual/models/editor.cy.spec.js rename to e2e/test/visual/models/editor.cy.spec.js index bbf3c1bbd9dfc..8145223cc0d80 100644 --- a/frontend/test/metabase-visual/models/editor.cy.spec.js +++ b/e2e/test/visual/models/editor.cy.spec.js @@ -1,9 +1,13 @@ -import { restore } from "__support__/e2e/helpers"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; -describe("visual tests > models > editor", () => { +// The card query is fairly complex one and it takes a long time to complete +// on slow machines, like the ones used in CI. +// We've seen timeouts with the Cypress default `requestTimeout` of 5,000ms +// for the `cardQuery` route. Hence, why we need to increase it to 30,000ms. +describe("visual tests > models > editor", { requestTimeout: 30000 }, () => { beforeEach(() => { restore(); cy.signInAsAdmin(); diff --git a/frontend/test/metabase-visual/notebook/notebook.cy.spec.js b/e2e/test/visual/notebook/notebook.cy.spec.js similarity index 98% rename from frontend/test/metabase-visual/notebook/notebook.cy.spec.js rename to e2e/test/visual/notebook/notebook.cy.spec.js index e5f39dea581df..e062438db485b 100644 --- a/frontend/test/metabase-visual/notebook/notebook.cy.spec.js +++ b/e2e/test/visual/notebook/notebook.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover, startNewQuestion } from "__support__/e2e/helpers"; +import { restore, popover, startNewQuestion } from "e2e/support/helpers"; describe("visual tests > notebook > major UI elements", () => { const VIEWPORT_WIDTH = 2500; diff --git a/frontend/test/metabase-visual/onboarding/urls.cy.spec.js b/e2e/test/visual/onboarding/urls.cy.spec.js similarity index 92% rename from frontend/test/metabase-visual/onboarding/urls.cy.spec.js rename to e2e/test/visual/onboarding/urls.cy.spec.js index 469174139c7df..49271509233b8 100644 --- a/frontend/test/metabase-visual/onboarding/urls.cy.spec.js +++ b/e2e/test/visual/onboarding/urls.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, navigationSidebar } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, navigationSidebar } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; describe("visual tests > onboarding > URLs", () => { beforeEach(() => { diff --git a/frontend/test/metabase-visual/static-visualizations/funnel.cy.spec.js b/e2e/test/visual/static-visualizations/funnel.cy.spec.js similarity index 91% rename from frontend/test/metabase-visual/static-visualizations/funnel.cy.spec.js rename to e2e/test/visual/static-visualizations/funnel.cy.spec.js index 76a785754489c..0b6be6bd88916 100644 --- a/frontend/test/metabase-visual/static-visualizations/funnel.cy.spec.js +++ b/e2e/test/visual/static-visualizations/funnel.cy.spec.js @@ -4,9 +4,9 @@ import { openEmailPage, sendSubscriptionsEmail, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { admin } = USERS; diff --git a/frontend/test/metabase-visual/static-visualizations/gauge.cy.spec.js b/e2e/test/visual/static-visualizations/gauge.cy.spec.js similarity index 93% rename from frontend/test/metabase-visual/static-visualizations/gauge.cy.spec.js rename to e2e/test/visual/static-visualizations/gauge.cy.spec.js index 00e581d3bc3d2..20a8a398c1067 100644 --- a/frontend/test/metabase-visual/static-visualizations/gauge.cy.spec.js +++ b/e2e/test/visual/static-visualizations/gauge.cy.spec.js @@ -4,10 +4,10 @@ import { openEmailPage, sendSubscriptionsEmail, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/static-visualizations/line-area-bar-combo.cy.spec.js b/e2e/test/visual/static-visualizations/line-area-bar-combo.cy.spec.js similarity index 93% rename from frontend/test/metabase-visual/static-visualizations/line-area-bar-combo.cy.spec.js rename to e2e/test/visual/static-visualizations/line-area-bar-combo.cy.spec.js index 50ad1dcf384ea..902c491943f5a 100644 --- a/frontend/test/metabase-visual/static-visualizations/line-area-bar-combo.cy.spec.js +++ b/e2e/test/visual/static-visualizations/line-area-bar-combo.cy.spec.js @@ -4,10 +4,10 @@ import { openEmailPage, sendSubscriptionsEmail, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, ORDERS, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/static-visualizations/pie.cy.spec.js b/e2e/test/visual/static-visualizations/pie.cy.spec.js similarity index 79% rename from frontend/test/metabase-visual/static-visualizations/pie.cy.spec.js rename to e2e/test/visual/static-visualizations/pie.cy.spec.js index b06eacb81c4df..a51e221deb1ea 100644 --- a/frontend/test/metabase-visual/static-visualizations/pie.cy.spec.js +++ b/e2e/test/visual/static-visualizations/pie.cy.spec.js @@ -4,9 +4,9 @@ import { openEmailPage, sendSubscriptionsEmail, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { admin } = USERS; @@ -25,6 +25,8 @@ describe("static visualizations", () => { createPieQuestion({ percentVisibility: "off " }), createPieQuestion({ percentVisibility: "legend" }), createPieQuestion({ percentVisibility: "inside" }), + createPieQuestion({ showTotal: true }), + createPieQuestion({ showTotal: false }), ], }).then(({ dashboard }) => { visitDashboard(dashboard.id); @@ -38,9 +40,9 @@ describe("static visualizations", () => { }); }); -function createPieQuestion({ percentVisibility }) { +function createPieQuestion({ percentVisibility, showTotal }) { const query = { - name: `pie showDataLabels=${percentVisibility}`, + name: `pie showDataLabels=${percentVisibility}, showTotal=${showTotal}`, native: { query: "select 1 x, 1000 y\n" + @@ -55,6 +57,7 @@ function createPieQuestion({ percentVisibility }) { }, visualization_settings: { "pie.percent_visibility": percentVisibility, + "pie.show_total": showTotal, }, display: "pie", database: SAMPLE_DB_ID, diff --git a/frontend/test/metabase-visual/static-visualizations/progress-bar.cy.spec.js b/e2e/test/visual/static-visualizations/progress-bar.cy.spec.js similarity index 92% rename from frontend/test/metabase-visual/static-visualizations/progress-bar.cy.spec.js rename to e2e/test/visual/static-visualizations/progress-bar.cy.spec.js index afa70eae96949..8eacad0d74e55 100644 --- a/frontend/test/metabase-visual/static-visualizations/progress-bar.cy.spec.js +++ b/e2e/test/visual/static-visualizations/progress-bar.cy.spec.js @@ -4,9 +4,9 @@ import { openEmailPage, sendSubscriptionsEmail, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { admin } = USERS; diff --git a/frontend/test/metabase-visual/static-visualizations/row.cy.spec.js b/e2e/test/visual/static-visualizations/row.cy.spec.js similarity index 87% rename from frontend/test/metabase-visual/static-visualizations/row.cy.spec.js rename to e2e/test/visual/static-visualizations/row.cy.spec.js index a58fe14056d63..fed8638916dda 100644 --- a/frontend/test/metabase-visual/static-visualizations/row.cy.spec.js +++ b/e2e/test/visual/static-visualizations/row.cy.spec.js @@ -4,10 +4,10 @@ import { openEmailPage, sendSubscriptionsEmail, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS_ID, ORDERS, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/static-visualizations/waterfall.cy.spec.js b/e2e/test/visual/static-visualizations/waterfall.cy.spec.js similarity index 93% rename from frontend/test/metabase-visual/static-visualizations/waterfall.cy.spec.js rename to e2e/test/visual/static-visualizations/waterfall.cy.spec.js index cfa163007c94d..a13a9b264f043 100644 --- a/frontend/test/metabase-visual/static-visualizations/waterfall.cy.spec.js +++ b/e2e/test/visual/static-visualizations/waterfall.cy.spec.js @@ -4,9 +4,9 @@ import { openEmailPage, sendSubscriptionsEmail, visitDashboard, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { USERS, SAMPLE_DB_ID } from "e2e/support/cypress_data"; const { admin } = USERS; diff --git a/frontend/test/metabase-visual/visualizations/bar.cy.spec.js b/e2e/test/visual/visualizations/bar.cy.spec.js similarity index 93% rename from frontend/test/metabase-visual/visualizations/bar.cy.spec.js rename to e2e/test/visual/visualizations/bar.cy.spec.js index 0ff4d820860b1..010465d675173 100644 --- a/frontend/test/metabase-visual/visualizations/bar.cy.spec.js +++ b/e2e/test/visual/visualizations/bar.cy.spec.js @@ -2,8 +2,8 @@ import { restore, visitQuestionAdhoc, ensureDcChartVisibility, -} from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +} from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; describe("visual tests > visualizations > bar", () => { beforeEach(() => { diff --git a/frontend/test/metabase-visual/visualizations/funnel.cy.spec.js b/e2e/test/visual/visualizations/funnel.cy.spec.js similarity index 90% rename from frontend/test/metabase-visual/visualizations/funnel.cy.spec.js rename to e2e/test/visual/visualizations/funnel.cy.spec.js index 566024e787e14..7766dee2d1391 100644 --- a/frontend/test/metabase-visual/visualizations/funnel.cy.spec.js +++ b/e2e/test/visual/visualizations/funnel.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; describe("visual tests > visualizations > funnel", () => { beforeEach(() => { diff --git a/frontend/test/metabase-visual/visualizations/line.cy.spec.js b/e2e/test/visual/visualizations/line.cy.spec.js similarity index 96% rename from frontend/test/metabase-visual/visualizations/line.cy.spec.js rename to e2e/test/visual/visualizations/line.cy.spec.js index 96e46d2f81a41..55b9a6e8167f9 100644 --- a/frontend/test/metabase-visual/visualizations/line.cy.spec.js +++ b/e2e/test/visual/visualizations/line.cy.spec.js @@ -2,10 +2,10 @@ import { restore, visitQuestionAdhoc, ensureDcChartVisibility, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PEOPLE } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/visualizations/map.cy.spec.js b/e2e/test/visual/visualizations/map.cy.spec.js similarity index 93% rename from frontend/test/metabase-visual/visualizations/map.cy.spec.js rename to e2e/test/visual/visualizations/map.cy.spec.js index 631a0d58966ed..65b0449714b99 100644 --- a/frontend/test/metabase-visual/visualizations/map.cy.spec.js +++ b/e2e/test/visual/visualizations/map.cy.spec.js @@ -1,5 +1,5 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; describe("visual tests > visualizations > map", () => { beforeEach(() => { diff --git a/frontend/test/metabase-visual/visualizations/pie.cy.spec.js b/e2e/test/visual/visualizations/pie.cy.spec.js similarity index 85% rename from frontend/test/metabase-visual/visualizations/pie.cy.spec.js rename to e2e/test/visual/visualizations/pie.cy.spec.js index 15528fd8c04d1..39ff1817a1280 100644 --- a/frontend/test/metabase-visual/visualizations/pie.cy.spec.js +++ b/e2e/test/visual/visualizations/pie.cy.spec.js @@ -1,6 +1,6 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; describe("visual tests > visualizations > pie", () => { beforeEach(() => { diff --git a/frontend/test/metabase-visual/visualizations/row.cy.spec.js b/e2e/test/visual/visualizations/row.cy.spec.js similarity index 78% rename from frontend/test/metabase-visual/visualizations/row.cy.spec.js rename to e2e/test/visual/visualizations/row.cy.spec.js index e0a02146d0bc8..39f15aff478bb 100644 --- a/frontend/test/metabase-visual/visualizations/row.cy.spec.js +++ b/e2e/test/visual/visualizations/row.cy.spec.js @@ -1,7 +1,7 @@ -import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers"; +import { restore, visitQuestionAdhoc } from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/visualizations/scatter.cy.spec.js b/e2e/test/visual/visualizations/scatter.cy.spec.js similarity index 93% rename from frontend/test/metabase-visual/visualizations/scatter.cy.spec.js rename to e2e/test/visual/visualizations/scatter.cy.spec.js index dca2957e80df0..d5077100e6507 100644 --- a/frontend/test/metabase-visual/visualizations/scatter.cy.spec.js +++ b/e2e/test/visual/visualizations/scatter.cy.spec.js @@ -2,10 +2,10 @@ import { restore, visitQuestionAdhoc, ensureDcChartVisibility, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; diff --git a/frontend/test/metabase-visual/visualizations/table.cy.spec.js b/e2e/test/visual/visualizations/table.cy.spec.js similarity index 91% rename from frontend/test/metabase-visual/visualizations/table.cy.spec.js rename to e2e/test/visual/visualizations/table.cy.spec.js index 92ec88510e17f..835ffbf3a76f3 100644 --- a/frontend/test/metabase-visual/visualizations/table.cy.spec.js +++ b/e2e/test/visual/visualizations/table.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, openReviewsTable, modal } from "__support__/e2e/helpers"; +import { restore, openReviewsTable, modal } from "e2e/support/helpers"; describe("visual tests > visualizations > table", () => { beforeEach(() => { diff --git a/frontend/test/metabase-visual/visualizations/waterfall.cy.spec.js b/e2e/test/visual/visualizations/waterfall.cy.spec.js similarity index 84% rename from frontend/test/metabase-visual/visualizations/waterfall.cy.spec.js rename to e2e/test/visual/visualizations/waterfall.cy.spec.js index 738e8c6c86236..c5203e323c8fb 100644 --- a/frontend/test/metabase-visual/visualizations/waterfall.cy.spec.js +++ b/e2e/test/visual/visualizations/waterfall.cy.spec.js @@ -2,10 +2,10 @@ import { restore, visitQuestionAdhoc, ensureDcChartVisibility, -} from "__support__/e2e/helpers"; +} from "e2e/support/helpers"; -import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; -import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000000000..e3667cd38a13d --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "types": ["cypress", "@testing-library/cypress"], + "paths": { + "*": ["../frontend/src/*"], + "e2e/*": ["./*"] + } + }, + "include": ["**/*"] +} diff --git a/frontend/test/validate-e2e-test-files.js b/e2e/validate-e2e-test-files.js similarity index 97% rename from frontend/test/validate-e2e-test-files.js rename to e2e/validate-e2e-test-files.js index fcd662d6d0f58..e7326ff199c95 100644 --- a/frontend/test/validate-e2e-test-files.js +++ b/e2e/validate-e2e-test-files.js @@ -6,7 +6,7 @@ const glob = require("glob"); const chalk = require("chalk"); const E2E_FILE_EXTENSION = ".cy.spec.js"; -const E2E_HOME = "frontend/test/metabase/scenarios/"; +const E2E_HOME = "e2e/test/"; init(); diff --git a/enterprise/backend/src/metabase_enterprise/advanced_config/api/logs.clj b/enterprise/backend/src/metabase_enterprise/advanced_config/api/logs.clj new file mode 100644 index 0000000000000..d29c7e8794b8a --- /dev/null +++ b/enterprise/backend/src/metabase_enterprise/advanced_config/api/logs.clj @@ -0,0 +1,42 @@ +(ns metabase-enterprise.advanced-config.api.logs + "/api/logs endpoints. + + These endpoints are meant to be used by admins to download logs before entries are auto-removed after the day limit. + + For example, the `query_execution` table will have entries removed after 30 days by default, and admins may wish to + keep logs externally for longer than this retention period." + (:require + [clojure.string :as str] + [compojure.core :refer [GET]] + [malli.core :as mc] + [malli.transform :as mtx] + [metabase.api.common :as api] + [metabase.db.connection :as mdb.connection] + [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] + [toucan2.core :as t2])) + +(mu/defn query-execution-logs + "Query to fetch the rows within the specified `month` of `year` from the `query_execution` table." + [year :- ms/IntGreaterThanZero + month :- ms/IntGreaterThanZero] + (let [date-part (fn [part-key part-value] + (if (= (mdb.connection/db-type) :postgres) + [:= [:date_part [:inline (name part-key)] :started_at] [:inline part-value]] + [:= [part-key :started_at] [:inline part-value]])) + results (t2/select :query_execution + {:order-by [[:started_at :desc]] + :where [:and + (date-part :year year) + (date-part :month month)]})] + results)) + +(api/defendpoint GET "/query_execution/:yyyy-mm" + "Fetch rows for the month specified by `:yyyy-mm` from the query_execution logs table. + Must be a superuser." + [yyyy-mm] + (let [[year month] (map #(mc/coerce ms/IntGreaterThanZero % (mtx/string-transformer)) (str/split yyyy-mm #"\-"))] + (api/check-superuser) + (query-execution-logs year month))) + +(api/define-routes) diff --git a/enterprise/backend/src/metabase_enterprise/api/routes.clj b/enterprise/backend/src/metabase_enterprise/api/routes.clj index 6ace53f05a7ae..66e55afae7214 100644 --- a/enterprise/backend/src/metabase_enterprise/api/routes.clj +++ b/enterprise/backend/src/metabase_enterprise/api/routes.clj @@ -6,6 +6,7 @@ `enterprise/backend/README.md` for more details." (:require [compojure.core :as compojure] + [metabase-enterprise.advanced-config.api.logs :as logs] [metabase-enterprise.advanced-permissions.api.routes :as advanced-permissions] [metabase-enterprise.api.routes.common :as ee.api.common] @@ -32,6 +33,9 @@ (compojure/context "/advanced-permissions" [] (ee.api.common/+require-premium-feature :advanced-permissions advanced-permissions/routes)) + (compojure/context + "/logs" [] + (ee.api.common/+require-premium-feature :advanced-config logs/routes)) (compojure/context "/serialization" [] (ee.api.common/+require-premium-feature :serialization serialization/routes)))) diff --git a/enterprise/backend/src/metabase_enterprise/audit_app/pages/users.clj b/enterprise/backend/src/metabase_enterprise/audit_app/pages/users.clj index c561c93c05a3e..046f3e565e0bf 100644 --- a/enterprise/backend/src/metabase_enterprise/audit_app/pages/users.clj +++ b/enterprise/backend/src/metabase_enterprise/audit_app/pages/users.clj @@ -195,6 +195,8 @@ :id :date_joined [[:case + [:= true :u.google_auth] + (h2x/literal "Google Sign-In") [:= nil :u.sso_source] (h2x/literal "Email") :else diff --git a/enterprise/backend/src/metabase_enterprise/serialization/serialize.clj b/enterprise/backend/src/metabase_enterprise/serialization/serialize.clj index 270e198b17884..58469b24b3648 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/serialize.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/serialize.clj @@ -103,7 +103,7 @@ [entity] (cond-> (dissoc entity :id :creator_id :created_at :updated_at :db_id :location :dashboard_id :fields_hash :personal_owner_id :made_public_by_id :collection_id - :pulse_id :result_metadata :entity_id) + :pulse_id :result_metadata :entity_id :action_id) (some #(instance? % entity) (map type [Metric Field Segment])) (dissoc :table_id))) (defmulti ^:private serialize-one diff --git a/enterprise/backend/src/metabase_enterprise/task/truncate_audit_log.clj b/enterprise/backend/src/metabase_enterprise/task/truncate_audit_log.clj new file mode 100644 index 0000000000000..42fb12e5e3670 --- /dev/null +++ b/enterprise/backend/src/metabase_enterprise/task/truncate_audit_log.clj @@ -0,0 +1,20 @@ +(ns metabase-enterprise.task.truncate-audit-log + "EE implementation of the `audit-max-retention-days` setting, used to determine the retention policy for audit tables." + (:require + [metabase.models.setting :as setting] + [metabase.models.setting.multi-setting + :refer [define-multi-setting-impl]] + [metabase.task.truncate-audit-log.interface :as truncate-audit-log.i])) + +(define-multi-setting-impl truncate-audit-log.i/audit-max-retention-days :ee + :getter (fn [] + (let [env-var-value (setting/get-value-of-type :integer :audit-max-retention-days) + min-retention-days truncate-audit-log.i/min-retention-days] + (cond + (nil? env-var-value) ##Inf + (zero? env-var-value) ##Inf + (< env-var-value + min-retention-days) (do + (truncate-audit-log.i/log-minimum-value-warning env-var-value) + min-retention-days) + :else env-var-value)))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/api/logs_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/api/logs_test.clj new file mode 100644 index 0000000000000..efaec4c19cc14 --- /dev/null +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/api/logs_test.clj @@ -0,0 +1,55 @@ +(ns ^:mb/once metabase-enterprise.advanced-config.api.logs-test + "Tests for /api/ee/logs endpoints" + (:require + [clojure.test :refer :all] + [java-time :as t] + [metabase-enterprise.advanced-config.api.logs :as ee.api.logs] + [metabase.models.query-execution :refer [QueryExecution]] + [metabase.public-settings.premium-features-test :as premium-features.test] + [metabase.query-processor.util :as qp.util] + [metabase.test :as mt])) + +(def ^:private now #t "2023-02-28T10:10:10.101010Z") + +(def ^:private query-execution-defaults + {:hash (qp.util/query-hash {}) + :result_rows 0 + :running_time 0 + :native true + :started_at now + :context :ad-hoc}) + +(deftest fetch-logs-test + (testing "GET /api/logs/query_execution/:yyyy-mm" + (let [test-user :crowberto + user-id (mt/user->id test-user)] + ;; QueryExecution is an unbounded mega table and query it could result in a full table scan :( (See: #29103) + ;; Run the test in an empty database to make querying less intense. + (mt/with-empty-h2-app-db + (mt/with-temp* [QueryExecution [qe-a (merge query-execution-defaults + {:executor_id user-id + :started_at (t/minus now (t/days 2))})] + QueryExecution [qe-b (merge query-execution-defaults + {:executor_id user-id + :started_at (t/minus now (t/days 32))})]] + (premium-features.test/with-premium-features #{:advanced-config} + (testing "Query Executions within `:yyyy-mm` are returned." + (is (= [(select-keys qe-a [:started_at :id])] + ;; we're calling the function directly instead of calling the API + ;; because we want the test to run against the empty h2 DB we bound above + ;; Until we figure out how to completely re-bind a database for API calls + ;; this should be enough + (->> (ee.api.logs/query-execution-logs 2023 2) + (filter #(#{user-id} (:executor_id %))) + (filter #((set (map :id [qe-a qe-b])) (:id %))) + (map #(select-keys % [:started_at :id])))))))))) + + (testing "permission tests" + (testing "require admins" + (premium-features.test/with-premium-features #{:advanced-config} + (is (= "You don't have permissions to do that." + (mt/user-http-request :rasta :get 403 "ee/logs/query_execution/2023-02"))))) + (testing "only works when `:advanced-config` feature is available." + (premium-features.test/with-premium-features #{} + (is (= "This API endpoint is only enabled if you have a premium token with the :advanced-config feature." + (mt/user-http-request :crowberto :get 402 "ee/logs/query_execution/2023-02")))))))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/common_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/common_test.clj index 64dff50e4f885..62a149f12ef90 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/common_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/common_test.clj @@ -4,7 +4,7 @@ [clojure.core.memoize :as memoize] [clojure.test :refer :all] [metabase.api.database :as api.database] - [metabase.models :refer [Database Field FieldValues Permissions Table]] + [metabase.models :refer [Dashboard DashboardCard Database Field FieldValues Permissions Table]] [metabase.models.database :as database] [metabase.models.field :as field] [metabase.models.permissions :as perms] @@ -433,3 +433,28 @@ :details :yes}} (is (partial= {:details {}} (mt/user-http-request :rasta :get 200 (format "database/%d?exclude_uneditable_details=true" db-id)))))))) + +(deftest actions-test + (mt/with-temp-copy-of-db + (mt/with-actions-test-data + (mt/with-actions [{:keys [action-id model-id]} {}] + (testing "Executing dashcard with action" + (mt/with-temp* [Dashboard [{dashboard-id :id}] + DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id + :action_id action-id + :card_id model-id}]] + (let [execute-path (format "dashboard/%s/dashcard/%s/execute" + dashboard-id + dashcard-id)] + (testing "Fails with access to the DB blocked" + (with-all-users-data-perms {(u/the-id (mt/db)) {:data {:native :none :schemas :block} + :details :yes}} + (mt/with-actions-enabled + (is (partial= {:message "You don't have permissions to do that."} + (mt/user-http-request :rasta :post 403 execute-path + {:parameters {"id" 1}})))))) + (testing "Works with access to the DB not blocked" + (mt/with-actions-enabled + (is (= {:rows-affected 1} + (mt/user-http-request :rasta :post 200 execute-path + {:parameters {"id" 1}})))))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/audit_app/pages_test.clj b/enterprise/backend/test/metabase_enterprise/audit_app/pages_test.clj index db90d6239b763..d6c06b3e8394d 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_app/pages_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_app/pages_test.clj @@ -205,3 +205,19 @@ (filter #((set (map u/the-id [a b c])) (first %))) (map second) set)))))))) + +(deftest user-login-method-test + (testing "User login method takes into account both the google_auth and sso_source columns" + (mt/with-test-user :crowberto + (premium-features-test/with-premium-features #{:audit-app} + (mt/with-temp* [User [a {:email "a@metabase.com" :sso_source nil :google_auth false}] + User [b {:email "b@metabase.com" :sso_source nil :google_auth true}] + User [c {:email "c@metabase.com" :sso_source "SAML" :google_auth false}]] + (is (= ["Email" "Google Sign-In" "SAML"] + (->> (get-in (mt/user-http-request :crowberto :post 202 "dataset" + {:type :internal + :fn "metabase-enterprise.audit-app.pages.users/table"}) + [:data :rows]) + (sort-by first) + (filter #((set (map u/the-id [a b c])) (first %))) + (map #(nth % 6)))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/dashboard_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/dashboard_test.clj index 6852cb2de5111..d6bf784eb123f 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/dashboard_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/dashboard_test.clj @@ -5,6 +5,7 @@ [metabase-enterprise.test :as met] [metabase.api.dashboard-test :as api.dashboard-test] [metabase.models :refer [Card Dashboard DashboardCard FieldValues]] + [metabase.models.params.chain-filter] [metabase.models.params.chain-filter-test :as chain-filter-test] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as perms-group] @@ -18,16 +19,19 @@ (met/with-gtaps {:gtaps {:categories {:query (mt/mbql-query categories {:filter [:< $id 3]})}}} (mt/with-temp-vals-in-db FieldValues (u/the-id (db/select-one-id FieldValues :field_id (mt/id :categories :name))) {:values ["Good" "Bad"]} (api.dashboard-test/with-chain-filter-fixtures [{:keys [dashboard]}] - (testing "GET /api/dashboard/:id/params/:param-key/values" - (api.dashboard-test/let-url [url (api.dashboard-test/chain-filter-values-url dashboard "_CATEGORY_NAME_")] - (is (= {:values ["African" "American"] - :has_more_values false} - (chain-filter-test/take-n-values 2 (mt/user-http-request :rasta :get 200 url)))))) - (testing "GET /api/dashboard/:id/params/:param-key/search/:query" - (api.dashboard-test/let-url [url (api.dashboard-test/chain-filter-search-url dashboard "_CATEGORY_NAME_" "a")] - (is (= {:values ["African" "American"] - :has_more_values false} - (mt/user-http-request :rasta :get 200 url)))))))))) + (with-redefs [metabase.models.params.chain-filter/use-cached-field-values? (constantly false)] + (testing "GET /api/dashboard/:id/params/:param-key/values" + (api.dashboard-test/let-url [url (api.dashboard-test/chain-filter-values-url dashboard "_CATEGORY_NAME_")] + (is (= {:values ["African" "American"] + :has_more_values false} + (->> url + (mt/user-http-request :rasta :get 200) + (chain-filter-test/take-n-values 2)))))) + (testing "GET /api/dashboard/:id/params/:param-key/search/:query" + (api.dashboard-test/let-url [url (api.dashboard-test/chain-filter-search-url dashboard "_CATEGORY_NAME_" "a")] + (is (= {:values ["African" "American"] + :has_more_values false} + (mt/user-http-request :rasta :get 200 url))))))))))) (deftest add-card-parameter-mapping-permissions-test (testing "POST /api/dashboard/:id/cards" diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/user_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/user_test.clj index 82cdaef01344f..836a4f76e2980 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/user_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/user_test.clj @@ -76,7 +76,7 @@ (testing "404 if user does not exist" (is (= "Not found." - (mt/user-http-request :crowberto :put 404 (format "mt/user/%d/attributes" (inc (t2/count 'User))) {})))) + (mt/user-http-request :crowberto :put 404 (format "mt/user/%d/attributes" Integer/MAX_VALUE) {})))) (testing "Admin can update user attributes" (t2.with-temp/with-temp diff --git a/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj index 3e80cd552248d..21d94670652c9 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj @@ -27,7 +27,7 @@ ;; ;; making use of the functionality in the [[metabase.db.schema-migrations-test.impl]] namespace for this (since it ;; already does what we need) - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db ;; create a single dummy User to own a Card and a Database for it to reference (let [user (db/simple-insert! User :email "nobody@nowhere.com" @@ -64,7 +64,7 @@ user-pre-insert-called? (atom false)] (log/infof "Dumping to %s" dump-dir) (cmd/dump dump-dir "--user" "crowberto@metabase.com") - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (with-redefs [load/pre-insert-user (fn [user] (reset! user-pre-insert-called? true) (assoc user :password "test-password"))] diff --git a/enterprise/backend/test/metabase_enterprise/serialization/test_util.clj b/enterprise/backend/test/metabase_enterprise/serialization/test_util.clj index 531866df31766..6808f678705d6 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/test_util.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/test_util.clj @@ -6,11 +6,9 @@ [metabase.db :as mdb] [metabase.db.connection :as mdb.connection] [metabase.db.data-source :as mdb.data-source] - [metabase.db.schema-migrations-test.impl :as schema-migrations-test.impl] [metabase.models :refer [Card Collection Dashboard DashboardCard DashboardCardSeries Database Field Metric NativeQuerySnippet Pulse PulseCard Segment Table User]] [metabase.models.collection :as collection] - [metabase.models.permissions-group :as perms-group] [metabase.query-processor.store :as qp.store] [metabase.shared.models.visualization-settings :as mb.viz] [metabase.test :as mt] @@ -53,20 +51,6 @@ `(binding [collection/*allow-deleting-personal-collections* true] (tt/with-temp* ~model-bindings ~@body))) -(defmacro with-empty-h2-app-db - "Runs `body` under a new, blank, H2 application database (randomly named), in which all model tables have been - created via Liquibase schema migrations. After `body` is finished, the original app DB bindings are restored. - - Makes use of functionality in the [[metabase.db.schema-migrations-test.impl]] namespace since that already does what - we need." - [& body] - `(schema-migrations-test.impl/with-temp-empty-app-db [conn# :h2] - (schema-migrations-test.impl/run-migrations-in-range! conn# [0 "v99.00-000"]) ; this should catch all migrations) - ;; since the actual group defs are not dynamic, we need with-redefs to change them here - (with-redefs [perms-group/all-users (#'perms-group/magic-group perms-group/all-users-group-name) - perms-group/admin (#'perms-group/magic-group perms-group/admin-group-name)] - ~@body))) - (defn create! [model & {:as properties}] (db/insert! model (merge (tt/with-temp-defaults model) properties))) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/backfill_ids_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/backfill_ids_test.clj index 0d34d592b7358..09d32e3b7a0b4 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/backfill_ids_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/backfill_ids_test.clj @@ -4,10 +4,11 @@ [metabase-enterprise.serialization.test-util :as ts] [metabase-enterprise.serialization.v2.backfill-ids :as serdes.backfill] [metabase.models :refer [Collection]] + [metabase.test :as mt] [toucan.db :as db])) (deftest backfill-needed-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Collection [{c1-id :id} {:name "some collection"}] Collection [{c2-id :id} {:name "other collection"}] ;; These two deliberately have the same name! @@ -31,7 +32,7 @@ (is (every? some? (all-eids)))))))) (deftest no-overwrite-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Collection [{c1-id :id c1-eid :entity_id} {:name "some collection"}] Collection [{c2-id :id} {:name "other collection"}]] (testing "deleting the entity_id for one of them" @@ -47,7 +48,7 @@ (is (= c1-eid (db/select-one-field :entity_id Collection :id c1-id)))))))) (deftest repeatable-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Collection [{c1-eid :entity_id} {:name "some collection"}] Collection [{c2-id :id} {:name "other collection"}]] (testing "deleting the entity_id for one of them" diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e/yaml_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e/yaml_test.clj index 4f81a959cc63b..d8134255c5b17 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e/yaml_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e/yaml_test.clj @@ -118,7 +118,7 @@ (for [type [:query :implicit :http]] (many-random-fks 10 {:spec-gen {:type type}} - {:model_id [:c 100] + {:model_id [:sm 10] :creator_id [:u 10]}))) :query-action (map-indexed (fn [idx x] @@ -158,6 +158,19 @@ {:table_id [:t 100] :collection_id [:coll 100] :creator_id [:u 10]})) + ;; Simple model is primary used for actions. + ;; We can't use :card for actions because implicit actions require the model's query to contain + ;; nothing but a source table + :simple-model (mapv #(update-in % [1 :refs] table->db) + (many-random-fks + 10 + {:spec-gen {:dataset_query {:database 1 + :query {:source-table 3} + :type :query} + :dataset true}} + {:table_id [:t 10] + :collection_id [:coll 10] + :creator_id [:u 10]})) :dashboard (many-random-fks 100 {} {:collection_id [:coll 100] :creator_id [:u 10]}) :dashboard-card (many-random-fks 300 {} {:card_id [:c 100] @@ -251,7 +264,8 @@ "Fields are scattered, so the directories are harder to count")) (testing "for cards" - (is (= 100 (->> (io/file dump-dir "collections") + ;; 100 from card, and 10 from simple-model + (is (= 110 (->> (io/file dump-dir "collections") collections (map (comp count dir->file-set #(io/file % "cards"))) (reduce +))))) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj index 58960d49e04d7..34f47b15fee13 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj @@ -39,7 +39,7 @@ set)) (deftest fundamentals-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Collection [{coll-id :id coll-eid :entity_id coll-slug :slug} {:name "Some Collection"}] @@ -100,7 +100,7 @@ (by-model "Collection" (extract/extract-metabase {:user 218921}))))))))) (deftest dashboard-and-cards-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Collection [{coll-id :id coll-eid :entity_id} {:name "Some Collection"}] User [{mark-id :id} {:first_name "Mark" @@ -481,7 +481,7 @@ (by-model "Dashboard"))))))))) (deftest dimensions-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [;; Simple case: a singular field, no human-readable field. Database [{db-id :id} {:name "My Database"}] Table [{no-schema-id :id} {:name "Schemaless Table" :db_id db-id}] @@ -567,7 +567,7 @@ (set (serdes.base/serdes-dependencies ser)))))))))) (deftest metrics-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -604,7 +604,7 @@ (set (serdes.base/serdes-dependencies ser)))))))))) (deftest native-query-snippets-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -653,7 +653,7 @@ (is (empty? (serdes.base/serdes-dependencies ser)))))))))) (deftest timelines-and-events-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -708,7 +708,7 @@ (set (serdes.base/serdes-dependencies ser))))))))))) (deftest segments-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -746,7 +746,7 @@ (set (serdes.base/serdes-dependencies ser)))))))))) (deftest implicit-action-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -782,7 +782,7 @@ (set (serdes.base/serdes-dependencies ser))))))))))))) (deftest http-action-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -818,7 +818,7 @@ (set (serdes.base/serdes-dependencies ser))))))))))))) (deftest query-action-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -858,7 +858,7 @@ (set (serdes.base/serdes-dependencies ser))))))))))))) (deftest field-values-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Database [{db-id :id} {:name "My Database"}] Table [{no-schema-id :id} {:name "Schemaless Table" :db_id db-id}] Field [{field-id :id} {:name "Some Field" @@ -900,7 +900,7 @@ (set (serdes.base/serdes-dependencies ser)))))))))) (deftest pulses-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -994,7 +994,7 @@ (set (serdes.base/serdes-dependencies ser)))))))))) (deftest pulse-cards-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{ann-id :id} {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"}] @@ -1077,7 +1077,7 @@ (set (serdes.base/serdes-dependencies ser)))))))))) (deftest cards-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{mark-id :id} {:first_name "Mark" :last_name "Knopfler" @@ -1127,7 +1127,7 @@ (set (serdes.base/serdes-dependencies ser))))))))) (deftest selective-serialization-basic-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [User [{mark-id :id} {:first_name "Mark" :last_name "Knopfler" :email "mark@direstrai.ts"}] @@ -1346,7 +1346,7 @@ set))))))))) (deftest foreign-key-field-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Database [{db-id :id} {:name "My Database"}] Table [{no-schema-id :id} {:name "Schemaless Table" :db_id db-id}] Field [{some-field-id :id} {:name "Some Field" :table_id no-schema-id}] diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj index 3e0ee17e497d6..e4488d9b2a090 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj @@ -24,6 +24,7 @@ User]] [metabase.models.action :as action] [metabase.models.serialization.base :as serdes.base] + [metabase.test :as mt] [metabase.util :as u] [schema.core :as s] [toucan.db :as db]) @@ -780,7 +781,7 @@ field3d (atom nil)] (testing "serializing the original database, table, field and fieldvalues" - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (reset! db1s (ts/create! Database :name "my-db")) (reset! table1s (ts/create! Table :name "CUSTOMERS" :db_id (:id @db1s))) (reset! field1s (ts/create! Field :name "STATE" :table_id (:id @table1s))) @@ -817,7 +818,7 @@ set))))))) (testing "deserializing finds existing FieldValues properly" - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db ;; A different database and tables, so the IDs don't match. (reset! db2d (ts/create! Database :name "other-db")) (reset! table2d (ts/create! Table :name "ORDERS" :db_id (:id @db2d))) @@ -864,7 +865,7 @@ table1s (atom nil)] (testing "loading a bare card" - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (reset! db1s (ts/create! Database :name "my-db")) (reset! table1s (ts/create! Table :name "CUSTOMERS" :db_id (:id @db1s))) (ts/create! Field :name "STATE" :table_id (:id @table1s)) @@ -909,7 +910,7 @@ card1s (atom nil) extracted (atom nil)] (testing "snippets referenced by native cards must be deserialized" - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (reset! db1s (ts/create! Database :name "my-db")) (reset! table1s (ts/create! Table :name "CUSTOMERS" :db_id (:id @db1s))) (reset! snippet1s (ts/create! NativeQuerySnippet :name "some snippet")) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/storage/yaml_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/storage/yaml_test.clj index d3778273401ed..1f55fa117c744 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/storage/yaml_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/storage/yaml_test.clj @@ -8,6 +8,7 @@ [metabase-enterprise.serialization.v2.storage.yaml :as storage.yaml] [metabase.models :refer [Card Collection Dashboard Database Field FieldValues NativeQuerySnippet Table]] [metabase.models.serialization.base :as serdes.base] + [metabase.test :as mt] [metabase.util.date-2 :as u.date] [toucan.db :as db] [yaml.core :as yaml])) @@ -23,7 +24,7 @@ (deftest basic-dump-test (ts/with-random-dump-dir [dump-dir "serdesv2-"] - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Collection [parent {:name "Some Collection"}] Collection [child {:name "Child Collection" :location (format "/%d/" (:id parent))}]] (let [export (into [] (extract/extract-metabase nil)) @@ -57,7 +58,7 @@ (deftest collection-nesting-test (ts/with-random-dump-dir [dump-dir "serdesv2-"] - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Collection [grandparent {:name "Grandparent Collection" :location "/"}] Collection [parent {:name "Parent Collection" @@ -87,7 +88,7 @@ (deftest snippets-collections-nesting-test (ts/with-random-dump-dir [dump-dir "serdesv2-"] - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Collection [grandparent {:name "Grandparent Collection" :namespace :snippets :location "/"}] @@ -121,7 +122,7 @@ (deftest embedded-slash-test (ts/with-random-dump-dir [dump-dir "serdesv2-"] - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Database [db {:name "My Company Data"}] Table [table {:name "Customers" :db_id (:id db)}] Field [website {:name "Company/organization website" :table_id (:id table)}] diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/util_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/util_test.clj index ee6bc388f3de8..4d5c1d5e3beb0 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/util_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/util_test.clj @@ -3,10 +3,11 @@ [clojure.test :refer :all] [metabase-enterprise.serialization.test-util :as ts] [metabase.models :refer [Database Field Table]] - [metabase.models.serialization.util :as serdes.util])) + [metabase.models.serialization.util :as serdes.util] + [metabase.test :as mt])) (deftest mbql-deserialize-test - (ts/with-empty-h2-app-db + (mt/with-empty-h2-app-db (ts/with-temp-dpc [Database [{db-id :id} {:name "Metabase Store"}] Table [{crm-id :id} {:name "crm_survey_response" :db_id db-id diff --git a/enterprise/backend/test/metabase_enterprise/task/truncate_audit_log_test.clj b/enterprise/backend/test/metabase_enterprise/task/truncate_audit_log_test.clj new file mode 100644 index 0000000000000..86bec50098489 --- /dev/null +++ b/enterprise/backend/test/metabase_enterprise/task/truncate_audit_log_test.clj @@ -0,0 +1,27 @@ +(ns metabase-enterprise.task.truncate-audit-log-test + (:require + [clojure.test :refer :all] + [metabase.models.setting :as setting] + [metabase.public-settings.premium-features :as premium-features] + [metabase.task.truncate-audit-log.interface :as truncate-audit-log.i] + [metabase.test :as mt])) + +(deftest audit-max-retention-days-test + ;; Tests for the OSS & Cloud implementations are in `metabase.task.truncate-audit-log-test` + (with-redefs [premium-features/enable-advanced-config? (constantly true)] + (is (= ##Inf (truncate-audit-log.i/audit-max-retention-days))) + + (mt/with-temp-env-var-value [mb-audit-max-retention-days 0] + (is (= ##Inf (truncate-audit-log.i/audit-max-retention-days)))) + + (mt/with-temp-env-var-value [mb-audit-max-retention-days 100] + (is (= 100 (truncate-audit-log.i/audit-max-retention-days)))) + + ;; Acceptable values have a lower bound of 30 + (mt/with-temp-env-var-value [mb-audit-max-retention-days 1] + (is (= 30 (truncate-audit-log.i/audit-max-retention-days)))) + + (is (thrown-with-msg? + java.lang.UnsupportedOperationException + #"You cannot set audit-max-retention-days" + (setting/set! :audit-max-retention-days 30))))) diff --git a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx index a5cd3484512fc..2f8c2e6d88172 100644 --- a/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx +++ b/enterprise/frontend/src/metabase-enterprise/license/components/LicenseAndBillingSettings/LicenseAndBillingSettings.tsx @@ -8,7 +8,7 @@ import LoadingSpinner from "metabase/components/LoadingSpinner"; import MetabaseSettings from "metabase/lib/settings"; -import { getSettings } from "metabase/selectors/settings"; +import { getUpgradeUrl } from "metabase/selectors/settings"; import { showLicenseAcceptedToast } from "metabase-enterprise/license/actions"; @@ -25,6 +25,8 @@ import { } from "metabase/admin/settings/components/SettingsLicense"; import { LicenseInput } from "metabase/admin/settings/components/LicenseInput"; import { ExplorePlansIllustration } from "metabase/admin/settings/components/SettingsLicense/ExplorePlansIllustration"; +import { SettingDefinition } from "metabase-types/api"; +import { State } from "metabase-types/store"; const HOSTING_FEATURE_KEY = "hosting"; const STORE_MANAGED_FEATURE_KEY = "metabase-store-managed"; @@ -58,20 +60,20 @@ const getDescription = (tokenStatus?: TokenStatus, hasToken?: boolean) => { return t`Your license is active until ${validUntil}! Hope you’re enjoying it.`; }; -interface LicenseAndBillingSettingsProps { - settings: Record; - settingValues: { - key: string; - value: any; - is_env_setting: boolean; - env_name: string; - }[]; +interface StateProps { + settingValues: SettingDefinition[]; + upgradeUrl: string; +} + +interface DispatchProps { showLicenseAcceptedToast: () => void; } +type LicenseAndBillingSettingsProps = DispatchProps & StateProps; + const LicenseAndBillingSettings = ({ settingValues, - settings, + upgradeUrl, showLicenseAcceptedToast, }: LicenseAndBillingSettingsProps) => { const { @@ -150,7 +152,7 @@ const LicenseAndBillingSettings = ({ invalid={isInvalid} loading={isUpdating} error={error} - token={token} + token={token ? String(token) : undefined} onUpdate={updateToken} /> @@ -161,7 +163,7 @@ const LicenseAndBillingSettings = ({ {t`Looking for more?`} {jt`You can get priority support, more tools to help you share your insights with your teams and powerful options to help you create seamless, interactive data experiences for your customers with ${( - + {t`our other paid plans.`} )}`} @@ -176,9 +178,9 @@ const LicenseAndBillingSettings = ({ }; export default connect( - (state: any) => ({ + (state: State): StateProps => ({ settingValues: state.admin.settings.settings, - settings: getSettings(state), + upgradeUrl: getUpgradeUrl(state, { utm_media: "license" }), }), { showLicenseAcceptedToast, diff --git a/frontend/src/metabase-lib/Question.ts b/frontend/src/metabase-lib/Question.ts index f4a1dff414f0c..2b0ed5366fb13 100644 --- a/frontend/src/metabase-lib/Question.ts +++ b/frontend/src/metabase-lib/Question.ts @@ -26,17 +26,17 @@ import { memoizeClass, sortObject } from "metabase-lib/utils"; import * as Urls from "metabase/lib/urls"; import { getCardUiParameters } from "metabase-lib/parameters/utils/cards"; import { - DashboardApi, CardApi, + DashboardApi, maybeUsePivotEndpoint, MetabaseApi, } from "metabase/services"; -import { ParameterValues } from "metabase-types/types/Parameter"; +// import { ParameterValues } from "metabase-types/types/Parameter"; import { Card as CardObject, DatasetQuery } from "metabase-types/types/Card"; import { VisualizationSettings } from "metabase-types/api/card"; import { Column, Dataset, Value } from "metabase-types/types/Dataset"; import { TableId } from "metabase-types/types/Table"; -import { DatabaseId } from "metabase-types/types/Database"; +// import { DatabaseId } from "metabase-types/types/Database"; import { ClickObject, DimensionValue, @@ -47,6 +47,8 @@ import { CollectionId, Parameter as ParameterObject, ParameterId, + DatabaseId, + ParameterValues, } from "metabase-types/api"; import { @@ -470,8 +472,13 @@ class QuestionInner { canWriteActions(): boolean { const database = this.database(); - const hasActionsEnabled = database != null && database.hasActionsEnabled(); - return this.canWrite() && hasActionsEnabled; + + return ( + this.canWrite() && + database != null && + database.canWrite() && + database.hasActionsEnabled() + ); } supportsImplicitActions(): boolean { @@ -692,19 +699,31 @@ class QuestionInner { ); const graphMetrics = this.setting("graph.metrics"); + if ( graphMetrics && - addedColumnNames.length > 0 && - removedColumnNames.length === 0 + (addedColumnNames.length > 0 || removedColumnNames.length > 0) ) { const addedMetricColumnNames = addedColumnNames.filter( name => query.columnDimensionWithName(name) instanceof AggregationDimension, ); - if (addedMetricColumnNames.length > 0) { + const removedMetricColumnNames = removedColumnNames.filter( + name => + previousQuery.columnDimensionWithName(name) instanceof + AggregationDimension, + ); + + if ( + addedMetricColumnNames.length > 0 || + removedMetricColumnNames.length > 0 + ) { return this.updateSettings({ - "graph.metrics": [...graphMetrics, ...addedMetricColumnNames], + "graph.metrics": [ + ..._.difference(graphMetrics, removedMetricColumnNames), + ...addedMetricColumnNames, + ], }); } } @@ -1142,6 +1161,44 @@ class QuestionInner { } } + /** + * Runs the query Explainer. + */ + async queryExplain( + cardId, + query, + result_metadata, + database_id, + ): Promise { + return await CardApi.queryExplainer({ + cardId: cardId, + card_id: cardId, + query: query, + result_metadata: result_metadata, + database_id: database_id, + }); + } + + /** + * Runs the query Optimiser. + */ + async queryOptimise( + cardId, + query, + result_metadata, + database_id, + warnings, + ): Promise { + return await CardApi.queryOptimiser({ + cardId: cardId, + card_id: cardId, + query: query, + result_metadata: result_metadata, + database_id: database_id, + warnings: warnings, + }); + } + setParameter(id: ParameterId, parameter: ParameterObject) { const newParameters = this.parameters().map(oldParameter => oldParameter.id === id ? parameter : oldParameter, @@ -1277,7 +1334,7 @@ class QuestionInner { if (this.isStructured()) { const questionWithParameters = this.setParameters(parameters); - if (!this.query().readOnly()) { + if (this.query().isEditable()) { return questionWithParameters .setParameterValues(parameterValues) .convertParametersToMbql() @@ -1313,6 +1370,7 @@ class QuestionInner { } } +// eslint-disable-next-line import/no-default-export -- deprecated usage export default class Question extends memoizeClass("query")( QuestionInner, ) { diff --git a/frontend/src/metabase-lib/actions/utils.ts b/frontend/src/metabase-lib/actions/utils.ts new file mode 100644 index 0000000000000..3b8d8fda68083 --- /dev/null +++ b/frontend/src/metabase-lib/actions/utils.ts @@ -0,0 +1,27 @@ +import { WritebackAction } from "metabase-types/api"; +import Question from "metabase-lib/Question"; +import Database from "metabase-lib/metadata/Database"; + +export const canRunAction = ( + action: WritebackAction, + databases: Database[], +) => { + const database = databases.find(({ id }) => id === action.database_id); + return database != null && database.hasActionsEnabled(); +}; + +export const canEditAction = (action: WritebackAction, model: Question) => { + if (action.model_id !== model.id()) { + return false; + } + + return model.canWriteActions(); +}; + +export const canArchiveAction = (action: WritebackAction, model: Question) => { + if (action.model_id !== model.id()) { + return false; + } + + return action.type !== "implicit" && canEditAction(action, model); +}; diff --git a/frontend/src/metabase-lib/actions/utils.unit.spec.ts b/frontend/src/metabase-lib/actions/utils.unit.spec.ts new file mode 100644 index 0000000000000..47608f3b2bbc9 --- /dev/null +++ b/frontend/src/metabase-lib/actions/utils.unit.spec.ts @@ -0,0 +1,38 @@ +import { + createMockDatabase, + createMockQueryAction, +} from "metabase-types/api/mocks"; +import Database from "metabase-lib/metadata/Database"; +import { canRunAction } from "./utils"; + +describe("canRunAction", () => { + it("should not be able to run an action if the user has no access to the database", () => { + const action = createMockQueryAction(); + + expect(canRunAction(action, [])).toBe(false); + }); + + it("should not be able to run an action if the database has actions disabled", () => { + const database = new Database( + createMockDatabase({ + native_permissions: "write", + settings: { "database-enable-actions": false }, + }), + ); + const action = createMockQueryAction({ database_id: database.id }); + + expect(canRunAction(action, [database])).toBe(false); + }); + + it("should be able to run an action if the database has actions enabled", () => { + const database = new Database( + createMockDatabase({ + native_permissions: "read", + settings: { "database-enable-actions": true }, + }), + ); + const action = createMockQueryAction({ database_id: database.id }); + + expect(canRunAction(action, [database])).toBe(true); + }); +}); diff --git a/frontend/src/metabase-lib/binning.ts b/frontend/src/metabase-lib/binning.ts new file mode 100644 index 0000000000000..723374db3777b --- /dev/null +++ b/frontend/src/metabase-lib/binning.ts @@ -0,0 +1,42 @@ +import * as ML from "cljs/metabase.lib.js"; +import { displayInfo } from "./metadata"; +import type { Bucket, ColumnMetadata, Clause, Query } from "./types"; + +export function binning(clause: Clause | ColumnMetadata): Bucket | null { + return ML.binning(clause); +} + +export function availableBinningStrategies( + query: Query, + stageIndex: number, + column: ColumnMetadata, +): Bucket[] { + return ML.available_binning_strategies(query, stageIndex, column); +} + +export function isBinnable( + query: Query, + stageIndex: number, + column: ColumnMetadata, +) { + return availableBinningStrategies(query, stageIndex, column).length > 0; +} + +export function withBinning( + column: ColumnMetadata, + binningStrategy: Bucket | null, +) { + return ML.with_binning(column, binningStrategy); +} + +export function withDefaultBinning( + query: Query, + stageIndex: number, + column: ColumnMetadata, +) { + const buckets = availableBinningStrategies(query, stageIndex, column); + const defaultBucket = buckets.find( + bucket => displayInfo(query, stageIndex, bucket).default, + ); + return defaultBucket ? withBinning(column, defaultBucket) : column; +} diff --git a/frontend/src/metabase-lib/breakout.ts b/frontend/src/metabase-lib/breakout.ts new file mode 100644 index 0000000000000..8d0758988c080 --- /dev/null +++ b/frontend/src/metabase-lib/breakout.ts @@ -0,0 +1,21 @@ +import * as ML from "cljs/metabase.lib.js"; +import type { BreakoutClause, ColumnMetadata, Query } from "./types"; + +export function breakoutableColumns( + query: Query, + stageIndex: number, +): ColumnMetadata[] { + return ML.breakoutable_columns(query, stageIndex); +} + +export function breakouts(query: Query, stageIndex: number): BreakoutClause[] { + return ML.breakouts(query, stageIndex); +} + +export function breakout( + query: Query, + stageIndex: number, + column: ColumnMetadata, +): Query { + return ML.breakout(query, stageIndex, column); +} diff --git a/frontend/src/metabase-lib/column_types.ts b/frontend/src/metabase-lib/column_types.ts new file mode 100644 index 0000000000000..996b688394588 --- /dev/null +++ b/frontend/src/metabase-lib/column_types.ts @@ -0,0 +1,36 @@ +import * as TYPES from "cljs/metabase.lib.types.isa"; +import type { ColumnMetadata } from "./types"; + +type TypeFn = (column: ColumnMetadata) => boolean; + +export const isAddress: TypeFn = TYPES.address_QMARK_; +export const isAvatarURL: TypeFn = TYPES.avatar_URL_QMARK_; +export const isBoolean: TypeFn = TYPES.boolean_QMARK_; +export const isCategory: TypeFn = TYPES.category_QMARK_; +export const isCity: TypeFn = TYPES.city_QMARK_; +export const isComment: TypeFn = TYPES.comment_QMARK_; +export const isCoordinate: TypeFn = TYPES.coordinate_QMARK_; +export const isCountry: TypeFn = TYPES.country_QMARK_; +export const isCurrency: TypeFn = TYPES.currency_QMARK_; +export const isDate: TypeFn = TYPES.date_QMARK_; +export const isDateWithoutTime: TypeFn = TYPES.date_without_time_QMARK_; +export const isDescription: TypeFn = TYPES.description_QMARK_; +export const isEmail: TypeFn = TYPES.email_QMARK_; +export const isEntityName: TypeFn = TYPES.entity_name_QMARK_; +export const isForeignKey: TypeFn = TYPES.foreign_key_QMARK_; +export const isID: TypeFn = TYPES.id_QMARK_; +export const isImageURL: TypeFn = TYPES.image_URL_QMARK_; +export const isLocation: TypeFn = TYPES.location_QMARK_; +export const isLatitude: TypeFn = TYPES.latitude_QMARK_; +export const isLongitude: TypeFn = TYPES.longitude_QMARK_; +export const isMetric: TypeFn = TYPES.metric_QMARK_; +export const isNumber: TypeFn = TYPES.number_QMARK_; +export const isNumeric: TypeFn = TYPES.numeric_QMARK_; +export const isPrimaryKey: TypeFn = TYPES.primary_key_QMARK_; +export const isScope: TypeFn = TYPES.scope_QMARK_; +export const isState: TypeFn = TYPES.state_QMARK_; +export const isString: TypeFn = TYPES.string_QMARK_; +export const isSummable: TypeFn = TYPES.summable_QMARK_; +export const isTime: TypeFn = TYPES.time_QMARK_; +export const isURL: TypeFn = TYPES.URL_QMARK_; +export const isZipCode: TypeFn = TYPES.zip_code_QMARK_; diff --git a/frontend/src/metabase-lib/common.ts b/frontend/src/metabase-lib/common.ts new file mode 100644 index 0000000000000..e1215f0fd02db --- /dev/null +++ b/frontend/src/metabase-lib/common.ts @@ -0,0 +1,7 @@ +import * as ML from "cljs/metabase.lib.js"; + +import type { Clause, ExternalOp } from "./types"; + +export function externalOp(clause: Clause): ExternalOp { + return ML.external_op(clause); +} diff --git a/frontend/src/metabase-lib/comparison.ts b/frontend/src/metabase-lib/comparison.ts new file mode 100644 index 0000000000000..a90c46cdbdac2 --- /dev/null +++ b/frontend/src/metabase-lib/comparison.ts @@ -0,0 +1,10 @@ +import * as ML from "cljs/metabase.lib.js"; +import type { DatasetQuery } from "metabase-types/api"; + +export function areLegacyQueriesEqual( + query1: DatasetQuery, + query2: DatasetQuery, + fieldIds?: number[], +): boolean { + return ML.query_EQ_(query1, query2, fieldIds); +} diff --git a/frontend/src/metabase-lib/expressions/config.js b/frontend/src/metabase-lib/expressions/config.js index da5b9d17003b7..005bb783aa8f7 100644 --- a/frontend/src/metabase-lib/expressions/config.js +++ b/frontend/src/metabase-lib/expressions/config.js @@ -505,6 +505,9 @@ export const EXPRESSION_FUNCTIONS = new Set([ export const EXPRESSION_OPERATORS = new Set(["+", "-", "*", "/"]); export const FILTER_OPERATORS = new Set(["!=", "<=", ">=", "<", ">", "="]); +// operators in which order of operands doesn't matter +export const EXPRESSION_OPERATOR_WITHOUT_ORDER_PRIORITY = new Set(["+", "*"]); + export const BOOLEAN_UNARY_OPERATORS = new Set(["not"]); export const LOGICAL_AND_OPERATOR = new Set(["and"]); export const LOGICAL_OR_OPERATOR = new Set(["or"]); diff --git a/frontend/src/metabase-lib/expressions/format.js b/frontend/src/metabase-lib/expressions/format.js index aafb13b5e4e3c..854dffa094fe3 100644 --- a/frontend/src/metabase-lib/expressions/format.js +++ b/frontend/src/metabase-lib/expressions/format.js @@ -18,6 +18,7 @@ import { getExpressionName, formatStringLiteral, hasOptions, + EXPRESSION_OPERATOR_WITHOUT_ORDER_PRIORITY, } from "./index"; export { DISPLAY_QUOTES, EDITOR_QUOTES } from "./config"; @@ -121,11 +122,27 @@ function formatOperator([op, ...args], options) { // FIXME: how should we format args? args = args.slice(0, -1); } + const formattedOperator = getExpressionName(op) || op; - const formattedArgs = args.map(arg => { + const formattedArgs = args.map((arg, index) => { + const argOp = isOperator(arg) && arg[0]; const isLowerPrecedence = - isOperator(arg) && OPERATOR_PRECEDENCE[op] > OPERATOR_PRECEDENCE[arg[0]]; - return format(arg, { ...options, parens: isLowerPrecedence }); + isOperator(arg) && OPERATOR_PRECEDENCE[op] > OPERATOR_PRECEDENCE[argOp]; + + // "*","/" always have two arguments. If the second argument of "/" is an expression, we have to calculate it first. + // Hence, adding parenthesis. + // "a / b * c" vs "a / (b * c)", "a / b / c" vs "a / (b / c)" + // "a - b + c" vs "a - (b + c)", "a - b - c" vs "a - (b - c)" + const isSamePrecedenceWithExecutionPriority = + index > 0 && + isOperator(arg) && + OPERATOR_PRECEDENCE[op] === OPERATOR_PRECEDENCE[argOp] && + !EXPRESSION_OPERATOR_WITHOUT_ORDER_PRIORITY.has(op); + + return format(arg, { + ...options, + parens: isLowerPrecedence || isSamePrecedenceWithExecutionPriority, + }); }); const clause = MBQL_CLAUSES[op]; const formatted = diff --git a/frontend/src/metabase-lib/fields.ts b/frontend/src/metabase-lib/fields.ts new file mode 100644 index 0000000000000..2426beef325b6 --- /dev/null +++ b/frontend/src/metabase-lib/fields.ts @@ -0,0 +1,21 @@ +import * as ML from "cljs/metabase.lib.js"; +import type { Clause, ColumnMetadata, Query } from "./types"; + +export function fields(query: Query, stageIndex: number): Clause[] { + return ML.fields(query, stageIndex); +} + +export function withFields( + query: Query, + stageIndex: number, + newFields: ColumnMetadata[], +): Query { + return ML.with_fields(query, stageIndex, newFields); +} + +export function fieldableColumns( + query: Query, + stageIndex: number, +): ColumnMetadata[] { + return ML.fieldable_columns(query, stageIndex); +} diff --git a/frontend/src/metabase-lib/filter.ts b/frontend/src/metabase-lib/filter.ts new file mode 100644 index 0000000000000..2791d27d24e16 --- /dev/null +++ b/frontend/src/metabase-lib/filter.ts @@ -0,0 +1,44 @@ +import * as ML from "cljs/metabase.lib.js"; + +import type { + ColumnMetadata, + ColumnWithOperators, + ExpressionArg, + ExternalOp, + FilterOperator, + FilterClause, + Query, +} from "./types"; + +export function filterableColumns( + query: Query, + stageIndex: number, +): ColumnWithOperators[] { + return ML.filterable_columns(query, stageIndex); +} + +export function filterableColumnOperators( + filterableColumn: ColumnWithOperators, +): FilterOperator[] { + return ML.filterable_column_operators(filterableColumn); +} + +export function filterClause( + filterOperator: FilterOperator, + column: ColumnMetadata, + ...args: ExpressionArg[] +): ExternalOp { + return ML.filter_clause(filterOperator, column, ...args); +} + +export function filter( + query: Query, + stageIndex: number, + booleanExpression: ExternalOp, +): Query { + return ML.filter(query, stageIndex, booleanExpression); +} + +export function filters(query: Query, stageIndex: number): FilterClause[] { + return ML.filters(query, stageIndex); +} diff --git a/frontend/src/metabase-lib/join.ts b/frontend/src/metabase-lib/join.ts new file mode 100644 index 0000000000000..e3cdc3d6d566d --- /dev/null +++ b/frontend/src/metabase-lib/join.ts @@ -0,0 +1,109 @@ +import * as ML from "cljs/metabase.lib.js"; + +import type { + CardMetadata, + ColumnMetadata, + ExternalOp, + FilterClause, + FilterOperator, + Join, + JoinStrategy, + Query, + TableMetadata, +} from "./types"; + +// Something you can join against -- either a raw Table, or a Card, which can be either a plain Saved Question or a +// Model +type Joinable = TableMetadata | CardMetadata; + +export function joins(query: Query, stageIndex: number): Join[] { + return ML.joins(query, stageIndex); +} + +export function joinClause( + joinable: Joinable, + conditions: FilterClause[] | ExternalOp[], +): Join { + return ML.join_clause(joinable, conditions); +} + +export function join(query: Query, stageIndex: number, join: Join): Query { + return ML.join(query, stageIndex, join); +} + +export function availableJoinStrategies( + query: Query, + stageIndex: number, +): JoinStrategy[] { + return ML.available_join_strategies(query, stageIndex); +} + +export function joinStrategy(join: Join): JoinStrategy { + return ML.join_strategy(join); +} + +export function withJoinStrategy(join: Join, strategy: JoinStrategy): Join { + return ML.with_join_strategy(join, strategy); +} + +export function joinConditions(join: Join): FilterClause[] { + return ML.join_conditions(join); +} + +export function withJoinConditions( + join: Join, + newConditions: FilterClause[] | ExternalOp[], +): Join { + return ML.with_join_conditions(join, newConditions); +} + +export function joinConditionLHSColumns( + query: Query, + stageIndex: number, + rhsColumn?: ColumnMetadata, +): ColumnMetadata[] { + return ML.join_condition_lhs_columns(query, stageIndex, rhsColumn); +} + +export function joinConditionRHSColumns( + query: Query, + stageIndex: number, + joinable: Joinable, + lhsColumn?: ColumnMetadata, +): ColumnMetadata[] { + return ML.join_condition_rhs_columns(query, stageIndex, joinable, lhsColumn); +} + +export function joinConditionOperators( + query: Query, + stageIndex: number, + lhsColumn?: ColumnMetadata, + rhsColumn?: ColumnMetadata, +): FilterOperator[] { + return ML.join_condition_operators(query, stageIndex, lhsColumn, rhsColumn); +} + +export function suggestedJoinCondition( + query: Query, + stageIndex: number, + joinable: Joinable, +): FilterClause | null { + return ML.suggested_join_condition(query, stageIndex, joinable); +} + +export function joinFields(join: Join): ColumnMetadata[] { + return ML.join_fields(join); +} + +export function withJoinFields(join: Join, newFields: ColumnMetadata[]): Join { + return ML.with_join_fields(join, newFields); +} + +export function renameJoin( + query: Query, + stageIndex: number, + oldNameOrIndex: string | number, + newName: string, +): Query { + return ML.rename_join(query, stageIndex, oldNameOrIndex, newName); +} diff --git a/frontend/src/metabase-lib/limit.ts b/frontend/src/metabase-lib/limit.ts new file mode 100644 index 0000000000000..3d4cf6e860329 --- /dev/null +++ b/frontend/src/metabase-lib/limit.ts @@ -0,0 +1,15 @@ +import * as ML from "cljs/metabase.lib.limit"; +import type { Query, Limit } from "./types"; + +export function currentLimit(query: Query, stageIndex: number): Limit { + return ML.current_limit(query, stageIndex); +} + +export function limit(query: Query, stageIndex: number, limit: Limit): Query { + return ML.limit(query, stageIndex, limit); +} + +export function hasLimit(query: Query, stageIndex: number) { + const limit = currentLimit(query, stageIndex); + return typeof limit === "number" && limit > 0; +} diff --git a/frontend/src/metabase-lib/metadata.ts b/frontend/src/metabase-lib/metadata.ts new file mode 100644 index 0000000000000..4e506fffeb58e --- /dev/null +++ b/frontend/src/metabase-lib/metadata.ts @@ -0,0 +1,99 @@ +import * as ML from "cljs/metabase.lib.js"; +import * as ML_MetadataCalculation from "cljs/metabase.lib.metadata.calculation"; +import type { DatabaseId } from "metabase-types/api"; +import type Metadata from "./metadata/Metadata"; +import type { + BreakoutClause, + BreakoutClauseDisplayInfo, + Bucket, + BucketDisplayInfo, + Clause, + ClauseDisplayInfo, + ColumnDisplayInfo, + ColumnGroup, + ColumnMetadata, + MetadataProvider, + OrderByClause, + OrderByClauseDisplayInfo, + TableDisplayInfo, + Query, +} from "./types"; + +export function metadataProvider( + databaseId: DatabaseId, + metadata: Metadata, +): MetadataProvider { + return ML.metadataProvider(databaseId, metadata); +} + +export function displayName(query: Query, clause: Clause): string { + return ML_MetadataCalculation.display_name(query, clause); +} + +declare function DisplayInfoFn( + query: Query, + stageIndex: number, + columnMetadata: ColumnMetadata, +): ColumnDisplayInfo; +declare function DisplayInfoFn( + query: Query, + stageIndex: number, + columnGroup: ColumnGroup, +): ColumnDisplayInfo | TableDisplayInfo; +declare function DisplayInfoFn( + query: Query, + stageIndex: number, + orderByClause: OrderByClause, +): OrderByClauseDisplayInfo; +declare function DisplayInfoFn( + query: Query, + stageIndex: number, + breakoutClause: BreakoutClause, +): BreakoutClauseDisplayInfo; +declare function DisplayInfoFn( + query: Query, + stageIndex: number, + clause: Clause, +): ClauseDisplayInfo; +declare function DisplayInfoFn( + query: Query, + stageIndex: number, + bucket: Bucket, +): BucketDisplayInfo; + +// x can be any sort of opaque object, e.g. a clause or metadata map. Values returned depend on what you pass in, but it +// should always have display_name... see :metabase.lib.metadata.calculation/display-info schema +export const displayInfo: typeof DisplayInfoFn = ML.display_info; + +export function groupColumns(columns: ColumnMetadata[]): ColumnGroup[] { + return ML.group_columns(columns); +} + +export function getColumnsFromColumnGroup( + group: ColumnGroup, +): ColumnMetadata[] { + return ML.columns_group_columns(group); +} + +export function describeTemporalUnit( + unit: string | null = null, + n: number = 1, +): string { + return ML.describe_temporal_unit(n, unit); +} + +type IntervalAmount = number | "current" | "next" | "last"; + +export function describeTemporalInterval( + n: IntervalAmount, + unit?: string, +): string { + return ML.describe_temporal_interval(n, unit); +} + +export function describeRelativeDatetime( + n: IntervalAmount, + unit?: string, +): string { + return ML.describe_relative_datetime(n, unit); +} diff --git a/frontend/src/metabase-lib/metadata/Database.ts b/frontend/src/metabase-lib/metadata/Database.ts index 7497b22df5e41..19a122ff19067 100644 --- a/frontend/src/metabase-lib/metadata/Database.ts +++ b/frontend/src/metabase-lib/metadata/Database.ts @@ -36,6 +36,8 @@ class DatabaseInner extends Base { features: DatabaseFeature[]; settings?: DatabaseSettings; native_permissions: NativePermissions; + is_metabot_enabled: boolean; + metabot_schema: string | null; // Only appears in GET /api/database/:id "can-manage"?: boolean; diff --git a/frontend/src/metabase-lib/metadata/Schema.ts b/frontend/src/metabase-lib/metadata/Schema.ts index 81412ec2eda89..f43c64e55c796 100644 --- a/frontend/src/metabase-lib/metadata/Schema.ts +++ b/frontend/src/metabase-lib/metadata/Schema.ts @@ -15,7 +15,7 @@ export default class Schema extends Base { tables: Table[]; displayName() { - return titleize(humanize(this.name)); + return this.name ? titleize(humanize(this.name)) : null; } getTables() { diff --git a/frontend/src/metabase-lib/metadata/Table.ts b/frontend/src/metabase-lib/metadata/Table.ts index f5e63542f7bd8..a2faa56d8edd1 100644 --- a/frontend/src/metabase-lib/metadata/Table.ts +++ b/frontend/src/metabase-lib/metadata/Table.ts @@ -32,6 +32,7 @@ class TableInner extends Base { schema_name: string; db_id: number; fields: Field[]; + latest_sync_timestamp?: any[]; metadata?: Metadata; db?: Database | undefined | null; @@ -108,7 +109,7 @@ class TableInner extends Base { // AGGREGATIONS aggregationOperators() { - return getAggregationOperators(this); + return getAggregationOperators(this, this.fields); } aggregationOperatorsLookup() { @@ -116,12 +117,7 @@ class TableInner extends Base { } aggregationOperator(short) { - return this.aggregation_operators_lookup[short]; - } - - // @deprecated: use aggregationOperatorsLookup - get aggregation_operators_lookup() { - return this.aggregationOperatorsLookup(); + return this.aggregationOperatorsLookup()[short]; } // FIELDS @@ -191,6 +187,7 @@ class TableInner extends Base { } } +// eslint-disable-next-line import/no-default-export -- deprecated usage export default class Table extends memoizeClass( "aggregationOperators", "aggregationOperatorsLookup", diff --git a/frontend/src/metabase-lib/operators/utils/index.js b/frontend/src/metabase-lib/operators/utils/index.js index 926472ffb89b0..11381af64de45 100644 --- a/frontend/src/metabase-lib/operators/utils/index.js +++ b/frontend/src/metabase-lib/operators/utils/index.js @@ -102,9 +102,9 @@ function populateFields(aggregationOperator, fields) { }; } -export function getAggregationOperators(table) { +export function getAggregationOperators(table, fields) { return getSupportedAggregationOperators(table) - .map(operator => populateFields(operator, table.fields)) + .map(operator => populateFields(operator, fields)) .filter( aggregation => !aggregation.requiresField || diff --git a/frontend/src/metabase-lib/operators/utils/index.unit.spec.js b/frontend/src/metabase-lib/operators/utils/index.unit.spec.js index cc7ee2f3a9f26..1a61273965108 100644 --- a/frontend/src/metabase-lib/operators/utils/index.unit.spec.js +++ b/frontend/src/metabase-lib/operators/utils/index.unit.spec.js @@ -202,7 +202,7 @@ describe("metabase-lib/operators/utils", () => { features: ["basic-aggregations", "standard-deviation-aggregations"], }, }; - const fullOperators = getAggregationOperators(table); + const fullOperators = getAggregationOperators(table, fields); return { fullOperators, operators: fullOperators.map(operator => operator.short), diff --git a/frontend/src/metabase-lib/order_by.ts b/frontend/src/metabase-lib/order_by.ts new file mode 100644 index 0000000000000..77b51a16f1591 --- /dev/null +++ b/frontend/src/metabase-lib/order_by.ts @@ -0,0 +1,47 @@ +import * as ML from "cljs/metabase.lib.js"; +import { removeClause } from "./query"; +import type { + ColumnMetadata, + OrderByClause, + OrderByDirection, + Query, +} from "./types"; + +export function orderableColumns( + query: Query, + stageIndex: number, +): ColumnMetadata[] { + return ML.orderable_columns(query, stageIndex); +} + +export function orderBys(query: Query, stageIndex: number): OrderByClause[] { + return ML.order_bys(query, stageIndex); +} + +export function orderBy( + query: Query, + stageIndex: number, + column: ColumnMetadata | OrderByClause, + direction?: OrderByDirection, +): Query { + return ML.order_by(query, stageIndex, column, direction); +} + +export function orderByClause( + column: ColumnMetadata, + direction?: OrderByDirection, +): OrderByClause { + return ML.order_by_clause(column, direction); +} + +export function changeDirection(query: Query, clause: OrderByClause): Query { + return ML.change_direction(query, clause); +} + +export function clearOrderBys(query: Query, stageIndex: number): Query { + let current = query; + orderBys(query, stageIndex).forEach(clause => { + current = removeClause(query, stageIndex, clause); + }); + return current; +} diff --git a/frontend/src/metabase-lib/parameters/utils/click-behavior.js b/frontend/src/metabase-lib/parameters/utils/click-behavior.js index f70b5a6cb6408..f94ef827255f2 100644 --- a/frontend/src/metabase-lib/parameters/utils/click-behavior.js +++ b/frontend/src/metabase-lib/parameters/utils/click-behavior.js @@ -197,7 +197,7 @@ function baseTypeFilterForParameterType(parameterType) { } export function clickBehaviorIsValid(clickBehavior) { - // opens action menu + // opens drill-through menu if (clickBehavior == null) { return true; } diff --git a/frontend/src/metabase-lib/queries/StructuredQuery.ts b/frontend/src/metabase-lib/queries/StructuredQuery.ts index f11c638f80c38..b71ffda6ada66 100644 --- a/frontend/src/metabase-lib/queries/StructuredQuery.ts +++ b/frontend/src/metabase-lib/queries/StructuredQuery.ts @@ -31,13 +31,16 @@ import { isVirtualCardId, getQuestionIdFromVirtualTableId, } from "metabase-lib/metadata/utils/saved-questions"; -import { isCompatibleAggregationOperatorForField } from "metabase-lib/operators/utils"; +import { + getAggregationOperators, + isCompatibleAggregationOperatorForField, +} from "metabase-lib/operators/utils"; import { TYPE } from "metabase-lib/types/constants"; import { fieldRefForColumn } from "metabase-lib/queries/utils/dataset"; import { isSegment } from "metabase-lib/queries/utils/filter"; import { getUniqueExpressionName } from "metabase-lib/queries/utils/expression"; import * as Q from "metabase-lib/queries/utils/query"; -import { memoizeClass } from "metabase-lib/utils"; +import { createLookupByProperty, memoizeClass } from "metabase-lib/utils"; import Dimension, { FieldDimension, ExpressionDimension, @@ -506,6 +509,7 @@ class StructuredQueryInner extends AtomicQuery { } hasAnyClauses() { + // this list should be kept in sync with BE in `metabase.models.card/model-supports-implicit-actions?` return ( this.hasJoins() || this.hasExpressions() || @@ -638,7 +642,27 @@ class StructuredQueryInner extends AtomicQuery { * @returns an array of aggregation options for the currently selected table */ aggregationOperators(): AggregationOperator[] { - return (this.table() && this.table().aggregationOperators()) || []; + const expressionFields = this.expressionDimensions().map( + expressionDimension => expressionDimension.field(), + ); + + const table = this.table(); + return ( + (table && + getAggregationOperators(table, [ + ...expressionFields, + ...table.fields, + ])) || + [] + ); + } + + aggregationOperatorsLookup(): Record { + return createLookupByProperty(this.aggregationOperators(), "short"); + } + + aggregationOperator(short: string): AggregationOperator { + return this.aggregationOperatorsLookup()[short]; } /** @@ -655,7 +679,7 @@ class StructuredQueryInner extends AtomicQuery { */ aggregationFieldOptions(agg: string | AggregationOperator): DimensionOptions { const aggregation: AggregationOperator = - typeof agg === "string" ? this.table().aggregationOperator(agg) : agg; + typeof agg === "string" ? this.aggregationOperator(agg) : agg; if (aggregation) { const fieldOptions = this.fieldOptions(field => { @@ -1258,8 +1282,6 @@ class StructuredQueryInner extends AtomicQuery { const table = this.table(); if (table) { - const dimensionIsFKReference = dimension => dimension.field?.().isFK(); - const filteredNonFKDimensions = this.dimensions().filter(dimensionFilter); for (const dimension of filteredNonFKDimensions) { @@ -1270,6 +1292,7 @@ class StructuredQueryInner extends AtomicQuery { // de-duplicate explicit and implicit joined tables const explicitJoins = this._getExplicitJoinsSet(joins); + const dimensionIsFKReference = dimension => dimension.field?.().isFK(); const fkDimensions = this.dimensions().filter(dimensionIsFKReference); for (const dimension of fkDimensions) { @@ -1717,6 +1740,7 @@ class StructuredQuery extends memoizeClass( "joinedDimensions", "breakoutDimensions", "aggregationDimensions", + "aggregationOperatorsLookup", "fieldDimensions", "columnDimensions", "columnNames", diff --git a/frontend/src/metabase-lib/queries/drills/quick-filter-drill.js b/frontend/src/metabase-lib/queries/drills/quick-filter-drill.js index 4f02affe2ff44..8505973cbbfec 100644 --- a/frontend/src/metabase-lib/queries/drills/quick-filter-drill.js +++ b/frontend/src/metabase-lib/queries/drills/quick-filter-drill.js @@ -1,12 +1,13 @@ import { isa, - isTypeFK, - isTypePK, isDate, isNumeric, + isTypeFK, + isTypePK, } from "metabase-lib/types/utils/isa"; import { TYPE } from "metabase-lib/types/constants"; import { isLocalField } from "metabase-lib/queries/utils"; +import { fieldRefForColumn } from "metabase-lib/queries/utils/dataset"; const INVALID_TYPES = [TYPE.Structured]; @@ -22,73 +23,78 @@ export function quickFilterDrill({ question, clicked }) { return null; } - const { column } = clicked; + const { column, value } = clicked; if (isTypePK(column.semantic_type) || isTypeFK(column.semantic_type)) { return null; } - const operators = getOperatorsForColumn(column); + const operators = getOperatorsForColumn(column, value); return { operators }; } -export function quickFilterDrillQuestion({ question, clicked, operator }) { - const { column, value } = clicked; +export function quickFilterDrillQuestion({ question, clicked, filter }) { + const { column } = clicked; - if (isLocalField(column.field_ref)) { - return question.filter(operator, column, value); + if (isLocalColumn(column)) { + return question.query().filter(filter).question(); + } else { + /** + * For aggregated and custom columns + * with field refs like ["aggregation", 0], + * we need to nest the query as filters like ["=", ["aggregation", 0], value] won't work + * + * So the query like + * { + * aggregations: [["count"]] + * source-table: 2, + * } + * + * Becomes + * { + * source-query: { + * aggregations: [["count"]] + * source-table: 2, + * }, + * filter: ["=", [ "field", "count", {"base-type": "type/BigInteger"} ], value] + * } + */ + return question.query().nest().filter(filter).question(); } - - /** - * For aggregated and custom columns - * with field refs like ["aggregation", 0], - * we need to nest the query as filters like ["=", ["aggregation", 0], value] won't work - * - * So the query like - * { - * aggregations: [["count"]] - * source-table: 2, - * } - * - * Becomes - * { - * source-query: { - * aggregations: [["count"]] - * source-table: 2, - * }, - * filter: ["=", [ "field", "count", {"base-type": "type/BigInteger"} ], value] - * } - */ - - const nestedQuestion = question.query().nest().question(); - - return nestedQuestion.filter( - operator, - { - ...column, - field_ref: getFieldLiteralFromColumn(column), - }, - value, - ); } -function getOperatorsForColumn(column) { - if (isNumeric(column) || isDate(column)) { +function getOperatorsForColumn(column, value) { + const fieldRef = getColumnFieldRef(column); + + if (INVALID_TYPES.some(type => isa(column.base_type, type))) { + return []; + } else if (value == null) { return [ - { name: "<", operator: "<" }, - { name: ">", operator: ">" }, - { name: "=", operator: "=" }, - { name: "≠", operator: "!=" }, + { name: "=", filter: ["is-null", fieldRef] }, + { name: "≠", filter: ["not-null", fieldRef] }, ]; - } else if (!INVALID_TYPES.some(type => isa(column.base_type, type))) { + } else if (isNumeric(column) || isDate(column)) { return [ - { name: "=", operator: "=" }, - { name: "≠", operator: "!=" }, + { name: "<", filter: ["<", fieldRef, value] }, + { name: ">", filter: [">", fieldRef, value] }, + { name: "=", filter: ["=", fieldRef, value] }, + { name: "≠", filter: ["!=", fieldRef, value] }, ]; } else { - return []; + return [ + { name: "=", filter: ["=", fieldRef, value] }, + { name: "≠", filter: ["!=", fieldRef, value] }, + ]; } } -function getFieldLiteralFromColumn(column) { - return ["field", column.name, { "base-type": column.base_type }]; +function isLocalColumn(column) { + return isLocalField(column.field_ref); +} + +function getColumnFieldRef(column) { + if (isLocalColumn(column)) { + return fieldRefForColumn(column); + } else { + return ["field", column.name, { "base-type": column.base_type }]; + } } diff --git a/frontend/src/metabase-lib/queries/structured/Aggregation.ts b/frontend/src/metabase-lib/queries/structured/Aggregation.ts index 7d7b0eadba498..97354cf2e3eaa 100644 --- a/frontend/src/metabase-lib/queries/structured/Aggregation.ts +++ b/frontend/src/metabase-lib/queries/structured/Aggregation.ts @@ -12,7 +12,10 @@ import Metric from "metabase-lib/metadata/Metric"; import StructuredQuery from "../StructuredQuery"; import Dimension, { AggregationDimension } from "../../Dimension"; import MBQLClause from "./MBQLClause"; + const INTEGER_AGGREGATIONS = new Set(["count", "cum-count", "distinct"]); +const ORIGINAL_FIELD_TYPE_AGGREGATIONS = new Set(["min", "max"]); + export default class Aggregation extends MBQLClause { /** * Replaces the aggregation in the parent query and returns the new StructuredQuery @@ -134,7 +137,16 @@ export default class Aggregation extends MBQLClause { baseType() { const short = this.short(); - return INTEGER_AGGREGATIONS.has(short) ? TYPE.Integer : TYPE.Float; + if (INTEGER_AGGREGATIONS.has(short)) { + return TYPE.Integer; + } + + const field = this.dimension()?.field(); + if (ORIGINAL_FIELD_TYPE_AGGREGATIONS.has(short) && field) { + return field.base_type; + } + + return TYPE.Float; } /** @@ -145,9 +157,7 @@ export default class Aggregation extends MBQLClause { return this.aggregation().isValid(); } else if (this.isStandard() && this.dimension()) { const dimension = this.dimension(); - const aggregationOperator = this.query() - .table() - .aggregationOperator(this[0]); + const aggregationOperator = this.query().aggregationOperator(this[0]); return ( aggregationOperator && (!aggregationOperator.requiresField || diff --git a/frontend/src/metabase-lib/queries/utils/drilldown.js b/frontend/src/metabase-lib/queries/utils/drilldown.js index 643f8440a7824..3c7fff1af200f 100644 --- a/frontend/src/metabase-lib/queries/utils/drilldown.js +++ b/frontend/src/metabase-lib/queries/utils/drilldown.js @@ -449,9 +449,7 @@ function columnToFieldDimension(column, metadata) { return; } - const dimension = column.field_ref - ? FieldDimension.parseMBQL(column.field_ref, metadata) - : new FieldDimension(column.id, null, metadata); + const dimension = new FieldDimension(column.id, null, metadata); if (column.unit) { return dimension.withTemporalUnit(column.unit); diff --git a/frontend/src/metabase-lib/queries/utils/query-time.js b/frontend/src/metabase-lib/queries/utils/query-time.js index 55bd73b337755..62cd8af6e5d23 100644 --- a/frontend/src/metabase-lib/queries/utils/query-time.js +++ b/frontend/src/metabase-lib/queries/utils/query-time.js @@ -603,6 +603,10 @@ export const setTimeComponent = (value, hours, minutes) => { } }; +const getMomentDateForSerialization = date => { + return date.clone().locale("en"); +}; + export const TIME_SELECTOR_DEFAULT_HOUR = 12; export const TIME_SELECTOR_DEFAULT_MINUTE = 30; @@ -624,7 +628,7 @@ export const EXCLUDE_OPTIONS = { return { displayName, value, - serialized: date.format("ddd"), + serialized: getMomentDateForSerialization(date).format("ddd"), test: val => value === val, }; }), @@ -645,7 +649,7 @@ export const EXCLUDE_OPTIONS = { return { displayName, value, - serialized: date.format("MMM"), + serialized: getMomentDateForSerialization(date).format("MMM"), test: value => moment(value).format("MMMM") === displayName, }; }; @@ -662,7 +666,7 @@ export const EXCLUDE_OPTIONS = { return { displayName: displayName + suffix, value, - serialized: date.format("Q"), + serialized: getMomentDateForSerialization(date).format("Q"), test: value => moment(value).format("Qo") === displayName, }; }), diff --git a/frontend/src/metabase-lib/query.ts b/frontend/src/metabase-lib/query.ts new file mode 100644 index 0000000000000..5bde33df53758 --- /dev/null +++ b/frontend/src/metabase-lib/query.ts @@ -0,0 +1,44 @@ +import * as ML from "cljs/metabase.lib.js"; +import type { DatabaseId, DatasetQuery } from "metabase-types/api"; +import type { Clause, ColumnMetadata, MetadataProvider, Query } from "./types"; + +export function fromLegacyQuery( + databaseId: DatabaseId, + metadata: MetadataProvider, + datasetQuery: DatasetQuery, +): Query { + return ML.query(databaseId, metadata, datasetQuery); +} + +export function toLegacyQuery(query: Query): DatasetQuery { + return ML.legacy_query(query); +} + +export function suggestedName(query: Query): string { + return ML.suggestedName(query); +} + +export function appendStage(query: Query): Query { + return ML.append_stage(query); +} + +export function dropStage(query: Query, stageIndex: number): Query { + return ML.drop_stage(query, stageIndex); +} + +export function removeClause( + query: Query, + stageIndex: number, + targetClause: Clause, +): Query { + return ML.remove_clause(query, stageIndex, targetClause); +} + +export function replaceClause( + query: Query, + stageIndex: number, + targetClause: Clause, + newClause: Clause | ColumnMetadata, +): Query { + return ML.replace_clause(query, stageIndex, targetClause, newClause); +} diff --git a/frontend/src/metabase-lib/temporal_bucket.ts b/frontend/src/metabase-lib/temporal_bucket.ts new file mode 100644 index 0000000000000..321f525d806f6 --- /dev/null +++ b/frontend/src/metabase-lib/temporal_bucket.ts @@ -0,0 +1,42 @@ +import * as ML from "cljs/metabase.lib.js"; +import { displayInfo } from "./metadata"; +import type { Bucket, ColumnMetadata, Clause, Query } from "./types"; + +export function temporalBucket(clause: Clause | ColumnMetadata): Bucket | null { + return ML.temporal_bucket(clause); +} + +export function availableTemporalBuckets( + query: Query, + stageIndex: number, + column: ColumnMetadata, +): Bucket[] { + return ML.available_temporal_buckets(query, stageIndex, column); +} + +export function isTemporalBucketable( + query: Query, + stageIndex: number, + column: ColumnMetadata, +) { + return availableTemporalBuckets(query, stageIndex, column).length > 0; +} + +export function withTemporalBucket( + column: ColumnMetadata, + bucket: Bucket | null, +): ColumnMetadata { + return ML.with_temporal_bucket(column, bucket); +} + +export function withDefaultTemporalBucket( + query: Query, + stageIndex: number, + column: ColumnMetadata, +) { + const buckets = availableTemporalBuckets(query, stageIndex, column); + const defaultBucket = buckets.find( + bucket => displayInfo(query, stageIndex, bucket).default, + ); + return defaultBucket ? withTemporalBucket(column, defaultBucket) : column; +} diff --git a/frontend/src/metabase-lib/types.ts b/frontend/src/metabase-lib/types.ts new file mode 100644 index 0000000000000..55e0da7746038 --- /dev/null +++ b/frontend/src/metabase-lib/types.ts @@ -0,0 +1,119 @@ +/** + * An "opaque type": this technique gives us a way to pass around opaque CLJS values that TS will track for us, + * and in other files it gets treated like `unknown` so it can't be examined, manipulated or a new one created. + */ +declare const Query: unique symbol; +export type Query = unknown & { _opaque: typeof Query }; + +declare const MetadataProvider: unique symbol; +export type MetadataProvider = unknown & { _opaque: typeof MetadataProvider }; + +declare const TableMetadata: unique symbol; +export type TableMetadata = unknown & { _opaque: typeof TableMetadata }; + +declare const CardMetadata: unique symbol; +export type CardMetadata = unknown & { _opaque: typeof CardMetadata }; + +export type Limit = number | null; + +declare const BreakoutClause: unique symbol; +export type BreakoutClause = unknown & { _opaque: typeof BreakoutClause }; + +declare const OrderByClause: unique symbol; +export type OrderByClause = unknown & { _opaque: typeof OrderByClause }; + +export type OrderByDirection = "asc" | "desc"; + +declare const FilterClause: unique symbol; +export type FilterClause = unknown & { _opaque: typeof FilterClause }; + +export type Clause = BreakoutClause | OrderByClause | FilterClause; + +declare const ColumnMetadata: unique symbol; +export type ColumnMetadata = unknown & { _opaque: typeof ColumnMetadata }; + +declare const ColumnWithOperators: unique symbol; +export type ColumnWithOperators = unknown & { + _opaque: typeof ColumnWithOperators; +}; + +declare const ColumnGroup: unique symbol; +export type ColumnGroup = unknown & { _opaque: typeof ColumnGroup }; + +declare const Bucket: unique symbol; +export type Bucket = unknown & { _opaque: typeof Bucket }; + +export type TableDisplayInfo = { + name: string; + displayName: string; + isSourceTable: boolean; + isFromJoin: boolean; + isImplicitlyJoinable: boolean; +}; + +type TableInlineDisplayInfo = Pick< + TableDisplayInfo, + "name" | "displayName" | "isSourceTable" +>; + +export type ColumnDisplayInfo = { + name: string; + displayName: string; + longDisplayName: string; + + fkReferenceName?: string; + isCalculated: boolean; + isFromJoin: boolean; + isImplicitlyJoinable: boolean; + table?: TableInlineDisplayInfo; + + breakoutPosition?: number; + orderByPosition?: number; + selected?: boolean; +}; + +export type ClauseDisplayInfo = Pick< + ColumnDisplayInfo, + "name" | "displayName" | "longDisplayName" | "table" +>; + +export type BreakoutClauseDisplayInfo = ClauseDisplayInfo; + +export type BucketDisplayInfo = { + displayName: string; + default?: boolean; + selected?: boolean; +}; + +export type OrderByClauseDisplayInfo = ClauseDisplayInfo & { + direction: OrderByDirection; +}; + +declare const FilterOperator: unique symbol; +export type FilterOperator = unknown & { _opaque: typeof FilterOperator }; + +export type ExpressionArg = + | null + | boolean + | number + | string + | ColumnMetadata + | Clause; + +// ExternalOp is a JS-friendly representation of a filter clause or aggregation clause. +declare const ExternalOp: unique symbol; +export type ExternalOp = { + _opaque: typeof ExternalOp; + operator: string; + options: Record; + args: ExpressionArg[]; +}; + +declare const Join: unique symbol; +export type Join = unknown & { _opaque: typeof Join }; + +export type JoinStrategy = + | "left-join" + | "right-join" + | "inner-join" + | "full-join"; diff --git a/frontend/src/metabase-lib/utils/create-lookup-by-property.ts b/frontend/src/metabase-lib/utils/create-lookup-by-property.ts index 698db8b658922..08a4b48a8c4e6 100644 --- a/frontend/src/metabase-lib/utils/create-lookup-by-property.ts +++ b/frontend/src/metabase-lib/utils/create-lookup-by-property.ts @@ -1,8 +1,8 @@ -export function createLookupByProperty(items: any[], property: string) { - const lookup: Record = {}; +export function createLookupByProperty(items: T[], property: keyof T) { + const lookup: Record = {}; for (const item of items) { - lookup[item[property]] = item; + lookup[item[property] as unknown as string] = item; } return lookup; diff --git a/frontend/src/metabase-lib/v2.ts b/frontend/src/metabase-lib/v2.ts new file mode 100644 index 0000000000000..2d504f87d9570 --- /dev/null +++ b/frontend/src/metabase-lib/v2.ts @@ -0,0 +1,17 @@ +// Note: only metabase-lib v2 exports should be added here + +export * from "./binning"; +export * from "./breakout"; +export * from "./column_types"; +export * from "./common"; +export * from "./comparison"; +export * from "./metadata"; +export * from "./breakout"; +export * from "./fields"; +export * from "./limit"; +export * from "./order_by"; +export * from "./filter"; +export * from "./join"; +export * from "./query"; +export * from "./temporal_bucket"; +export * from "./types"; diff --git a/frontend/src/metabase-types/api/actions.ts b/frontend/src/metabase-types/api/actions.ts index 03e144dfec5a3..3e2a6555a5d11 100644 --- a/frontend/src/metabase-types/api/actions.ts +++ b/frontend/src/metabase-types/api/actions.ts @@ -96,7 +96,7 @@ export type OnSubmitActionForm = ( // Action Forms export type ActionDisplayType = "form" | "button"; -export type FieldType = "string" | "number" | "date" | "category"; +export type FieldType = "string" | "number" | "date"; export type DateInputType = "date" | "time" | "datetime"; @@ -108,8 +108,7 @@ export type InputSettingType = | "number" | "select" | "radio" - | "boolean" - | "category"; + | "boolean"; // these types get passed to the input components export type InputComponentType = @@ -121,14 +120,15 @@ export type InputComponentType = | "radio" | "date" | "time" - | "datetime-local" - | "category"; + | "datetime-local"; export type Size = "small" | "medium" | "large"; export type DateRange = [string, string]; export type NumberRange = [number, number]; +export type FieldValueOptions = (string | number)[]; + export interface FieldSettings { id: string; name: string; @@ -142,7 +142,7 @@ export interface FieldSettings { defaultValue?: string | number; hidden: boolean; range?: DateRange | NumberRange; - valueOptions?: (string | number)[]; + valueOptions?: FieldValueOptions; width?: Size; height?: number; hasSearch?: boolean; diff --git a/frontend/src/metabase-types/api/activity.ts b/frontend/src/metabase-types/api/activity.ts index c84572be24680..b1909254dddeb 100644 --- a/frontend/src/metabase-types/api/activity.ts +++ b/frontend/src/metabase-types/api/activity.ts @@ -5,6 +5,10 @@ export interface ModelObject { } export interface RecentItem { + cnt: number; + max_ts: string; + model_id: number; + user_id: number; model: ModelType; model_object: ModelObject; } diff --git a/frontend/src/metabase-types/api/collection.ts b/frontend/src/metabase-types/api/collection.ts index ea1930680fc52..500eee95f13f0 100644 --- a/frontend/src/metabase-types/api/collection.ts +++ b/frontend/src/metabase-types/api/collection.ts @@ -1,5 +1,6 @@ import { UserId } from "./user"; import { CardDisplayType } from "./card"; +import { DatabaseId } from "./database"; export type RegularCollectionId = number; @@ -48,9 +49,11 @@ type CollectionItemModel = | "pulse" | "collection"; -export interface CollectionItem { - id: number; - model: T; +export type CollectionItemId = number; + +export interface CollectionItem { + id: CollectionItemId; + model: CollectionItemModel; name: string; description: string | null; copy?: boolean; @@ -60,6 +63,8 @@ export interface CollectionItem { collection?: Collection; display?: CardDisplayType; personal_owner_id?: UserId; + database_id?: DatabaseId; + moderated_status?: string; getIcon: () => { name: string }; getUrl: (opts?: Record) => string; setArchived?: (isArchived: boolean) => void; @@ -67,3 +72,9 @@ export interface CollectionItem { setCollection?: (collection: Collection) => void; setCollectionPreview?: (isEnabled: boolean) => void; } + +export interface CollectionListQuery { + archived?: boolean; + "exclude-other-user-collections"?: boolean; + namespace?: string; +} diff --git a/frontend/src/metabase-types/api/database.ts b/frontend/src/metabase-types/api/database.ts index 92f3dde139a51..d56beff7aeec1 100644 --- a/frontend/src/metabase-types/api/database.ts +++ b/frontend/src/metabase-types/api/database.ts @@ -1,4 +1,3 @@ -import { NativePermissions } from "./permissions"; import { ScheduleSettings } from "./settings"; import { Table } from "./table"; import { ISO8601Time } from "."; @@ -17,15 +16,23 @@ export type DatabaseFeature = | "basic-aggregations" | "binning" | "case-sensitivity-string-filter-options" + | "dynamic-schema" | "expression-aggregations" | "expressions" | "foreign-keys" | "native-parameters" | "nested-queries" | "standard-deviation-aggregations" + | "percentile-aggregations" | "persist-models" | "persist-models-enabled" - | "set-timezone"; + | "schemas" + | "set-timezone" + | "left-join" + | "right-join" + | "inner-join" + | "full-join" + | "nested-field-columns"; export interface Database extends DatabaseData { id: DatabaseId; @@ -33,11 +40,10 @@ export interface Database extends DatabaseData { features: DatabaseFeature[]; creator_id?: number; timezone?: string; - native_permissions: NativePermissions; + native_permissions: "write" | "none"; initial_sync_status: InitialSyncStatus; - - settings?: DatabaseSettings | null; - + caveats?: string; + points_of_interest?: string; created_at: ISO8601Time; updated_at: ISO8601Time; @@ -55,9 +61,12 @@ export interface DatabaseData { auto_run_queries: boolean | null; refingerprint: boolean | null; cache_ttl: number | null; + is_metabot_enabled: boolean; + metabot_schema: string | null; is_sample: boolean; is_full_sync: boolean; is_on_demand: boolean; + settings?: DatabaseSettings | null; } export interface DatabaseSchedules { @@ -71,3 +80,26 @@ export interface DatabaseUsageInfo { metric: number; segment: number; } + +export interface DatabaseQuery { + include?: "tables" | "tables.fields"; + include_editable_data_model?: boolean; + exclude_uneditable_details?: boolean; +} + +export interface DatabaseListQuery { + include?: "tables"; + saved?: boolean; + include_editable_data_model?: boolean; + exclude_uneditable_details?: boolean; +} + +export interface DatabaseIdFieldListQuery { + include_editable_data_model?: boolean; +} + +export interface SavedQuestionDatabase { + id: -1337; + name: "Saved Questions"; + is_saved_questions: true; +} diff --git a/frontend/src/metabase-types/api/dataset.ts b/frontend/src/metabase-types/api/dataset.ts index 0051890d155c8..587e93b9d77cc 100644 --- a/frontend/src/metabase-types/api/dataset.ts +++ b/frontend/src/metabase-types/api/dataset.ts @@ -34,11 +34,16 @@ export interface DatasetData { download_perms?: DownloadPermission; } +export type JsonQuery = { + parameters: unknown[]; +}; + export interface Dataset { data: DatasetData; database_id: DatabaseId; row_count: number; running_time: number; + json_query?: JsonQuery; } export interface NativeQueryForm { diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts index 8a19ea130c438..15e591cdcda38 100644 --- a/frontend/src/metabase-types/api/index.ts +++ b/frontend/src/metabase-types/api/index.ts @@ -12,6 +12,7 @@ export * from "./field"; export * from "./foreign-key"; export * from "./group"; export * from "./metric"; +export * from "./metabot"; export * from "./models"; export * from "./notifications"; export * from "./permissions"; @@ -19,8 +20,10 @@ export * from "./query"; export * from "./revision"; export * from "./segment"; export * from "./settings"; +export * from "./setup"; export * from "./slack"; export * from "./snippets"; +export * from "./store"; export * from "./table"; export * from "./timeline"; export * from "./user"; diff --git a/frontend/src/metabase-types/api/metabot.ts b/frontend/src/metabase-types/api/metabot.ts new file mode 100644 index 0000000000000..dbbdb7cfbe236 --- /dev/null +++ b/frontend/src/metabase-types/api/metabot.ts @@ -0,0 +1,5 @@ +export type MetabotFeedbackType = + | "great" + | "wrong_data" + | "incorrect_result" + | "invalid_sql"; diff --git a/frontend/src/metabase-types/api/metric.ts b/frontend/src/metabase-types/api/metric.ts index 6912b8b01f955..fd6f393392d60 100644 --- a/frontend/src/metabase-types/api/metric.ts +++ b/frontend/src/metabase-types/api/metric.ts @@ -1,3 +1,4 @@ +import { GroupIds } from "metabase/admin/types"; import { StructuredQuery } from "./query"; import { TableId } from "./table"; @@ -11,4 +12,5 @@ export interface Metric { archived: boolean; definition: StructuredQuery; revision_message?: string; + groups: String; } diff --git a/frontend/src/metabase-types/api/mocks/actions.ts b/frontend/src/metabase-types/api/mocks/actions.ts index efc366bb2aa90..f5fc4e651327e 100644 --- a/frontend/src/metabase-types/api/mocks/actions.ts +++ b/frontend/src/metabase-types/api/mocks/actions.ts @@ -37,6 +37,7 @@ export const createMockQueryAction = ({ name: "Query Action Mock", description: null, model_id: 1, + database_id: 1, parameters: [], creator_id: creator.id, creator, @@ -58,6 +59,7 @@ export const createMockImplicitQueryAction = ({ name: "Create", description: "", model_id: 1, + database_id: 1, parameters: [], visualization_settings: undefined, creator_id: creator.id, diff --git a/frontend/src/metabase-types/api/mocks/activity.ts b/frontend/src/metabase-types/api/mocks/activity.ts index c54c2ceee9198..646e7e1cd5d5e 100644 --- a/frontend/src/metabase-types/api/mocks/activity.ts +++ b/frontend/src/metabase-types/api/mocks/activity.ts @@ -12,6 +12,10 @@ export const createMockRecentItem = ( ): RecentItem => ({ model: "table", model_object: createMockModelObject(), + cnt: 1, + model_id: 1, + max_ts: "2021-03-01T00:00:00.000Z", + user_id: 1, ...opts, }); diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts index 8384fb7853aac..51586f5fcf8b0 100644 --- a/frontend/src/metabase-types/api/mocks/index.ts +++ b/frontend/src/metabase-types/api/mocks/index.ts @@ -15,5 +15,7 @@ export * from "./segment"; export * from "./table"; export * from "./timeline"; export * from "./settings"; +export * from "./setup"; export * from "./snippets"; +export * from "./store"; export * from "./user"; diff --git a/frontend/src/metabase-types/api/mocks/presets/index.ts b/frontend/src/metabase-types/api/mocks/presets/index.ts new file mode 100644 index 0000000000000..acfe6d3e828e6 --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/presets/index.ts @@ -0,0 +1,2 @@ +export * from "./sample_cards"; +export * from "./sample_database"; diff --git a/frontend/src/metabase-types/api/mocks/presets/sample_cards.ts b/frontend/src/metabase-types/api/mocks/presets/sample_cards.ts new file mode 100644 index 0000000000000..6001cdc5f3c6e --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/presets/sample_cards.ts @@ -0,0 +1,133 @@ +import { + Card, + NativeDatasetQuery, + StructuredDatasetQuery, + UnsavedCard, +} from "metabase-types/api"; +import { + createMockStructuredCard, + createMockNativeCard, +} from "metabase-types/api/mocks"; +import { ORDERS_ID, SAMPLE_DB_ID } from "./sample_database"; + +type StructuredCard = Card; +type StructuredUnsavedCard = UnsavedCard; +type NativeCard = Card; +type NativeUnsavedCard = UnsavedCard; + +export const createAdHocCard = ( + opts?: Partial, +): StructuredUnsavedCard => ({ + display: "table", + visualization_settings: {}, + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + }, + }, + ...opts, +}); + +export const createAdHocNativeCard = ( + opts?: Partial, +): NativeUnsavedCard => ({ + display: "table", + visualization_settings: {}, + dataset_query: { + type: "native", + database: SAMPLE_DB_ID, + native: { + query: "select * from orders", + "template-tags": {}, + }, + }, + ...opts, +}); + +export const createEmptyAdHocNativeCard = ( + opts?: Partial, +): NativeUnsavedCard => ({ + display: "table", + visualization_settings: {}, + dataset_query: { + type: "native", + database: SAMPLE_DB_ID, + native: { + query: "", + "template-tags": {}, + }, + }, + ...opts, +}); + +export const createSavedStructuredCard = ( + opts?: Partial, +): StructuredCard => { + return createMockStructuredCard({ + display: "table", + visualization_settings: {}, + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + }, + }, + ...opts, + }); +}; + +export const createSavedNativeCard = ( + opts?: Partial, +): NativeCard => { + return createMockNativeCard({ + display: "table", + visualization_settings: {}, + dataset_query: { + type: "native", + database: SAMPLE_DB_ID, + native: { + query: "select * from orders", + "template-tags": {}, + }, + }, + ...opts, + }); +}; + +export const createStructuredModelCard = ( + opts?: Partial, +): StructuredCard => { + return createSavedStructuredCard({ + dataset: true, + ...opts, + }); +}; + +export const createNativeModelCard = ( + opts?: Partial, +): NativeCard => { + return createSavedNativeCard({ + dataset: true, + ...opts, + }); +}; + +export const createComposedModelCard = ({ + id = 1, + ...opts +}: Partial = {}): StructuredCard => { + return createStructuredModelCard({ + dataset_query: { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": `card__${id}`, + }, + }, + ...opts, + id, + }); +}; diff --git a/frontend/src/metabase-types/api/mocks/presets/sample_database.ts b/frontend/src/metabase-types/api/mocks/presets/sample_database.ts new file mode 100644 index 0000000000000..b62d795358e5e --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/presets/sample_database.ts @@ -0,0 +1,1341 @@ +import { + Database, + Field, + FieldDimensionOption, + FieldValues, + Table, +} from "metabase-types/api"; +import { + createMockDatabase, + createMockTable, + createMockField, + createMockFingerprint, + createMockGlobalFieldFingerprint, + createMockTextFieldFingerprint, + createMockNumberFieldFingerprint, + createMockDateTimeFieldFingerprint, +} from "metabase-types/api/mocks"; + +export const SAMPLE_DB_ID = 1; +export const ORDERS_ID = 2; +export const PEOPLE_ID = 5; +export const PRODUCTS_ID = 1; +export const REVIEWS_ID = 8; + +export const ORDERS = { + ID: 11, + USER_ID: 15, + PRODUCT_ID: 9, + SUBTOTAL: 16, + TAX: 10, + TOTAL: 13, + DISCOUNT: 17, + CREATED_AT: 14, + QUANTITY: 12, +}; + +export const PEOPLE = { + ID: 32, + ADDRESS: 42, + EMAIL: 37, + PASSWORD: 34, + NAME: 39, + CITY: 31, + LONGITUDE: 40, + STATE: 33, + SOURCE: 36, + BIRTH_DATE: 35, + ZIP: 43, + LATITUDE: 41, + CREATED_AT: 38, +}; + +export const PRODUCTS = { + ID: 3, + EAN: 5, + TITLE: 8, + CATEGORY: 1, + VENDOR: 4, + PRICE: 7, + RATING: 2, + CREATED_AT: 6, +}; + +export const REVIEWS = { + ID: 67, + PRODUCT_ID: 68, + REVIEWER: 69, + RATING: 66, + BODY: 70, + CREATED_AT: 71, +}; + +// Note: don't assign field values to the field object itself +// Field values are not included in the field object in the API response +// Please use `setupFieldValuesEndpoints` utility from `__support__/server-mocks` + +export const PRODUCT_CATEGORY_VALUES: FieldValues = { + field_id: PRODUCTS.CATEGORY, + values: [["Doohickey"], ["Gadget"], ["Gizmo"], ["Widget"]], + has_more_values: false, +}; + +export const PRODUCT_VENDOR_VALUES: FieldValues = { + field_id: PRODUCTS.VENDOR, + values: [["Vendor 1"], ["Vendor 2"], ["Vendor 3"], ["Vendor 4"]], + has_more_values: true, +}; + +export const PEOPLE_SOURCE_VALUES: FieldValues = { + field_id: PEOPLE.SOURCE, + values: [["Affiliate"], ["Facebook"], ["Google"], ["Organic"], ["Twitter"]], + has_more_values: false, +}; + +const DEFAULT_NUMERIC_BINNING_OPTION: FieldDimensionOption = { + name: "Auto bin", + mbql: ["field", null, { binning: { strategy: "default" } }], + type: "type/Number", +}; + +const DEFAULT_COORDINATE_BINNING_OPTION: FieldDimensionOption = { + name: "Auto bin", + mbql: ["field", null, { binning: { strategy: "default" } }], + type: "type/Coordinate", +}; + +const DEFAULT_TEMPORAL_BUCKETING_OPTION: FieldDimensionOption = { + name: "Day", + mbql: ["field", null, { "temporal-unit": "day" }], + type: "type/DateTime", +}; + +export const createSampleDatabase = (opts?: Partial): Database => + createMockDatabase({ + id: SAMPLE_DB_ID, + name: "Sample Database", + tables: [ + createOrdersTable(), + createPeopleTable(), + createProductsTable(), + createReviewsTable(), + ], + is_sample: true, + ...opts, + }); + +export const createOrdersTable = (opts?: Partial
): Table => + createMockTable({ + id: ORDERS_ID, + db_id: SAMPLE_DB_ID, + name: "ORDERS", + display_name: "Orders", + schema: "PUBLIC", + fields: [ + createOrdersIdField(), + createOrdersUserIdField(), + createOrdersProductIdField(), + createOrdersSubtotalField(), + createOrdersTaxField(), + createOrdersTotalField(), + createOrdersDiscountField(), + createOrdersCreatedAtField(), + createOrdersQuantityField(), + ], + dimension_options: createTableDimensionOptions(), + ...opts, + }); + +export const createPeopleTable = (opts?: Partial
): Table => + createMockTable({ + id: PEOPLE_ID, + db_id: SAMPLE_DB_ID, + name: "PEOPLE", + display_name: "People", + schema: "PUBLIC", + fields: [ + createPeopleIdField(), + createPeopleAddressField(), + createPeopleEmailField(), + createPeoplePasswordField(), + createPeopleNameField(), + createPeopleCityField(), + createPeopleLongitudeField(), + createPeopleStateField(), + createPeopleSourceField(), + createPeopleBirthDateField(), + createPeopleZipField(), + createPeopleLatitudeField(), + createPeopleCreatedAtField(), + ], + dimension_options: createTableDimensionOptions(), + ...opts, + }); + +export const createProductsTable = (opts?: Partial
): Table => + createMockTable({ + id: PRODUCTS_ID, + db_id: SAMPLE_DB_ID, + name: "PRODUCTS", + display_name: "Products", + description: "All of our products", + schema: "PUBLIC", + fields: [ + createProductsIdField(), + createProductsEanField(), + createProductsTitleField(), + createProductsCategoryField(), + createProductsVendorField(), + createProductsPriceField(), + createProductsRatingField(), + createProductsCreatedAtField(), + ], + dimension_options: createTableDimensionOptions(), + ...opts, + }); + +export const createReviewsTable = (opts?: Partial
): Table => + createMockTable({ + id: REVIEWS_ID, + db_id: SAMPLE_DB_ID, + name: "REVIEWS", + display_name: "Reviews", + schema: "PUBLIC", + fields: [ + createReviewsIdField(), + createReviewsProductIdField(), + createReviewsReviewerField(), + createReviewsRatingField(), + createReviewsBodyField(), + createReviewsCreatedAtField(), + ], + dimension_options: createTableDimensionOptions(), + ...opts, + }); + +export const createOrdersIdField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.ID, + table_id: ORDERS_ID, + name: "ID", + display_name: "ID", + base_type: "type/BigInteger", + effective_type: "type/BigInteger", + semantic_type: "type/PK", + has_field_values: "none", + fingerprint: null, + ...opts, + }); + +export const createOrdersUserIdField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.USER_ID, + table_id: ORDERS_ID, + name: "USER_ID", + display_name: "User ID", + base_type: "type/Integer", + effective_type: "type/Integer", + semantic_type: "type/FK", + fk_target_field_id: PEOPLE.ID, + has_field_values: "none", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 929, + }), + }), + ...opts, + }); + +export const createOrdersProductIdField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.PRODUCT_ID, + table_id: ORDERS_ID, + name: "PRODUCT_ID", + display_name: "Product ID", + base_type: "type/Integer", + effective_type: "type/Integer", + semantic_type: "type/FK", + fk_target_field_id: PRODUCTS.ID, + has_field_values: "none", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 200, + }), + }), + ...opts, + }); + +export const createOrdersSubtotalField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.SUBTOTAL, + table_id: ORDERS_ID, + name: "SUBTOTAL", + display_name: "Subtotal", + base_type: "type/Float", + effective_type: "type/Float", + semantic_type: null, + has_field_values: "none", + default_dimension_option: DEFAULT_NUMERIC_BINNING_OPTION, + dimension_options: createNumericFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 340, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 15.691943673970439, + q1: 49.74894519060184, + q3: 105.42965746993103, + max: 148.22900526552291, + sd: 32.53705013056317, + avg: 77.01295465356547, + }), + }, + }), + ...opts, + }); + +export const createOrdersTaxField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.TAX, + table_id: ORDERS_ID, + name: "TAX", + display_name: "Tax", + base_type: "type/Float", + effective_type: "type/Float", + semantic_type: null, + has_field_values: "none", + default_dimension_option: DEFAULT_NUMERIC_BINNING_OPTION, + dimension_options: createNumericFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 797, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 0, + q1: 2.273340386603857, + q3: 5.337275338216307, + max: 11.12, + sd: 2.3206651358900316, + avg: 3.8722100000000004, + }), + }, + }), + ...opts, + }); + +export const createOrdersTotalField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.TOTAL, + table_id: ORDERS_ID, + name: "TOTAL", + display_name: "Total", + base_type: "type/Float", + effective_type: "type/Float", + semantic_type: null, + has_field_values: "none", + default_dimension_option: DEFAULT_NUMERIC_BINNING_OPTION, + dimension_options: createNumericFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 4426, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 8.93914247937167, + q1: 51.34535490743823, + q3: 110.29428389265787, + max: 159.34900526552292, + sd: 34.26469575709948, + avg: 80.35871658771228, + }), + }, + }), + ...opts, + }); + +export const createOrdersDiscountField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.DISCOUNT, + table_id: ORDERS_ID, + name: "DISCOUNT", + display_name: "Discount", + base_type: "type/Float", + effective_type: "type/Float", + semantic_type: "type/Discount", + has_field_values: "none", + default_dimension_option: DEFAULT_NUMERIC_BINNING_OPTION, + dimension_options: createNumericFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 701, + "nil%": 0.898, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 0.17088996672584322, + q1: 2.9786226681458743, + q3: 7.338187788658235, + max: 61.69684269960571, + sd: 3.053663125001991, + avg: 5.161255547580326, + }), + }, + }), + ...opts, + }); + +export const createOrdersCreatedAtField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.CREATED_AT, + table_id: ORDERS_ID, + name: "CREATED_AT", + display_name: "Created At", + base_type: "type/DateTime", + effective_type: "type/DateTime", + semantic_type: "type/CreationTimestamp", + has_field_values: "none", + default_dimension_option: DEFAULT_TEMPORAL_BUCKETING_OPTION, + dimension_options: createTemporalFieldBucketingOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 9998, + }), + type: { + "type/DateTime": createMockDateTimeFieldFingerprint({ + earliest: "2016-04-30T18:56:13.352Z", + latest: "2020-04-19T14:07:15.657Z", + }), + }, + }), + ...opts, + }); + +export const createOrdersQuantityField = (opts?: Partial): Field => + createMockField({ + id: ORDERS.QUANTITY, + table_id: ORDERS_ID, + name: "QUANTITY", + display_name: "Quantity", + base_type: "type/Integer", + effective_type: "type/Integer", + semantic_type: "type/Quantity", + default_dimension_option: DEFAULT_NUMERIC_BINNING_OPTION, + dimension_options: createNumericFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 62, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 0, + q1: 1.755882607764982, + q3: 4.882654507928044, + max: 100, + sd: 4.214258386403798, + avg: 3.7015, + }), + }, + }), + ...opts, + }); + +export const createPeopleIdField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.ID, + table_id: PEOPLE_ID, + name: "ID", + display_name: "ID", + base_type: "type/BigInteger", + effective_type: "type/BigInteger", + semantic_type: "type/PK", + fingerprint: null, + has_field_values: "none", + ...opts, + }); + +export const createPeopleAddressField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.ADDRESS, + table_id: PEOPLE_ID, + name: "ADDRESS", + display_name: "Address", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: null, + has_field_values: "search", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2490, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 20.85, + }), + }, + }), + ...opts, + }); + +export const createPeopleEmailField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.EMAIL, + table_id: PEOPLE_ID, + name: "EMAIL", + display_name: "Email", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/Email", + has_field_values: "search", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2500, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "percent-email": 1, + "average-length": 24.1824, + }), + }, + }), + ...opts, + }); + +export const createPeoplePasswordField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.PASSWORD, + table_id: PEOPLE_ID, + name: "PASSWORD", + display_name: "Password", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: null, + + // It's actually set to "search" in the original sample database, + // but it's handy having a string field with no values for testing + has_field_values: "none", + + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2500, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 36, + }), + }, + }), + ...opts, + }); + +export const createPeopleNameField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.NAME, + table_id: PEOPLE_ID, + name: "NAME", + display_name: "Name", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/Name", + has_field_values: "search", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2499, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 13.532, + }), + }, + }), + ...opts, + }); + +export const createPeopleCityField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.CITY, + table_id: PEOPLE_ID, + name: "CITY", + display_name: "City", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/City", + has_field_values: "search", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 1966, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "percent-state": 0.002, + "average-length": 8.284, + }), + }, + }), + ...opts, + }); + +export const createPeopleLongitudeField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.LONGITUDE, + table_id: PEOPLE_ID, + name: "LONGITUDE", + display_name: "Longitude", + base_type: "type/Float", + effective_type: "type/Float", + semantic_type: "type/Longitude", + has_field_values: "none", + default_dimension_option: DEFAULT_COORDINATE_BINNING_OPTION, + dimension_options: createCoordinateFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2491, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: -166.5425726, + q1: -101.58350792373135, + q3: -84.65289348288829, + max: -67.96735199999999, + sd: 15.399698968175663, + avg: -95.18741780363999, + }), + }, + }), + ...opts, + }); + +export const createPeopleStateField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.STATE, + table_id: PEOPLE_ID, + name: "STATE", + display_name: "State", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/State", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 49, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "percent-state": 1, + "average-length": 2, + }), + }, + }), + ...opts, + }); + +export const createPeopleSourceField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.SOURCE, + table_id: PEOPLE_ID, + name: "SOURCE", + display_name: "Source", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/Source", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 5, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 7.4084, + }), + }, + }), + ...opts, + }); + +export const createPeopleBirthDateField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.BIRTH_DATE, + table_id: PEOPLE_ID, + name: "BIRTH_DATE", + display_name: "Birth Date", + base_type: "type/Date", + effective_type: "type/Date", + semantic_type: null, + has_field_values: "none", + default_dimension_option: DEFAULT_TEMPORAL_BUCKETING_OPTION, + dimension_options: createTemporalFieldBucketingOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2308, + }), + type: { + "type/DateTime": createMockDateTimeFieldFingerprint({ + earliest: "1958-04-26", + latest: "2000-04-03", + }), + }, + }), + ...opts, + }); + +export const createPeopleZipField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.ZIP, + table_id: PEOPLE_ID, + name: "ZIP", + display_name: "Zip", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/ZipCode", + has_field_values: "search", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2234, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 5, + }), + }, + }), + ...opts, + }); + +export const createPeopleLatitudeField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.LATITUDE, + table_id: PEOPLE_ID, + name: "LATITUDE", + display_name: "Latitude", + base_type: "type/Float", + effective_type: "type/Float", + semantic_type: "type/Latitude", + has_field_values: "none", + default_dimension_option: DEFAULT_COORDINATE_BINNING_OPTION, + dimension_options: createCoordinateFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2491, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 25.775827, + q1: 35.302705923023126, + q3: 43.773802584662, + max: 70.6355001, + sd: 6.390832341883712, + avg: 39.87934670484002, + }), + }, + }), + ...opts, + }); + +export const createPeopleCreatedAtField = (opts?: Partial): Field => + createMockField({ + id: PEOPLE.CREATED_AT, + table_id: PEOPLE_ID, + name: "CREATED_AT", + display_name: "Created At", + base_type: "type/DateTime", + effective_type: "type/Text", + semantic_type: "type/CreationTimestamp", + default_dimension_option: DEFAULT_TEMPORAL_BUCKETING_OPTION, + dimension_options: createTemporalFieldBucketingOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 2500, + "nil%": 0, + }), + type: { + "type/DateTime": createMockDateTimeFieldFingerprint({ + earliest: "2016-04-19T21:35:18.752Z", + latest: "2019-04-19T14:06:27.3Z", + }), + }, + }), + ...opts, + }); + +export const createProductsIdField = (opts?: Partial): Field => + createMockField({ + id: PRODUCTS.ID, + table_id: PRODUCTS_ID, + name: "ID", + display_name: "ID", + base_type: "type/BigInteger", + effective_type: "type/BigInteger", + semantic_type: "type/PK", + has_field_values: "none", + fingerprint: null, + ...opts, + }); + +export const createProductsEanField = (opts?: Partial): Field => + createMockField({ + id: PRODUCTS.EAN, + table_id: PRODUCTS_ID, + name: "EAN", + display_name: "Ean", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: null, + has_field_values: "none", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 200, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 13, + }), + }, + }), + ...opts, + }); + +export const createProductsTitleField = (opts?: Partial): Field => + createMockField({ + id: PRODUCTS.TITLE, + table_id: PRODUCTS_ID, + name: "TITLE", + display_name: "Title", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/Title", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 199, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 21.495, + }), + }, + }), + ...opts, + }); + +export const createProductsCategoryField = (opts?: Partial): Field => + createMockField({ + id: PRODUCTS.CATEGORY, + table_id: PRODUCTS_ID, + name: "CATEGORY", + display_name: "Category", + description: "The type of product.", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/Category", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 4, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 6.375, + }), + }, + }), + ...opts, + }); + +export const createProductsVendorField = (opts?: Partial): Field => + createMockField({ + id: PRODUCTS.VENDOR, + table_id: PRODUCTS_ID, + name: "VENDOR", + display_name: "Vendor", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/Company", + has_field_values: "search", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 200, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 20.6, + }), + }, + }), + ...opts, + }); + +export const createProductsPriceField = (opts?: Partial): Field => + createMockField({ + id: PRODUCTS.PRICE, + table_id: PRODUCTS_ID, + name: "PRICE", + display_name: "Price", + base_type: "type/Float", + effective_type: "type/Float", + semantic_type: null, + has_field_values: "none", + default_dimension_option: DEFAULT_NUMERIC_BINNING_OPTION, + dimension_options: createNumericFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 170, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 15.691943673970439, + q1: 37.25154462926434, + q3: 75.45898071609447, + max: 98.81933684368194, + sd: 21.711481557852057, + avg: 55.74639966792074, + }), + }, + }), + ...opts, + }); + +export const createProductsRatingField = (opts?: Partial): Field => + createMockField({ + id: PRODUCTS.RATING, + table_id: PRODUCTS_ID, + name: "RATING", + display_name: "Rating", + base_type: "type/Float", + effective_type: "type/Float", + semantic_type: "type/Score", + has_field_values: "none", + default_dimension_option: DEFAULT_NUMERIC_BINNING_OPTION, + dimension_options: createNumericFieldBinningOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 23, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 0, + q1: 3.5120465053408525, + q3: 4.216124969497314, + max: 5, + sd: 1.3605488657451452, + avg: 3.4715, + }), + }, + }), + ...opts, + }); + +export const createProductsCreatedAtField = (opts?: Partial): Field => + createMockField({ + id: PRODUCTS.CREATED_AT, + table_id: PRODUCTS_ID, + name: "CREATED_AT", + display_name: "Created At", + base_type: "type/DateTime", + effective_type: "type/DateTime", + semantic_type: "type/CreationTimestamp", + has_field_values: "none", + default_dimension_option: DEFAULT_TEMPORAL_BUCKETING_OPTION, + dimension_options: createTemporalFieldBucketingOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 200, + }), + type: { + "type/DateTime": createMockDateTimeFieldFingerprint({ + earliest: "2016-04-26T19:29:55.147Z", + latest: "2019-04-15T13:34:19.931Z", + }), + }, + }), + ...opts, + }); + +export const createReviewsIdField = (opts?: Partial): Field => + createMockField({ + id: REVIEWS.ID, + table_id: REVIEWS_ID, + name: "ID", + display_name: "ID", + base_type: "type/BigInteger", + semantic_type: "type/PK", + has_field_values: "none", + fingerprint: null, + ...opts, + }); + +export const createReviewsProductIdField = (opts?: Partial): Field => + createMockField({ + id: REVIEWS.PRODUCT_ID, + table_id: REVIEWS_ID, + name: "PRODUCT_ID", + display_name: "Product ID", + base_type: "type/Integer", + effective_type: "type/Integer", + semantic_type: "type/FK", + fk_target_field_id: PRODUCTS.ID, + has_field_values: "none", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 176, + "nil%": 0, + }), + }), + ...opts, + }); + +export const createReviewsReviewerField = (opts?: Partial): Field => + createMockField({ + id: REVIEWS.REVIEWER, + table_id: REVIEWS_ID, + name: "REVIEWER", + display_name: "Reviewer", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: null, + has_field_values: "search", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 1076, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "percent-state": 0.001798561151079137, + "average-length": 9.972122302158274, + }), + }, + }), + ...opts, + }); + +export const createReviewsRatingField = (opts?: Partial): Field => + createMockField({ + id: REVIEWS.RATING, + table_id: REVIEWS_ID, + name: "RATING", + display_name: "Rating", + base_type: "type/Integer", + effective_type: "type/Integer", + semantic_type: "type/Score", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 5, + }), + type: { + "type/Number": createMockNumberFieldFingerprint({ + min: 1, + q1: 3.54744353181696, + q3: 4.764807071650455, + max: 5, + sd: 1.0443899855660577, + avg: 3.987410071942446, + }), + }, + }), + ...opts, + }); + +export const createReviewsBodyField = (opts?: Partial): Field => + createMockField({ + id: REVIEWS.BODY, + table_id: REVIEWS_ID, + name: "BODY", + display_name: "Body", + base_type: "type/Text", + effective_type: "type/Text", + semantic_type: "type/Description", + has_field_values: "search", + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 1112, + }), + type: { + "type/Text": createMockTextFieldFingerprint({ + "average-length": 177.41996402877697, + }), + }, + }), + ...opts, + }); + +export const createReviewsCreatedAtField = (opts?: Partial): Field => + createMockField({ + id: REVIEWS.CREATED_AT, + table_id: REVIEWS_ID, + name: "CREATED_AT", + display_name: "Created At", + base_type: "type/DateTime", + effective_type: "type/DateTime", + semantic_type: "type/CreationTimestamp", + default_dimension_option: DEFAULT_TEMPORAL_BUCKETING_OPTION, + dimension_options: createTemporalFieldBucketingOptions(), + fingerprint: createMockFingerprint({ + global: createMockGlobalFieldFingerprint({ + "distinct-count": 1112, + }), + type: { + "type/DateTime": createMockDateTimeFieldFingerprint({ + earliest: "2016-06-03T00:37:05.818Z", + latest: "2020-04-19T14:15:25.677Z", + }), + }, + }), + ...opts, + }); + +function createTemporalBucketingOptions(): Record< + string, + FieldDimensionOption +> { + return { + "0": { + name: "Day", + mbql: ["field", null, { "temporal-unit": "day" }], + type: "type/Date", + }, + "1": { + name: "Week", + mbql: ["field", null, { "temporal-unit": "week" }], + type: "type/Date", + }, + "2": { + name: "Month", + mbql: ["field", null, { "temporal-unit": "month" }], + type: "type/Date", + }, + "3": { + name: "Quarter", + mbql: ["field", null, { "temporal-unit": "quarter" }], + type: "type/Date", + }, + "4": { + name: "Year", + mbql: ["field", null, { "temporal-unit": "year" }], + type: "type/Date", + }, + "5": { + name: "Day of week", + mbql: ["field", null, { "temporal-unit": "day-of-week" }], + type: "type/Date", + }, + "6": { + name: "Day of month", + mbql: ["field", null, { "temporal-unit": "day-of-month" }], + type: "type/Date", + }, + "7": { + name: "Day of year", + mbql: ["field", null, { "temporal-unit": "day-of-year" }], + type: "type/Date", + }, + "8": { + name: "Week of year", + mbql: ["field", null, { "temporal-unit": "week-of-year" }], + type: "type/Date", + }, + "9": { + name: "Month of year", + mbql: ["field", null, { "temporal-unit": "month-of-year" }], + type: "type/Date", + }, + "10": { + name: "Quarter of year", + mbql: ["field", null, { "temporal-unit": "quarter-of-year" }], + type: "type/Date", + }, + "11": { + name: "Minute", + mbql: ["field", null, { "temporal-unit": "minute" }], + type: "type/DateTime", + }, + "12": { + name: "Hour", + mbql: ["field", null, { "temporal-unit": "hour" }], + type: "type/DateTime", + }, + "13": { + name: "Day", + mbql: ["field", null, { "temporal-unit": "day" }], + type: "type/DateTime", + }, + "14": { + name: "Week", + mbql: ["field", null, { "temporal-unit": "week" }], + type: "type/DateTime", + }, + "15": { + name: "Month", + mbql: ["field", null, { "temporal-unit": "month" }], + type: "type/DateTime", + }, + "16": { + name: "Quarter", + mbql: ["field", null, { "temporal-unit": "quarter" }], + type: "type/DateTime", + }, + "17": { + name: "Year", + mbql: ["field", null, { "temporal-unit": "year" }], + type: "type/DateTime", + }, + "18": { + name: "Minute of hour", + mbql: ["field", null, { "temporal-unit": "minute-of-hour" }], + type: "type/DateTime", + }, + "19": { + name: "Hour of day", + mbql: ["field", null, { "temporal-unit": "hour-of-day" }], + type: "type/DateTime", + }, + "20": { + name: "Day of week", + mbql: ["field", null, { "temporal-unit": "day-of-week" }], + type: "type/DateTime", + }, + "21": { + name: "Day of month", + mbql: ["field", null, { "temporal-unit": "day-of-month" }], + type: "type/DateTime", + }, + "22": { + name: "Day of year", + mbql: ["field", null, { "temporal-unit": "day-of-year" }], + type: "type/DateTime", + }, + "23": { + name: "Week of year", + mbql: ["field", null, { "temporal-unit": "week-of-year" }], + type: "type/DateTime", + }, + "24": { + name: "Month of year", + mbql: ["field", null, { "temporal-unit": "month-of-year" }], + type: "type/DateTime", + }, + "25": { + name: "Quarter of year", + mbql: ["field", null, { "temporal-unit": "quarter-of-year" }], + type: "type/DateTime", + }, + "26": { + name: "Minute", + mbql: ["field", null, { "temporal-unit": "minute" }], + type: "type/Time", + }, + "27": { + name: "Hour", + mbql: ["field", null, { "temporal-unit": "hour" }], + type: "type/Time", + }, + "28": { + name: "Minute of hour", + mbql: ["field", null, { "temporal-unit": "minute-of-hour" }], + type: "type/Time", + }, + }; +} +``; +function createNumericBinningOptions(): Record { + return { + "29": { + name: "Auto bin", + mbql: ["field", null, { binning: { strategy: "default" } }], + type: "type/Number", + }, + "30": { + name: "10 bins", + mbql: [ + "field", + null, + { binning: { strategy: "num-bins", "num-bins": 10 } }, + ], + type: "type/Number", + }, + "31": { + name: "50 bins", + mbql: [ + "field", + null, + { binning: { strategy: "num-bins", "num-bins": 50 } }, + ], + type: "type/Number", + }, + "32": { + name: "100 bins", + mbql: [ + "field", + null, + { binning: { strategy: "num-bins", "num-bins": 100 } }, + ], + type: "type/Number", + }, + "33": { name: "Don't bin", mbql: null, type: "type/Number" }, + }; +} + +function createCoordinateBinningOptions(): Record< + string, + FieldDimensionOption +> { + return { + "34": { + name: "Auto bin", + mbql: ["field", null, { binning: { strategy: "default" } }], + type: "type/Coordinate", + }, + "35": { + name: "Bin every 0.1 degrees", + mbql: [ + "field", + null, + { binning: { strategy: "bin-width", "bin-width": 0.1 } }, + ], + type: "type/Coordinate", + }, + "36": { + name: "Bin every 1 degree", + mbql: [ + "field", + null, + { binning: { strategy: "bin-width", "bin-width": 1 } }, + ], + type: "type/Coordinate", + }, + "37": { + name: "Bin every 10 degrees", + mbql: [ + "field", + null, + { binning: { strategy: "bin-width", "bin-width": 10 } }, + ], + type: "type/Coordinate", + }, + "38": { + name: "Bin every 20 degrees", + mbql: [ + "field", + null, + { binning: { strategy: "bin-width", "bin-width": 20 } }, + ], + type: "type/Coordinate", + }, + "39": { name: "Don't bin", mbql: null, type: "type/Coordinate" }, + }; +} + +function createTableDimensionOptions() { + return { + ...createTemporalBucketingOptions(), + ...createNumericBinningOptions(), + ...createCoordinateBinningOptions(), + }; +} + +function createTemporalFieldBucketingOptions() { + return Object.values(createTemporalBucketingOptions()); +} + +function createNumericFieldBinningOptions() { + return Object.values(createNumericBinningOptions()); +} + +function createCoordinateFieldBinningOptions() { + return Object.values(createCoordinateBinningOptions()); +} diff --git a/frontend/src/metabase-types/api/mocks/revision.ts b/frontend/src/metabase-types/api/mocks/revision.ts new file mode 100644 index 0000000000000..cccf167aade39 --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/revision.ts @@ -0,0 +1,21 @@ +import { Revision } from "metabase-types/api"; + +export const createMockRevision = (opts?: Partial): Revision => { + return { + id: 1, + description: "created this", + message: null, + timestamp: "2023-05-16T13:33:30.198622-07:00", + is_creation: true, + is_reversion: false, + has_multiple_changes: false, + user: { + id: 1, + first_name: "Admin", + last_name: "Test", + common_name: "Admin Test", + }, + diff: null, + ...opts, + }; +}; diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index 1ebe636dfdab2..ff8a01c245069 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -155,12 +155,17 @@ export const createMockSettings = (opts?: Partial): Settings => ({ "google-auth-configured": false, "google-auth-enabled": false, "is-hosted?": false, + "is-metabot-enabled": false, "jwt-enabled": false, "jwt-configured": false, "ldap-configured?": false, "ldap-enabled": false, "loading-message": "doing-science", "other-sso-enabled?": null, + "openai-api-key": null, + "openai-organization": null, + "openai-model": null, + "openai-available-models": [], "password-complexity": { total: 6, digit: 1 }, "persisted-models-enabled": false, "premium-embedding-token": null, diff --git a/frontend/src/metabase-types/api/mocks/setup.ts b/frontend/src/metabase-types/api/mocks/setup.ts new file mode 100644 index 0000000000000..621f4a34966d9 --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/setup.ts @@ -0,0 +1,22 @@ +import { SetupCheckListItem, SetupCheckListTask } from "metabase-types/api"; + +export const createMockSetupCheckListItem = ( + opts?: Partial, +): SetupCheckListItem => ({ + name: "Setup", + tasks: [], + ...opts, +}); + +export const createMockSetupCheckListTask = ( + opts?: Partial, +): SetupCheckListTask => ({ + title: "Setup", + group: "Setup", + description: "", + link: "/", + completed: false, + triggered: true, + is_next_step: false, + ...opts, +}); diff --git a/frontend/src/metabase-types/api/mocks/store.ts b/frontend/src/metabase-types/api/mocks/store.ts new file mode 100644 index 0000000000000..12f8ab14d95d3 --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/store.ts @@ -0,0 +1,9 @@ +import { StoreTokenStatus } from "metabase-types/api"; + +export const createMockStoreTokenStatus = ( + opts?: Partial, +): StoreTokenStatus => ({ + ...opts, + valid: false, + trial: false, +}); diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index 21c0a9e599b6f..74e067ea603c0 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -128,6 +128,15 @@ export interface TokenStatus { status?: TokenStatusStatus; } +export type DayOfWeekId = + | "sunday" + | "monday" + | "tuesday" + | "wednesday" + | "thursday" + | "friday" + | "saturday"; + export interface TokenFeatures { advanced_config: boolean; advanced_permissions: boolean; @@ -152,7 +161,13 @@ export interface SettingDefinition { value?: unknown; } +export interface OpenAiModel { + id: string; + owned_by: string; +} + export interface Settings { + "active-users-count"?: number; "admin-email": string; "anon-tracking-enabled": boolean; "application-font": string; @@ -182,11 +197,16 @@ export interface Settings { "hide-embed-branding?": boolean; "is-hosted?": boolean; "jwt-enabled"?: boolean; + "is-metabot-enabled": boolean; "jwt-configured"?: boolean; "ldap-configured?": boolean; "ldap-enabled": boolean; "loading-message": LoadingMessage; "other-sso-enabled?": boolean | null; + "openai-api-key": string | null; + "openai-organization": string | null; + "openai-model": string | null; + "openai-available-models"?: OpenAiModel[]; "password-complexity": PasswordComplexity; "persisted-models-enabled": boolean; "premium-embedding-token": string | null; @@ -211,6 +231,7 @@ export interface Settings { "slack-files-channel": string | null; "slack-token": string | null; "slack-token-valid?": boolean; + "start-of-week"?: DayOfWeekId; "subscription-allowed-domains": string | null; "token-features": TokenFeatures; "token-status": TokenStatus | null; @@ -221,4 +242,3 @@ export interface Settings { } export type SettingKey = keyof Settings; -0; diff --git a/frontend/src/metabase-types/api/setup.ts b/frontend/src/metabase-types/api/setup.ts new file mode 100644 index 0000000000000..a5b55565fac59 --- /dev/null +++ b/frontend/src/metabase-types/api/setup.ts @@ -0,0 +1,14 @@ +export interface SetupCheckListItem { + name: string; + tasks: SetupCheckListTask[]; +} + +export interface SetupCheckListTask { + title: string; + group: string; + description: string; + link: string; + completed: boolean; + triggered: boolean; + is_next_step: boolean; +} diff --git a/frontend/src/metabase-types/api/store.ts b/frontend/src/metabase-types/api/store.ts new file mode 100644 index 0000000000000..4f696fb85a79d --- /dev/null +++ b/frontend/src/metabase-types/api/store.ts @@ -0,0 +1,4 @@ +export interface StoreTokenStatus { + valid: boolean; + trial: boolean; +} diff --git a/frontend/src/metabase-types/api/user.ts b/frontend/src/metabase-types/api/user.ts index e1db8712b9473..f4480a94fbeb4 100644 --- a/frontend/src/metabase-types/api/user.ts +++ b/frontend/src/metabase-types/api/user.ts @@ -1,3 +1,5 @@ +import { GroupIds } from "metabase/admin/types"; + export type UserId = number; export type UserAttribute = string; @@ -13,7 +15,7 @@ export interface BaseUser { is_active: boolean; is_qbnewb: boolean; is_superuser: boolean; - + group_ids: GroupIds; date_joined: string; last_login: string; first_login: string; diff --git a/frontend/src/metabase-types/guards/forms.ts b/frontend/src/metabase-types/guards/forms.ts index e1ea0900b0cc0..9969322194081 100644 --- a/frontend/src/metabase-types/guards/forms.ts +++ b/frontend/src/metabase-types/guards/forms.ts @@ -3,12 +3,13 @@ import type { CustomFormFieldDefinition, FormFieldDefinition, } from "metabase-types/forms"; +import { isReactComponent } from "./react"; export function isCustomWidget( formField: FormFieldDefinition, ): formField is CustomFormFieldDefinition { return ( !(formField as StandardFormFieldDefinition).type && - typeof (formField as CustomFormFieldDefinition).widget === "function" + isReactComponent((formField as CustomFormFieldDefinition).widget) ); } diff --git a/frontend/src/metabase-types/guards/react.ts b/frontend/src/metabase-types/guards/react.ts index 0fa5ae58f48e2..d31d9170155e1 100644 --- a/frontend/src/metabase-types/guards/react.ts +++ b/frontend/src/metabase-types/guards/react.ts @@ -8,3 +8,15 @@ export function isReactDOMTypeElement( ): element is React.ReactElement { return ReactIs.isElement(element) && typeof element.type === "string"; } + +export function isReactComponent( + component: any, +): component is React.FC | React.Component | React.ExoticComponent { + return ( + typeof component === "function" || + // Checking for "Exotic" components such as ones returned by memo, forwardRef + (typeof component === "object" && + "$$typeof" in component && + typeof component["$$typeof"] === "symbol") + ); +} diff --git a/frontend/src/metabase-types/store/index.ts b/frontend/src/metabase-types/store/index.ts index 0ca79be771b0b..f232527f8e44d 100644 --- a/frontend/src/metabase-types/store/index.ts +++ b/frontend/src/metabase-types/store/index.ts @@ -3,6 +3,7 @@ export * from "./app"; export * from "./dashboard"; export * from "./embed"; export * from "./entities"; +export * from "./metabot"; export * from "./settings"; export * from "./qb"; export * from "./setup"; diff --git a/frontend/src/metabase-types/store/metabot.ts b/frontend/src/metabase-types/store/metabot.ts new file mode 100644 index 0000000000000..bd60b45976e33 --- /dev/null +++ b/frontend/src/metabase-types/store/metabot.ts @@ -0,0 +1,29 @@ +import { + Card, + CardId, + TableId, + DatabaseId, + Dataset, + MetabotFeedbackType, +} from "metabase-types/api"; +import { Deferred } from "metabase/lib/promise"; + +export type MetabotEntityId = CardId | DatabaseId; +export type MetabotEntityType = "database" | "model"; +export type MetabotQueryStatus = "idle" | "running" | "complete"; + +export interface MetabotState { + entityId: MetabotEntityId | null; + entityType: MetabotEntityType | null; + card: Card | null; + promptTemplateVersions: string[] | null; + datasetQuery: string; + prompt: string; + table: TableId; + initialTable: TableId; + queryStatus: MetabotQueryStatus; + queryResults: [Dataset] | null; + queryError: unknown; + feedbackType: MetabotFeedbackType | null; + cancelQueryDeferred: Deferred | null; +} diff --git a/frontend/src/metabase-types/store/mocks/index.ts b/frontend/src/metabase-types/store/mocks/index.ts index cadf854c2ff96..93f57f29d7ad4 100644 --- a/frontend/src/metabase-types/store/mocks/index.ts +++ b/frontend/src/metabase-types/store/mocks/index.ts @@ -3,6 +3,7 @@ export * from "./app"; export * from "./dashboard"; export * from "./embed"; export * from "./entities"; +export * from "./metabot"; export * from "./parameters"; export * from "./qb"; export * from "./settings"; diff --git a/frontend/src/metabase-types/store/mocks/metabot.ts b/frontend/src/metabase-types/store/mocks/metabot.ts new file mode 100644 index 0000000000000..c9bd2d8430ede --- /dev/null +++ b/frontend/src/metabase-types/store/mocks/metabot.ts @@ -0,0 +1,17 @@ +import { MetabotState } from "metabase-types/store"; + +export const createMockMetabotState = ( + opts?: Partial, +): MetabotState => ({ + entityId: null, + entityType: null, + card: null, + prompt: "", + queryStatus: "idle", + queryResults: null, + queryError: null, + feedbackType: null, + promptTemplateVersions: null, + cancelQueryDeferred: null, + ...opts, +}); diff --git a/frontend/src/metabase-types/store/mocks/state.ts b/frontend/src/metabase-types/store/mocks/state.ts index 264e95f991b93..e3030ef9c2803 100644 --- a/frontend/src/metabase-types/store/mocks/state.ts +++ b/frontend/src/metabase-types/store/mocks/state.ts @@ -5,6 +5,7 @@ import { createMockAppState, createMockDashboardState, createMockEmbedState, + createMockMetabotState, createMockEntitiesState, createMockParametersState, createMockQueryBuilderState, @@ -18,6 +19,7 @@ export const createMockState = (opts?: Partial): State => ({ currentUser: createMockUser(), dashboard: createMockDashboardState(), embed: createMockEmbedState(), + metabot: createMockMetabotState(), entities: createMockEntitiesState(), parameters: createMockParametersState(), qb: createMockQueryBuilderState(), diff --git a/frontend/src/metabase-types/store/state.ts b/frontend/src/metabase-types/store/state.ts index 290e2ae0da466..b50f027a403a5 100644 --- a/frontend/src/metabase-types/store/state.ts +++ b/frontend/src/metabase-types/store/state.ts @@ -4,6 +4,7 @@ import { AppState } from "./app"; import { DashboardState } from "./dashboard"; import { EmbedState } from "./embed"; import { EntitiesState } from "./entities"; +import { MetabotState } from "./metabot"; import { QueryBuilderState } from "./qb"; import { ParametersState } from "./parameters"; import { SettingsState } from "./settings"; @@ -16,6 +17,7 @@ export interface State { dashboard: DashboardState; embed: EmbedState; entities: EntitiesState; + metabot: MetabotState; qb: QueryBuilderState; parameters: ParametersState; settings: SettingsState; diff --git a/frontend/src/metabase-types/types/Database.ts b/frontend/src/metabase-types/types/Database.ts index e19cdc8e58679..eef4a3a90b3ca 100644 --- a/frontend/src/metabase-types/types/Database.ts +++ b/frontend/src/metabase-types/types/Database.ts @@ -32,7 +32,7 @@ export type DatabaseEngine = string; export type DatabaseNativePermission = "write" | "read"; export type Database = { - id: DatabaseId; + id?: DatabaseId; name: string; description?: string; diff --git a/frontend/src/metabase/account/notifications/containers/NotificationsApp/NotificationsApp.jsx b/frontend/src/metabase/account/notifications/containers/NotificationsApp/NotificationsApp.jsx index 70a8a21964fa3..2d9bf183c6256 100644 --- a/frontend/src/metabase/account/notifications/containers/NotificationsApp/NotificationsApp.jsx +++ b/frontend/src/metabase/account/notifications/containers/NotificationsApp/NotificationsApp.jsx @@ -33,8 +33,8 @@ export default _.compose( reload: true, }), Pulses.loadList({ - // Load all pulses the current user can read (i.e. is a creator or recipient of) - query: state => ({ can_read: true }), + // Load all pulses the current user is a creator or recipient of + query: state => ({ creator_or_recipient: true }), reload: true, }), connect(mapStateToProps, mapDispatchToProps), diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionForm.styled.tsx b/frontend/src/metabase/actions/components/ActionForm/ActionForm.styled.tsx index 14b8a155d297a..1a9ac6fca1a6b 100644 --- a/frontend/src/metabase/actions/components/ActionForm/ActionForm.styled.tsx +++ b/frontend/src/metabase/actions/components/ActionForm/ActionForm.styled.tsx @@ -1,43 +1,7 @@ import styled from "@emotion/styled"; -import { space } from "metabase/styled-components/theme"; -import { color } from "metabase/lib/colors"; export const ActionFormButtonContainer = styled.div` display: flex; justify-content: flex-end; gap: 0.5rem; `; - -interface FormFieldContainerProps { - isSettings?: boolean; -} - -export const FormFieldContainer = styled.div` - ${({ isSettings }) => - isSettings && - ` - position: relative; - display: flex; - align-items: center; - border-radius: ${space(1)}; - padding: ${space(1)}; - margin-bottom: ${space(1)}; - background-color: ${color("bg-white")}; - border: 1px solid ${color("border")}; - overflow: hidden; - `} -`; - -export const SettingsContainer = styled.div` - display: flex; - flex-direction: column; - justify-content: space-between; - color: ${color("text-medium")}; - margin-right: ${space(1)}; -`; - -export const InputContainer = styled.div` - flex-grow: 1; - flex-basis: 1; - flex-shrink: 0; -`; diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx b/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx index c205f8dc18b5b..f2e44160091e7 100644 --- a/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx +++ b/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx @@ -1,12 +1,6 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { t } from "ttag"; -import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; -import type { - DraggableProvided, - OnDragEndResponder, - DroppableProvided, -} from "react-beautiful-dnd"; import type { FormikHelpers } from "formik"; import Button from "metabase/core/components/Button"; @@ -14,196 +8,99 @@ import Form from "metabase/core/components/Form"; import FormProvider from "metabase/core/components/FormProvider"; import FormSubmitButton from "metabase/core/components/FormSubmitButton"; import FormErrorMessage from "metabase/core/components/FormErrorMessage"; -import Icon from "metabase/components/Icon"; + +import useActionForm from "metabase/actions/hooks/use-action-form"; +import { + getSubmitButtonColor, + getSubmitButtonLabel, +} from "metabase/actions/utils"; import type { ActionFormInitialValues, - ActionFormSettings, - FieldSettings, - WritebackParameter, - Parameter, + ParameterId, ParametersForActionExecution, + WritebackAction, } from "metabase-types/api"; -import { reorderFields } from "metabase/actions/containers/ActionCreator/FormCreator"; -import { FieldSettingsButtons } from "../../containers/ActionCreator/FormCreator/FieldSettingsButtons"; -import { FormFieldWidget } from "./ActionFormFieldWidget"; -import { - ActionFormButtonContainer, - FormFieldContainer, - SettingsContainer, - InputContainer, -} from "./ActionForm.styled"; +import ActionFormFieldWidget from "../ActionFormFieldWidget"; -import { getForm, getFormValidationSchema } from "./utils"; +import { ActionFormButtonContainer } from "./ActionForm.styled"; -export interface ActionFormComponentProps { - parameters: WritebackParameter[] | Parameter[]; +interface ActionFormProps { + action: WritebackAction; initialValues?: ActionFormInitialValues; - isEditable?: boolean; - onClose?: () => void; - onSubmit?: ( - params: ParametersForActionExecution, + + // Parameters that shouldn't be displayed in the form + // Can be used to "lock" certain parameter values. + // E.g. when a value is coming from a dashboard filter. + // Hidden field values should still be included in initialValues, + // and they will be submitted together in batch. + hiddenFields?: ParameterId[]; + + onSubmit: ( + parameters: ParametersForActionExecution, actions: FormikHelpers, ) => void; - submitTitle?: string; - submitButtonColor?: string; - formSettings?: ActionFormSettings; - setFormSettings?: (formSettings: ActionFormSettings) => void; + onClose?: () => void; } -export const ActionForm = ({ - parameters, - initialValues = {}, - isEditable, - onClose, +function ActionForm({ + action, + initialValues: rawInitialValues = {}, + hiddenFields = [], onSubmit, - submitTitle, - submitButtonColor = "primary", - formSettings, - setFormSettings, -}: ActionFormComponentProps): JSX.Element => { - // allow us to change the color of the submit button - const submitButtonVariant = { [submitButtonColor]: true }; - - const isSettings = !!(formSettings && setFormSettings); - - const form = useMemo( - () => getForm(parameters, formSettings?.fields), - [parameters, formSettings?.fields], - ); + onClose, +}: ActionFormProps): JSX.Element { + const { initialValues, form, validationSchema, getCleanValues } = + useActionForm({ + action, + initialValues: rawInitialValues, + }); - const formValidationSchema = useMemo( - () => getFormValidationSchema(parameters, formSettings?.fields), - [parameters, formSettings?.fields], + const editableFields = useMemo( + () => form.fields.filter(field => !hiddenFields.includes(field.name)), + [form, hiddenFields], ); - const handleDragEnd: OnDragEndResponder = ({ source, destination }) => { - if (!isSettings) { - return; - } - - const oldOrder = source.index; - const newOrder = destination?.index ?? source.index; - - const reorderedFields = reorderFields( - formSettings.fields ?? {}, - oldOrder, - newOrder, - ); - setFormSettings({ - ...formSettings, - fields: reorderedFields, - }); - }; - - const handleChangeFieldSettings = (newFieldSettings: FieldSettings) => { - if (!isSettings || !newFieldSettings?.id) { - return; - } - - setFormSettings({ - ...formSettings, - fields: { - ...formSettings.fields, - [newFieldSettings.id]: newFieldSettings, - }, - }); - }; - - const handleSubmit = ( - values: ParametersForActionExecution, - actions: FormikHelpers, - ) => onSubmit?.(formValidationSchema.cast(values), actions); - - if (isSettings) { - const fieldSettings = formSettings.fields || {}; - return ( - -
- - - {(provided: DroppableProvided) => ( -
- {form.fields.map((field, index) => ( - - {(provided: DraggableProvided) => ( - - {isEditable && ( - - - - )} - - - - {isEditable && ( - - )} - - )} - - ))} -
- )} -
-
- -
- ); - } - - const hasFormFields = !!form.fields.length; + const submitButtonProps = useMemo(() => { + const variant = getSubmitButtonColor(action); + return { + title: getSubmitButtonLabel(action), + [variant]: true, + }; + }, [action]); + + const handleSubmit = useCallback( + ( + values: ParametersForActionExecution, + actions: FormikHelpers, + ) => onSubmit(getCleanValues(values), actions), + [getCleanValues, onSubmit], + ); return ( - {({ dirty }) => ( -
- {form.fields.map(field => ( - - ))} - - - {onClose && } - - - - - - )} +
+ {editableFields.map(field => ( + + ))} + + + {onClose && ( + + )} + + + + +
); -}; +} + +export default ActionForm; diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionForm.unit.spec.tsx b/frontend/src/metabase/actions/components/ActionForm/ActionForm.unit.spec.tsx index fb989930ab1a7..84ebda01aec3f 100644 --- a/frontend/src/metabase/actions/components/ActionForm/ActionForm.unit.spec.tsx +++ b/frontend/src/metabase/actions/components/ActionForm/ActionForm.unit.spec.tsx @@ -8,9 +8,12 @@ import type { ParametersForActionExecution, WritebackParameter, } from "metabase-types/api"; -import { createMockActionParameter } from "metabase-types/api/mocks"; +import { + createMockActionParameter, + createMockQueryAction, +} from "metabase-types/api/mocks"; -import { ActionForm } from "./ActionForm"; +import ActionForm from "./ActionForm"; const makeFieldSettings = ( overrides: Partial = {}, @@ -43,37 +46,31 @@ type SetupOpts = { initialValues?: ParametersForActionExecution; parameters: WritebackParameter[]; formSettings: ActionFormSettings; - isSettings?: boolean; + onSubmit?: () => Promise; }; const setup = ({ initialValues, parameters, formSettings, - isSettings = false, + onSubmit = jest.fn(), }: SetupOpts) => { - const setFormSettings = jest.fn(); - const onSubmit = jest.fn(); + const action = createMockQueryAction({ + parameters, + visualization_settings: formSettings, + }); render( , ); - return { setFormSettings, onSubmit }; + return { action, onSubmit }; }; -function setupSettings(opts: Omit) { - return setup({ ...opts, isSettings: true }); -} - describe("Actions > ActionForm", () => { describe("Form Display", () => { it("displays a form with am input label", () => { @@ -231,7 +228,7 @@ describe("Actions > ActionForm", () => { }); it("can submit form field values", async () => { - const { onSubmit } = setup({ + const { action, onSubmit } = setup({ parameters: [ makeParameter({ id: "abc-123" }), makeParameter({ id: "def-456" }), @@ -255,7 +252,7 @@ describe("Actions > ActionForm", () => { userEvent.type(screen.getByLabelText(/text input/i), "Murloc"); userEvent.type(screen.getByLabelText(/number input/i), "12345"); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( @@ -267,11 +264,47 @@ describe("Actions > ActionForm", () => { ); }); }); + + it("shows an error if submit fails", async () => { + const message = "Something went wrong when submitting the form."; + const error = { success: false, error: message, message }; + const { action } = await setup({ + onSubmit: jest.fn().mockRejectedValue(error), + parameters: [ + makeParameter({ id: "abc-123" }), + makeParameter({ id: "def-456" }), + ], + formSettings: { + type: "form", + fields: { + "abc-123": makeFieldSettings({ + inputType: "string", + id: "abc-123", + title: "text input", + }), + "def-456": makeFieldSettings({ + inputType: "number", + id: "def-456", + title: "number input", + }), + }, + }, + }); + + userEvent.type(screen.getByLabelText(/text input/i), "Murloc"); + userEvent.type(screen.getByLabelText(/number input/i), "12345"); + userEvent.click(screen.getByRole("button", { name: action.name })); + + expect(await screen.findByText(message)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: action.name }), + ).toHaveTextContent("Failed"); + }); }); describe("Form Validation", () => { it("allows form submission when required fields are provided", async () => { - const { onSubmit } = setup({ + const { action, onSubmit } = setup({ parameters: [ makeParameter({ id: "abc-123" }), makeParameter({ id: "def-456" }), @@ -297,16 +330,14 @@ describe("Actions > ActionForm", () => { userEvent.type(await screen.findByLabelText(/foo input/i), "baz"); userEvent.type(await screen.findByLabelText(/bar input/i), "baz"); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); await waitFor(() => expect(onSubmit).toHaveBeenCalled()); - expect( - screen.queryByText(/this field is required/i), - ).not.toBeInTheDocument(); + expect(screen.queryByText(/required/i)).not.toBeInTheDocument(); }); it("disables form submission when required fields are not provided", async () => { - const { onSubmit } = setup({ + const { action, onSubmit } = setup({ parameters: [ makeParameter({ id: "abc-123" }), makeParameter({ id: "def-456" }), @@ -333,19 +364,57 @@ describe("Actions > ActionForm", () => { userEvent.click(await screen.findByLabelText(/foo input/i)); // leave empty userEvent.type(await screen.findByLabelText(/bar input/i), "baz"); await waitFor(() => - expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(), + expect( + screen.getByRole("button", { name: action.name }), + ).toBeDisabled(), ); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); - expect( - await screen.findByText(/this field is required/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/required/i)).toBeInTheDocument(); expect(onSubmit).not.toHaveBeenCalled(); }); - it("disables form submission when no fields are changed", async () => { - const { onSubmit } = setup({ + it("allows form submission when all required fields are set", async () => { + const { action, onSubmit } = setup({ + parameters: [ + makeParameter({ id: "abc-123" }), + makeParameter({ id: "def-456" }), + ], + formSettings: { + type: "form", + fields: { + "abc-123": makeFieldSettings({ + inputType: "string", + id: "abc-123", + title: "foo input", + required: true, + }), + "def-456": makeFieldSettings({ + inputType: "string", + id: "def-456", + title: "bar input", + required: false, + }), + }, + }, + }); + + expect(screen.getByRole("button", { name: action.name })).toBeDisabled(); + + userEvent.type(screen.getByLabelText(/foo input/i), "baz"); + await waitFor(() => { + expect(screen.getByRole("button", { name: action.name })).toBeEnabled(); + }); + + userEvent.click(screen.getByRole("button", { name: action.name })); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled(); + }); + }); + + it("allows form submission when all fields are optional", async () => { + const { action, onSubmit } = setup({ parameters: [ makeParameter({ id: "abc-123" }), makeParameter({ id: "def-456" }), @@ -369,11 +438,11 @@ describe("Actions > ActionForm", () => { }, }); - expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(screen.getByRole("button", { name: action.name })).toBeEnabled(); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); - await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()); + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); }); it("cannot type a string in a numeric field", async () => { @@ -394,7 +463,7 @@ describe("Actions > ActionForm", () => { }); it("allows submission of a null non-required boolean field", async () => { - const { onSubmit } = setup({ + const { action, onSubmit } = setup({ parameters: [ makeParameter({ id: "abc-123" }), makeParameter({ id: "def-456" }), @@ -420,16 +489,14 @@ describe("Actions > ActionForm", () => { userEvent.type(await screen.findByLabelText(/foo input/i), "baz"); userEvent.type(await screen.findByLabelText(/bar input/i), "baz"); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); await waitFor(() => expect(onSubmit).toHaveBeenCalled()); - expect( - screen.queryByText(/this field is required/i), - ).not.toBeInTheDocument(); + expect(screen.queryByText(/required/i)).not.toBeInTheDocument(); }); it("sets a default value for an empty field", async () => { - const { onSubmit } = setup({ + const { action, onSubmit } = setup({ parameters: [ makeParameter({ id: "abc-123" }), makeParameter({ id: "def-456" }), @@ -442,29 +509,27 @@ describe("Actions > ActionForm", () => { id: "abc-123", title: "foo input", required: true, + defaultValue: "foo", }), "def-456": makeFieldSettings({ inputType: "boolean", id: "def-456", title: "bar input", required: false, + defaultValue: "bar", }), }, }, }); - userEvent.type(await screen.findByLabelText(/foo input/i), "baz"); - userEvent.type(await screen.findByLabelText(/bar input/i), "baz"); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); await waitFor(() => expect(onSubmit).toHaveBeenCalled()); - expect( - screen.queryByText(/this field is required/i), - ).not.toBeInTheDocument(); + expect(screen.queryByText(/required/i)).not.toBeInTheDocument(); }); it("sets types on form submissions correctly", async () => { - const { onSubmit } = setup({ + const { action, onSubmit } = setup({ parameters: [ makeParameter({ id: "abc-123" }), makeParameter({ id: "def-456" }), @@ -498,7 +563,7 @@ describe("Actions > ActionForm", () => { userEvent.type(await screen.findByLabelText(/foo input/i), "1"); userEvent.type(await screen.findByLabelText(/bar input/i), "1"); userEvent.type(await screen.findByLabelText(/baz input/i), "1"); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( @@ -518,7 +583,7 @@ describe("Actions > ActionForm", () => { const inputTypes = ["string", "number", "text", "date", "datetime", "time"]; inputTypes.forEach(inputType => { it(`casts empty optional ${inputType} field to null`, async () => { - const { onSubmit } = setup({ + const { action, onSubmit } = setup({ initialValues: { "abc-123": 1 }, parameters: [makeParameter({ id: "abc-123" })], formSettings: { @@ -538,7 +603,7 @@ describe("Actions > ActionForm", () => { fireEvent.change(screen.getByLabelText(/input/i), { target: { value: "" }, }); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( @@ -554,14 +619,14 @@ describe("Actions > ActionForm", () => { // bug repro: https://github.com/metabase/metabase/issues/27377 // eslint-disable-next-line jest/no-disabled-tests it.skip("casts empty optional category fields to null", async () => { - const { onSubmit } = setup({ + const { action, onSubmit } = setup({ initialValues: { "abc-123": "aaa" }, parameters: [makeParameter({ id: "abc-123" })], formSettings: { type: "form", fields: { "abc-123": makeFieldSettings({ - inputType: "category", + inputType: "string", id: "abc-123", title: "input", required: false, @@ -571,7 +636,7 @@ describe("Actions > ActionForm", () => { }); userEvent.clear(screen.getByLabelText(/input/i)); - userEvent.click(screen.getByRole("button", { name: "Save" })); + userEvent.click(screen.getByRole("button", { name: action.name })); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( @@ -583,164 +648,4 @@ describe("Actions > ActionForm", () => { }); }); }); - - describe("Form Creation", () => { - it("renders the form editor", () => { - setupSettings({ - parameters: [makeParameter()], - formSettings: { - type: "form", - fields: { - "abc-123": makeFieldSettings({ inputType: "string" }), - }, - }, - }); - - expect(screen.getByTestId("action-form-editor")).toBeInTheDocument(); - expect(screen.getByRole("textbox")).toBeInTheDocument(); - }); - - it("can change a string field to a numeric field", async () => { - const formSettings: ActionFormSettings = { - type: "form", - fields: { - "abc-123": makeFieldSettings({ inputType: "string" }), - }, - }; - const { setFormSettings } = setupSettings({ - parameters: [makeParameter()], - formSettings, - }); - - // click the settings cog then the number input type - userEvent.click(await screen.findByLabelText("Field settings")); - userEvent.click(await screen.findByText("Number")); - - await waitFor(() => { - expect(setFormSettings).toHaveBeenCalledWith({ - ...formSettings, - fields: { - "abc-123": makeFieldSettings({ - fieldType: "number", - inputType: "number", - }), - }, - }); - }); - }); - - it("can change a string field to a text(area) field", async () => { - const formSettings: ActionFormSettings = { - type: "form", - fields: { - "abc-123": makeFieldSettings({ inputType: "string" }), - }, - }; - - const { setFormSettings } = setupSettings({ - parameters: [makeParameter()], - formSettings, - }); - - // click the settings cog then the number input type - userEvent.click(await screen.findByLabelText("Field settings")); - userEvent.click(await screen.findByText("Long text")); - - await waitFor(() => { - expect(setFormSettings).toHaveBeenCalledWith({ - ...formSettings, - fields: { - "abc-123": makeFieldSettings({ - fieldType: "string", - inputType: "text", - }), - }, - }); - }); - }); - - it("can change a numeric field to a date field", async () => { - const formSettings: ActionFormSettings = { - type: "form", - fields: { - "abc-123": makeFieldSettings({ inputType: "number" }), - }, - }; - - const { setFormSettings } = setupSettings({ - parameters: [makeParameter()], - formSettings, - }); - - userEvent.click(await screen.findByLabelText("Field settings")); - userEvent.click(await screen.findByText("Date")); - - await waitFor(() => { - expect(setFormSettings).toHaveBeenCalledWith({ - ...formSettings, - fields: { - "abc-123": makeFieldSettings({ - fieldType: "date", - inputType: "date", - }), - }, - }); - }); - }); - - it("can change a date field to a select field", async () => { - const formSettings: ActionFormSettings = { - type: "form", - fields: { - "abc-123": makeFieldSettings({ inputType: "date" }), - }, - }; - const { setFormSettings } = setupSettings({ - parameters: [makeParameter()], - formSettings, - }); - - userEvent.click(await screen.findByLabelText("Field settings")); - userEvent.click(await screen.findByText("Category")); - - await waitFor(() => { - expect(setFormSettings).toHaveBeenCalledWith({ - ...formSettings, - fields: { - "abc-123": makeFieldSettings({ - fieldType: "category", - inputType: "select", - }), - }, - }); - }); - }); - it("can toggle required state", async () => { - const formSettings: ActionFormSettings = { - type: "form", - fields: { - "abc-123": makeFieldSettings({ inputType: "string" }), - }, - }; - const { setFormSettings } = setupSettings({ - parameters: [makeParameter()], - formSettings, - }); - - userEvent.click(await screen.findByLabelText("Field settings")); - userEvent.click(await screen.findByRole("switch")); - - await waitFor(() => { - expect(setFormSettings).toHaveBeenCalledWith({ - ...formSettings, - fields: { - "abc-123": makeFieldSettings({ - required: true, - inputType: "string", - }), - }, - }); - }); - }); - }); }); diff --git a/frontend/src/metabase/actions/components/ActionForm/index.ts b/frontend/src/metabase/actions/components/ActionForm/index.ts index e1f10285c7c12..d2ac1639e76d4 100644 --- a/frontend/src/metabase/actions/components/ActionForm/index.ts +++ b/frontend/src/metabase/actions/components/ActionForm/index.ts @@ -1,2 +1 @@ -export * from "./ActionForm"; -export * from "./ActionFormFieldWidget"; +export { default } from "./ActionForm"; diff --git a/frontend/src/metabase/actions/components/ActionForm/utils.ts b/frontend/src/metabase/actions/components/ActionForm/utils.ts deleted file mode 100644 index 8dd79de671129..0000000000000 --- a/frontend/src/metabase/actions/components/ActionForm/utils.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { t } from "ttag"; -import * as Yup from "yup"; - -import type { - ActionFormSettings, - ActionFormOption, - FieldSettingsMap, - InputSettingType, - InputComponentType, - Parameter, - WritebackParameter, -} from "metabase-types/api"; -import type { - ActionFormProps, - ActionFormFieldProps, - FieldSettings, -} from "metabase/actions/types"; - -import { sortActionParams, isEditableField } from "metabase/actions/utils"; -import { isEmpty } from "metabase/lib/validate"; - -const getOptionsFromArray = ( - options: (number | string)[], -): ActionFormOption[] => options.map(o => ({ name: o, value: o })); - -const getSampleOptions = () => [ - { name: t`Option One`, value: 1 }, - { name: t`Option Two`, value: 2 }, - { name: t`Option Three`, value: 3 }, -]; - -const inputTypeHasOptions = (fieldSettings: FieldSettings) => - ["select", "radio"].includes(fieldSettings.inputType); - -type FieldPropTypeMap = Record; - -const fieldPropsTypeMap: FieldPropTypeMap = { - string: "text", - text: "textarea", - date: "date", - datetime: "datetime-local", - time: "time", - number: "number", - boolean: "boolean", - category: "category", - select: "select", - radio: "radio", -}; - -export const getFormField = ( - parameter: Parameter, - fieldSettings: FieldSettings, -) => { - if ( - fieldSettings.field && - !isEditableField(fieldSettings.field, parameter as Parameter) - ) { - return undefined; - } - - const fieldProps: ActionFormFieldProps = { - name: parameter.id, - type: fieldPropsTypeMap[fieldSettings?.inputType] ?? "text", - title: - fieldSettings.title || - fieldSettings.name || - parameter["display-name"] || - parameter.name || - parameter.id, - description: fieldSettings.description ?? "", - placeholder: fieldSettings?.placeholder, - optional: !fieldSettings.required, - field: fieldSettings.field, - }; - - if (inputTypeHasOptions(fieldSettings)) { - fieldProps.options = fieldSettings.valueOptions?.length - ? getOptionsFromArray(fieldSettings.valueOptions) - : getSampleOptions(); - } - - return fieldProps; -}; - -export const getForm = ( - parameters: WritebackParameter[] | Parameter[], - fieldSettings: Record = {}, -): ActionFormProps => { - const sortedParams = parameters.sort( - sortActionParams({ fields: fieldSettings } as ActionFormSettings), - ); - return { - fields: sortedParams - ?.map(param => getFormField(param, fieldSettings[param.id] ?? {})) - .filter(Boolean) as ActionFormFieldProps[], - }; -}; - -const getFieldValidationType = (fieldSettings: FieldSettings) => { - switch (fieldSettings.inputType) { - case "number": - return Yup.number(); - case "boolean": - return Yup.boolean(); - case "date": - case "datetime": - case "time": - // for dates, cast empty strings to null - return Yup.string().transform((value, originalValue) => - originalValue?.length ? value : null, - ); - default: - return Yup.string(); - } -}; - -export const getFormValidationSchema = ( - parameters: WritebackParameter[] | Parameter[], - fieldSettings: FieldSettingsMap = {}, -) => { - const requiredMessage = t`This field is required`; - - const schema = Object.values(fieldSettings) - .filter(fieldSetting => - // only validate fields that are present in the form - parameters.find(parameter => parameter.id === fieldSetting.id), - ) - .map(fieldSetting => { - let yupType: Yup.AnySchema = getFieldValidationType(fieldSetting); - - if (fieldSetting.required) { - yupType = yupType.required(requiredMessage); - } else { - yupType = yupType.nullable(); - } - - if (!isEmpty(fieldSetting.defaultValue)) { - yupType = yupType.default(fieldSetting.defaultValue); - } - - return [fieldSetting.id, yupType]; - }); - return Yup.object(Object.fromEntries(schema)); -}; diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionFormFieldWidget.tsx b/frontend/src/metabase/actions/components/ActionFormFieldWidget/ActionFormFieldWidget.tsx similarity index 83% rename from frontend/src/metabase/actions/components/ActionForm/ActionFormFieldWidget.tsx rename to frontend/src/metabase/actions/components/ActionFormFieldWidget/ActionFormFieldWidget.tsx index ccb074f9de50a..2cd5fa70588f0 100644 --- a/frontend/src/metabase/actions/components/ActionForm/ActionFormFieldWidget.tsx +++ b/frontend/src/metabase/actions/components/ActionFormFieldWidget/ActionFormFieldWidget.tsx @@ -8,7 +8,6 @@ import FormRadioWidget, { import FormSelectWidget from "metabase/core/components/FormSelect"; import FormNumericInputWidget from "metabase/core/components/FormNumericInput"; import FormBooleanWidget from "metabase/core/components/FormToggle"; -import CategoryFieldPicker from "metabase/components/FormCategoryInput"; import type { InputComponentType } from "metabase-types/api"; import type { ActionFormFieldProps } from "metabase/actions/types"; @@ -27,19 +26,21 @@ const WIDGETS: Record> = { boolean: FormBooleanWidget, radio: VerticalRadio, select: FormSelectWidget, - category: CategoryFieldPicker, }; interface FormWidgetProps { formField: ActionFormFieldProps; } -export const FormFieldWidget = forwardRef(function FormFieldWidget( +const ActionFormFieldWidget = forwardRef(function FormFieldWidget( { formField }: FormWidgetProps, ref: React.Ref, ) { const Widget = (formField.type ? WIDGETS[formField.type] : FormInputWidget) ?? FormInputWidget; - return ; + + return ; }); + +export default ActionFormFieldWidget; diff --git a/frontend/src/metabase/actions/components/ActionFormFieldWidget/index.ts b/frontend/src/metabase/actions/components/ActionFormFieldWidget/index.ts new file mode 100644 index 0000000000000..22c6d5b882aa9 --- /dev/null +++ b/frontend/src/metabase/actions/components/ActionFormFieldWidget/index.ts @@ -0,0 +1 @@ +export { default } from "./ActionFormFieldWidget"; diff --git a/frontend/src/metabase/actions/components/ActionViz/Action.tsx b/frontend/src/metabase/actions/components/ActionViz/Action.tsx index 3a53c05d53abe..a41c092e77201 100644 --- a/frontend/src/metabase/actions/components/ActionViz/Action.tsx +++ b/frontend/src/metabase/actions/components/ActionViz/Action.tsx @@ -3,50 +3,53 @@ import _ from "underscore"; import { t } from "ttag"; import { connect } from "react-redux"; -import { executeRowAction } from "metabase/dashboard/actions"; - import Tooltip from "metabase/core/components/Tooltip"; +import { getResponseErrorMessage } from "metabase/core/utils/errors"; + +import Databases from "metabase/entities/databases"; + +import { executeRowAction } from "metabase/dashboard/actions"; +import { getEditingDashcardId } from "metabase/dashboard/selectors"; import type { ActionDashboardCard, - ParametersForActionExecution, - WritebackQueryAction, Dashboard, + ParametersForActionExecution, + WritebackAction, } from "metabase-types/api"; - import type { VisualizationProps } from "metabase-types/types/Visualization"; -import type { Dispatch, State } from "metabase-types/store"; import type { ParameterValueOrArray } from "metabase-types/types/Parameter"; - -import { - generateFieldSettingsFromParameters, - setNumericValues, -} from "metabase/actions/utils"; - -import { getEditingDashcardId } from "metabase/dashboard/selectors"; -import Databases from "metabase/entities/databases"; +import type { Dispatch, State } from "metabase-types/store"; import type Database from "metabase-lib/metadata/Database"; import { getDashcardParamValues, getNotProvidedActionParameters, + getMappedActionParameters, shouldShowConfirmation, } from "./utils"; + import ActionVizForm from "./ActionVizForm"; import ActionButtonView from "./ActionButtonView"; import { FullContainer } from "./ActionButton.styled"; -export interface ActionProps extends VisualizationProps { +interface OwnProps { dashcard: ActionDashboardCard; dashboard: Dashboard; - dispatch: Dispatch; parameterValues: { [id: string]: ParameterValueOrArray }; isEditingDashcard: boolean; + dispatch: Dispatch; +} + +interface DatabaseLoaderProps { database: Database; + error?: unknown; } -export function ActionComponent({ +export type ActionProps = VisualizationProps & OwnProps & DatabaseLoaderProps; + +function ActionComponent({ dashcard, dashboard, dispatch, @@ -74,6 +77,16 @@ export function ActionComponent({ ); }, [dashcard, dashcardParamValues]); + const mappedParameters = useMemo(() => { + if (!dashcard.action) { + return []; + } + return getMappedActionParameters( + dashcard.action, + dashcardParamValues ?? [], + ); + }, [dashcard, dashcardParamValues]); + const shouldConfirm = shouldShowConfirmation(dashcard?.action); const shouldDisplayButton = !!( @@ -83,40 +96,30 @@ export function ActionComponent({ ); const onSubmit = useCallback( - (parameterMap: ParametersForActionExecution) => { - const params = { - ...setNumericValues( - dashcardParamValues, - generateFieldSettingsFromParameters( - dashcard?.action?.parameters ?? [], - ), - ), - ...parameterMap, - }; - - return executeRowAction({ + (parameters: ParametersForActionExecution) => + executeRowAction({ dashboard, dashcard, - parameters: params, + parameters, dispatch, shouldToast: shouldDisplayButton, - }); - }, - [dashboard, dashcard, dashcardParamValues, dispatch, shouldDisplayButton], + }), + [dashboard, dashcard, dispatch, shouldDisplayButton], ); return ( ); } @@ -129,18 +132,21 @@ function mapStateToProps(state: State, props: ActionProps) { }; } -export function ActionFn(props: ActionProps) { +function ActionFn(props: ActionProps) { const { database, dashcard: { action }, + error, } = props; const hasActionsEnabled = database?.hasActionsEnabled?.(); - if (!action || !hasActionsEnabled) { - const tooltip = !action - ? t`No action assigned` - : t`Actions are not enabled for this database`; + if (error || !action || !hasActionsEnabled) { + const tooltip = getErrorTooltip({ + hasActionAssigned: !!action, + hasActionsEnabled, + error, + }); return ( @@ -160,10 +166,32 @@ export function ActionFn(props: ActionProps) { return ; } +function getErrorTooltip({ + hasActionAssigned, + hasActionsEnabled, + error, +}: { + hasActionAssigned: boolean; + hasActionsEnabled: boolean; + error?: unknown; +}) { + if (error) { + return getResponseErrorMessage(error); + } + if (!hasActionAssigned) { + return t`No action assigned`; + } + if (!hasActionsEnabled) { + return t`Actions are not enabled for this database`; + } + return t`Something's gone wrong`; +} + export default _.compose( Databases.load({ id: (state: State, props: ActionProps) => props.dashcard?.action?.database_id, + loadingAndErrorWrapper: false, }), connect(mapStateToProps), )(ActionFn); diff --git a/frontend/src/metabase/actions/components/ActionViz/Action.unit.spec.tsx b/frontend/src/metabase/actions/components/ActionViz/Action.unit.spec.tsx index 95d783d12d957..be0613a20c3ad 100644 --- a/frontend/src/metabase/actions/components/ActionViz/Action.unit.spec.tsx +++ b/frontend/src/metabase/actions/components/ActionViz/Action.unit.spec.tsx @@ -1,141 +1,154 @@ import React from "react"; -import _ from "underscore"; import fetchMock from "fetch-mock"; import userEvent from "@testing-library/user-event"; -import { waitFor } from "@testing-library/react"; -import { renderWithProviders, screen } from "__support__/ui"; +import { renderWithProviders, screen, getIcon, waitFor } from "__support__/ui"; +import { + setupDatabasesEndpoints, + setupUnauthorizedDatabasesEndpoints, +} from "__support__/server-mocks"; +import type { ActionDashboardCard } from "metabase-types/api"; +import type { ParameterTarget } from "metabase-types/types/Parameter"; import { + createMockActionDashboardCard as _createMockActionDashboardCard, createMockActionParameter, + createMockFieldSettings, createMockQueryAction, createMockImplicitQueryAction, createMockDashboard, createMockDatabase, } from "metabase-types/api/mocks"; -import Action, { ActionComponent, ActionProps } from "./Action"; - -const defaultProps = { - dashcard: { - id: 456, - card_id: 777, // action model id - action: createMockQueryAction({ - name: "My Awesome Action", - database_id: 2, - parameters: [ - createMockActionParameter({ - id: "parameter_1", - type: "type/Text", - target: ["variable", ["template-tag", "1"]], - }), - createMockActionParameter({ - id: "parameter_2", - type: "type/Text", - target: ["variable", ["template-tag", "2"]], - }), - ], +import Action, { ActionProps } from "./Action"; + +const DASHBOARD_ID = 123; +const DASHCARD_ID = 456; +const ACTION_MODEL_ID = 777; +const ACTION_EXEC_MOCK_PATH = `path:/api/dashboard/${DASHBOARD_ID}/dashcard/${DASHCARD_ID}/execute`; + +const DATABASE_WITHOUT_ACTIONS = createMockDatabase({ id: 1 }); +const DATABASE = createMockDatabase({ + id: 2, + settings: { "database-enable-actions": true }, +}); + +const ACTION = createMockQueryAction({ + name: "My Awesome Action", + database_id: DATABASE.id, + parameters: [ + createMockActionParameter({ + id: "parameter_1", + type: "type/Text", + target: ["variable", ["template-tag", "1"]], }), + createMockActionParameter({ + id: "parameter_2", + type: "type/Integer", + target: ["variable", ["template-tag", "2"]], + }), + ], +}); + +function createMockActionDashboardCard( + opts: Partial = {}, +) { + return _createMockActionDashboardCard({ + id: DASHCARD_ID, + card_id: ACTION_MODEL_ID, + dashboard_id: DASHBOARD_ID, + action: ACTION, parameter_mappings: [ { parameter_id: "dash-param-1", - card_id: 1, target: ["variable", ["template-tag", "1"]], }, { parameter_id: "dash-param-2", - card_id: 1, target: ["variable", ["template-tag", "2"]], }, ], - }, - dashboard: createMockDashboard({ id: 123 }), - dispatch: _.noop, - isSettings: false, - isEditing: false, - settings: {}, - onVisualizationClick: _.noop, - parameterValues: {}, -} as unknown as ActionProps; - -const databases: Record = { - 1: createMockDatabase({ id: 1 }), - 2: createMockDatabase({ - id: 2, - settings: { "database-enable-actions": true }, - }), -}; - -async function setup(options?: Partial) { - return renderWithProviders( - , - ); + ...opts, + }); } -async function setupActionWrapper(options?: Partial) { - const dbId = options?.dashcard?.action?.database_id ?? 0; - - fetchMock.get(`path:/api/database/${dbId}`, databases[dbId] ?? null); +type SetupOpts = Partial & { + hasDataPermissions?: boolean; +}; - return renderWithProviders(, { - withSampleDatabase: true, - storeInitialState: { - entities: { - databases, - }, - }, - }); -} +async function setup({ + dashboard = createMockDashboard({ id: DASHBOARD_ID }), + dashcard = createMockActionDashboardCard(), + settings = {}, + parameterValues = {}, + hasDataPermissions = true, + ...props +}: SetupOpts = {}) { + const databases = [DATABASE, DATABASE_WITHOUT_ACTIONS]; + + if (hasDataPermissions) { + setupDatabasesEndpoints(databases); + fetchMock.post(ACTION_EXEC_MOCK_PATH, { "rows-updated": [1] }); + } else { + setupUnauthorizedDatabasesEndpoints(databases); + } + + renderWithProviders( + , + ); -function setupExecutionEndpoint() { - fetchMock.post("path:/api/dashboard/123/dashcard/456/execute", { - "rows-updated": [1], - }); + // Wait until UI is ready + await screen.findByRole("button"); } -describe("Actions > ActionViz > ActionComponent", () => { - // button actions are just a modal trigger around forms +describe("Actions > ActionViz > Action", () => { describe("Button actions", () => { it("should render an empty state for a button with no action", async () => { - await setupActionWrapper({ - dashcard: { - ...defaultProps.dashcard, - action: undefined, - }, + await setup({ + dashcard: createMockActionDashboardCard({ action: undefined }), }); - expect(screen.getByLabelText("bolt icon")).toBeInTheDocument(); + expect(getIcon("bolt")).toBeInTheDocument(); expect(screen.getByRole("button")).toBeDisabled(); expect(screen.getByLabelText(/no action assigned/i)).toBeInTheDocument(); }); it("should render a disabled state for a button with an action from a database where actions are disabled", async () => { - await setupActionWrapper({ - dashcard: { - ...defaultProps.dashcard, + await setup({ + dashcard: createMockActionDashboardCard({ action: createMockQueryAction({ - name: "My Awesome Action", - database_id: 1, + database_id: DATABASE_WITHOUT_ACTIONS.id, }), - }, + }), }); - expect(await screen.findByLabelText("bolt icon")).toBeInTheDocument(); - expect(await screen.findByRole("button")).toBeDisabled(); + expect(getIcon("bolt")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeDisabled(); + expect( + screen.getByLabelText(/actions are not enabled/i), + ).toBeInTheDocument(); + }); + + it("should render a disabled state if the user doesn't have permissions to action database", async () => { + await setup({ hasDataPermissions: false }); + expect(getIcon("bolt")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeDisabled(); expect( - await screen.findByLabelText(/actions are not enabled/i), + screen.getByLabelText(/don't have permission/i), ).toBeInTheDocument(); }); it("should render an enabled state when the action is valid", async () => { - await setupActionWrapper({ - dashcard: { - ...defaultProps.dashcard, - action: createMockQueryAction({ - name: "My Awesome Action", - database_id: 2, - }), - }, - }); - expect(await screen.findByRole("button")).toBeEnabled(); + await setup(); + expect(screen.getByRole("button")).toBeEnabled(); }); it("should render a button with default text", async () => { @@ -174,6 +187,59 @@ describe("Actions > ActionViz > ActionComponent", () => { userEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + + it("should format dashboard filter values for numeric parameters", async () => { + const parameterId = "parameter_1"; + const parameterTarget: ParameterTarget = [ + "variable", + ["template-tag", "1"], + ]; + + const action = createMockQueryAction({ + database_id: DATABASE.id, + parameters: [ + createMockActionParameter({ + id: parameterId, + name: parameterId, + type: "number/=", + target: parameterTarget, + }), + ], + visualization_settings: { + fields: { + [parameterId]: createMockFieldSettings({ + fieldType: "number", + inputType: "number", + }), + }, + }, + }); + + await setup({ + dashcard: createMockActionDashboardCard({ + action, + parameter_mappings: [ + { + parameter_id: "dash-param-1", + target: parameterTarget, + }, + ], + }), + parameterValues: { "dash-param-1": "44" }, + }); + + userEvent.click(screen.getByRole("button", { name: "Click me" })); + + await waitFor(async () => { + const call = fetchMock.lastCall(ACTION_EXEC_MOCK_PATH); + expect(await call?.request?.json()).toEqual({ + modelId: ACTION_MODEL_ID, + parameters: { + parameter_1: 44, + }, + }); + }); + }); }); describe("Form actions", () => { @@ -189,7 +255,9 @@ describe("Actions > ActionViz > ActionComponent", () => { it("should render the action name as the form title", async () => { await setup({ settings: formSettings }); - expect(screen.getByText("My Awesome Action")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "My Awesome Action" }), + ).toBeInTheDocument(); }); it("should only show form fields with no provided values from dashboard filters", async () => { @@ -205,25 +273,23 @@ describe("Actions > ActionViz > ActionComponent", () => { it("should render as a button if no parameters are missing", async () => { await setup({ settings: formSettings, - parameterValues: { "dash-param-1": "foo", "dash-param-2": "bar" }, + parameterValues: { "dash-param-1": "foo", "dash-param-2": 2 }, }); expect( - await screen.findByRole("button", { name: "Click me" }), + screen.getByRole("button", { name: "Click me" }), ).toBeInTheDocument(); }); it("should submit provided form input values to the action execution endpoint", async () => { const expectedBody = { - modelId: 777, + modelId: ACTION_MODEL_ID, parameters: { parameter_1: "foo", - parameter_2: "bar", + parameter_2: 5, }, }; - setupExecutionEndpoint(); - await setup({ settings: formSettings }); userEvent.type(screen.getByLabelText("Parameter 1"), "foo"); @@ -231,35 +297,31 @@ describe("Actions > ActionViz > ActionComponent", () => { expect(screen.getByLabelText("Parameter 1")).toHaveValue("foo"), ); - userEvent.type(screen.getByLabelText("Parameter 2"), "bar"); + userEvent.type(screen.getByLabelText("Parameter 2"), "5"); await waitFor(() => - expect(screen.getByLabelText("Parameter 2")).toHaveValue("bar"), + expect(screen.getByLabelText("Parameter 2")).toHaveValue(5), ); - userEvent.click(screen.getByRole("button", { name: "Run" })); + userEvent.click(screen.getByRole("button", { name: ACTION.name })); await waitFor(async () => { - const call = fetchMock.lastCall( - "path:/api/dashboard/123/dashcard/456/execute", - ); + const call = fetchMock.lastCall(ACTION_EXEC_MOCK_PATH); expect(await call?.request?.json()).toEqual(expectedBody); }); }); it("should combine data from dashboard parameters and form input when submitting for execution", async () => { const expectedBody = { - modelId: 777, + modelId: ACTION_MODEL_ID, parameters: { parameter_1: "foo", - parameter_2: "baz", + parameter_2: 5, }, }; - setupExecutionEndpoint(); - await setup({ settings: formSettings, - parameterValues: { "dash-param-2": "baz" }, + parameterValues: { "dash-param-2": "5" }, }); userEvent.type(screen.getByLabelText("Parameter 1"), "foo"); @@ -267,12 +329,10 @@ describe("Actions > ActionViz > ActionComponent", () => { expect(screen.getByLabelText("Parameter 1")).toHaveValue("foo"), ); - userEvent.click(screen.getByRole("button", { name: "Run" })); + userEvent.click(screen.getByRole("button", { name: ACTION.name })); await waitFor(async () => { - const call = fetchMock.lastCall( - "path:/api/dashboard/123/dashcard/456/execute", - ); + const call = fetchMock.lastCall(ACTION_EXEC_MOCK_PATH); expect(await call?.request?.json()).toEqual(expectedBody); }); }); @@ -281,11 +341,11 @@ describe("Actions > ActionViz > ActionComponent", () => { describe("Implicit Actions", () => { it("shows a confirmation modal when clicking an implicit delete action with a provided parameter", async () => { await setup({ - dashcard: { - ...defaultProps.dashcard, + dashcard: createMockActionDashboardCard({ action: createMockImplicitQueryAction({ name: "My Delete Action", kind: "row/delete", + database_id: DATABASE.id, parameters: [ createMockActionParameter({ id: "1", @@ -295,7 +355,7 @@ describe("Actions > ActionViz > ActionComponent", () => { }), ], }), - }, + }), parameterValues: { "dash-param-1": "foo" }, }); diff --git a/frontend/src/metabase/actions/components/ActionViz/ActionButtonView.tsx b/frontend/src/metabase/actions/components/ActionViz/ActionButtonView.tsx index 8b965bd94d97b..917b032e76bdc 100644 --- a/frontend/src/metabase/actions/components/ActionViz/ActionButtonView.tsx +++ b/frontend/src/metabase/actions/components/ActionViz/ActionButtonView.tsx @@ -1,10 +1,11 @@ import React from "react"; import { t } from "ttag"; +import Ellipsified from "metabase/core/components/Ellipsified"; +import Icon from "metabase/components/Icon"; + import type { VisualizationProps } from "metabase-types/types/Visualization"; -import Icon from "metabase/components/Icon"; -import Ellipsified from "metabase/core/components/Ellipsified"; import { StyledButton, StyledButtonContent } from "./ActionButton.styled"; interface ActionButtonViewProps extends Pick { diff --git a/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.styled.tsx b/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.styled.tsx index 3078b242b970e..2630b02d1e76b 100644 --- a/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.styled.tsx +++ b/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.styled.tsx @@ -3,7 +3,7 @@ import styled from "@emotion/styled"; import { space } from "metabase/styled-components/theme"; import { color } from "metabase/lib/colors"; -import Link from "metabase/core/components/Link"; +import ExternalLink from "metabase/core/components/ExternalLink"; export const ActionSettingsWrapper = styled.div` display: flex; @@ -89,7 +89,7 @@ export const ExplainerText = styled.p` color: ${color("text-medium")}; `; -export const BrandLinkWithLeftMargin = styled(Link)` +export const BrandLinkWithLeftMargin = styled(ExternalLink)` margin-left: ${space(1)}; color: ${color("brand")}; `; diff --git a/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.tsx b/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.tsx index 5372c3c9ebc1c..7b6edd7bd0335 100644 --- a/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.tsx +++ b/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.tsx @@ -2,19 +2,21 @@ import React from "react"; import { connect } from "react-redux"; import { t } from "ttag"; +import Button from "metabase/core/components/Button"; +import EmptyState from "metabase/components/EmptyState"; + +import MetabaseSettings from "metabase/lib/settings"; + +import { ConnectedActionPicker } from "metabase/actions/containers/ActionPicker/ActionPicker"; +import { setActionForDashcard } from "metabase/dashboard/actions"; + import type { ActionDashboardCard, Dashboard, WritebackAction, } from "metabase-types/api"; -import Button from "metabase/core/components/Button"; - -import { ConnectedActionPicker } from "metabase/actions/containers/ActionPicker/ActionPicker"; -import { setActionForDashcard } from "metabase/dashboard/actions"; -import EmptyState from "metabase/components/EmptyState"; import { ConnectedActionParameterMappingForm } from "./ActionParameterMapper"; - import { ActionSettingsWrapper, ParameterMapperContainer, @@ -70,7 +72,9 @@ export function ActionDashcardSettings({ {t`You can either ask users to enter values, or use the value of a dashboard filter.`} - + {t`Learn more.`} diff --git a/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.unit.spec.tsx b/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.unit.spec.tsx index 79e0e96aadd93..e69154bcd5e9a 100644 --- a/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.unit.spec.tsx +++ b/frontend/src/metabase/actions/components/ActionViz/ActionDashcardSettings.unit.spec.tsx @@ -88,8 +88,7 @@ const setup = ( setupSearchEndpoints(models.map(model => createMockCollectionItem(model))); setupCardsEndpoints(models); - setupActionsEndpoints(models[0].id, actions1); - setupActionsEndpoints(models[1].id, actions2); + setupActionsEndpoints([...actions1, ...actions2]); renderWithProviders( 0 || showConfirmMessage) { setShowModal(true); } else { - onSubmit({}); + onSubmit(getCleanValues()); } }; @@ -71,24 +79,24 @@ function ActionVizForm({ return ( <> {showModal && ( setShowModal(false)} - title={title} - onSubmit={onModalSubmit} - showConfirmMessage={!!showConfirmMessage} - confirmMessage={action.visualization_settings?.confirmMessage} + action={action} dashboard={dashboard} dashcard={dashcard} - missingParameters={missingParameters} + mappedParameters={mappedParameters} dashcardParamValues={dashcardParamValues} + title={title} + showConfirmMessage={showConfirmMessage} + confirmMessage={action.visualization_settings?.confirmMessage} + onSubmit={onModalSubmit} + onClose={() => setShowModal(false)} onCancel={() => setShowModal(false)} - action={action} /> )} @@ -99,12 +107,12 @@ function ActionVizForm({ {title} ); diff --git a/frontend/src/metabase/actions/components/ActionViz/utils.ts b/frontend/src/metabase/actions/components/ActionViz/utils.ts index cdd240a754d43..1ab0b93f451db 100644 --- a/frontend/src/metabase/actions/components/ActionViz/utils.ts +++ b/frontend/src/metabase/actions/components/ActionViz/utils.ts @@ -84,11 +84,22 @@ export function getNotProvidedActionParameters( }); } +export function getMappedActionParameters( + action: WritebackAction, + dashboardParamValues: ParametersForActionExecution, +) { + const parameters = action.parameters ?? []; + return parameters.filter(parameter => { + return isMappedParameter(parameter, dashboardParamValues); + }); +} + export const shouldShowConfirmation = (action?: WritebackAction) => { if (!action) { return false; } - const hasConfirmationMessage = action.visualization_settings?.confirmMessage; + const hasConfirmationMessage = + !!action.visualization_settings?.confirmMessage; const isImplicitDelete = action.type === "implicit" && action.kind === "row/delete"; return hasConfirmationMessage || isImplicitDelete; diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/constants.ts b/frontend/src/metabase/actions/constants.ts similarity index 73% rename from frontend/src/metabase/actions/containers/ActionCreator/FormCreator/constants.ts rename to frontend/src/metabase/actions/constants.ts index 7bf1c4f2e4912..e5f971314f78e 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/constants.ts +++ b/frontend/src/metabase/actions/constants.ts @@ -6,6 +6,13 @@ interface FieldOptionType { name: string; } +interface InputOptionType { + value: InputSettingType; + name: string; +} + +type InputOptionsMap = Record; + export const getFieldTypes = (): FieldOptionType[] => [ { value: "string", @@ -19,24 +26,8 @@ export const getFieldTypes = (): FieldOptionType[] => [ value: "date", name: t`Date`, }, - { - value: "category", - name: t`Category`, - }, ]; -interface InputOptionType { - value: InputSettingType; - name: string; -} - -interface InputOptionsMap { - string: InputOptionType[]; - number: InputOptionType[]; - date: InputOptionType[]; - category: InputOptionType[]; -} - const getTextInputs = (): InputOptionType[] => [ { value: "string", @@ -77,14 +68,5 @@ export const getInputTypes = (): InputOptionsMap => ({ value: "datetime", name: t`Date + time`, }, - // { - // value: "monthyear", - // name: t`month + year`, - // }, - // { - // value: "quarteryear", - // name: t`quarter + year`, - // }, ], - category: [...getSelectInputs()], }); diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionContextProvider.tsx similarity index 60% rename from frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider.tsx rename to frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionContextProvider.tsx index 0128814acfcdc..46589cc9acf72 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionContextProvider.tsx @@ -1,23 +1,32 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { CreateQueryActionParams } from "metabase/entities/actions"; -import QueryActionEditor from "metabase/actions/containers/ActionCreator/QueryActionEditor"; -import type { DatabaseId, WritebackQueryAction } from "metabase-types/api"; +import type { + ActionFormSettings, + DatabaseId, + NativeDatasetQuery, + VisualizationSettings, + WritebackParameter, + WritebackQueryAction, +} from "metabase-types/api"; +import type { Card as LegacyCard } from "metabase-types/types/Card"; import type Metadata from "metabase-lib/metadata/Metadata"; import type NativeQuery from "metabase-lib/queries/NativeQuery"; +import Question from "metabase-lib/Question"; import { getTemplateTagParametersFromCard } from "metabase-lib/parameters/utils/template-tags"; -import { getDefaultFormSettings } from "../../../utils"; -import { - newQuestion, - convertActionToQuestion, - convertQuestionToAction, -} from "../utils"; +import { getDefaultFormSettings } from "../../../../utils"; + +import { ActionContext } from "../ActionContext"; +import type { ActionContextProviderProps, EditorBodyProps } from "../types"; -import { ActionContext } from "./ActionContext"; -import type { ActionContextProviderProps, EditorBodyProps } from "./types"; +import { + setParameterTypesFromFieldSettings, + setTemplateTagTypesFromFieldSettings, +} from "./utils"; +import QueryActionEditor from "./QueryActionEditor"; export interface QueryActionContextProviderProps extends ActionContextProviderProps { @@ -25,6 +34,70 @@ export interface QueryActionContextProviderProps databaseId?: DatabaseId; } +// ActionCreator uses the NativeQueryEditor, which expects a Question object +// This utilities help us to work with the WritebackQueryAction as with a Question + +function newQuestion(metadata: Metadata, databaseId?: DatabaseId) { + return new Question( + { + dataset_query: { + type: "native", + database: databaseId ?? null, + native: { + query: "", + }, + }, + }, + metadata, + ); +} + +function convertActionToQuestionCard( + action: WritebackQueryAction, +): LegacyCard { + return { + id: action.id, + name: action.name, + description: action.description, + dataset_query: action.dataset_query as NativeDatasetQuery, + display: "action", + visualization_settings: + action.visualization_settings as VisualizationSettings, + }; +} + +function convertActionToQuestion( + action: WritebackQueryAction, + metadata: Metadata, +) { + const question = new Question(convertActionToQuestionCard(action), metadata); + return question.setParameters(action.parameters); +} + +function convertQuestionToAction( + question: Question, + formSettings: ActionFormSettings, +) { + const cleanQuestion = setTemplateTagTypesFromFieldSettings( + question, + formSettings, + ); + const parameters = setParameterTypesFromFieldSettings( + formSettings, + cleanQuestion.parameters(), + ); + + return { + id: question.id(), + name: question.displayName() as string, + description: question.description(), + dataset_query: question.datasetQuery() as NativeDatasetQuery, + database_id: question.databaseId() as DatabaseId, + parameters: parameters as WritebackParameter[], + visualization_settings: formSettings, + }; +} + function resolveQuestion( action: WritebackQueryAction | undefined, { metadata, databaseId }: { metadata: Metadata; databaseId?: DatabaseId }, diff --git a/frontend/src/metabase/actions/containers/ActionCreator/QueryActionEditor.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionEditor.tsx similarity index 96% rename from frontend/src/metabase/actions/containers/ActionCreator/QueryActionEditor.tsx rename to frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionEditor.tsx index 98e3408beb757..0b795489dd121 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/QueryActionEditor.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/QueryActionEditor.tsx @@ -26,7 +26,7 @@ function QueryActionEditor({ hasParametersList={false} resizable={false} readOnly={!isEditable} - requireWriteback + editorContext="action" /> ); diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/index.ts b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/index.ts new file mode 100644 index 0000000000000..4456dbd9eaa57 --- /dev/null +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/index.ts @@ -0,0 +1,3 @@ +export { default } from "./QueryActionContextProvider"; +export * from "./QueryActionContextProvider"; +export { ACE_ELEMENT_ID } from "./QueryActionEditor"; diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/utils.ts b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/utils.ts new file mode 100644 index 0000000000000..4ebc38c38e200 --- /dev/null +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/utils.ts @@ -0,0 +1,86 @@ +import type { + ActionFormSettings, + FieldType, + InputSettingType, + Parameter, + ParameterType, +} from "metabase-types/api"; +import type { TemplateTag, TemplateTagType } from "metabase-types/types/Query"; + +import type NativeQuery from "metabase-lib/queries/NativeQuery"; +import Question from "metabase-lib/Question"; + +type FieldTypeMap = Record; +type TagTypeMap = Record; + +const fieldTypeToParameterTypeMap: FieldTypeMap = { + string: "string/=", + number: "number/=", +}; + +const dateTypeToParameterTypeMap: FieldTypeMap = { + date: "date/single", + datetime: "date/single", + monthyear: "date/month-year", + quarteryear: "date/quarter-year", +}; + +const fieldTypeToTagTypeMap: TagTypeMap = { + string: "text", + number: "number", + date: "date", +}; + +const getTagTypeFromFieldSettings = (fieldType: FieldType): TemplateTagType => { + return fieldTypeToTagTypeMap[fieldType] ?? "text"; +}; + +const getParameterTypeFromFieldSettings = ( + fieldType: FieldType, + inputType: InputSettingType, +): ParameterType => { + if (fieldType === "date") { + return dateTypeToParameterTypeMap[inputType] ?? "date/single"; + } + + return fieldTypeToParameterTypeMap[fieldType] ?? "string/="; +}; + +export const setTemplateTagTypesFromFieldSettings = ( + question: Question, + settings: ActionFormSettings, +): Question => { + const fields = settings.fields || {}; + const query = question.query() as NativeQuery; + let tempQuestion = question.clone(); + + query.variableTemplateTags().forEach((tag: TemplateTag) => { + const currentQuery = tempQuestion.query() as NativeQuery; + const fieldType = fields[tag.id]?.fieldType ?? "string"; + const nextTag = { + ...tag, + type: getTagTypeFromFieldSettings(fieldType), + }; + tempQuestion = tempQuestion.setQuery( + currentQuery.setTemplateTag(tag.name, nextTag), + ); + }); + + return tempQuestion; +}; + +export const setParameterTypesFromFieldSettings = ( + settings: ActionFormSettings, + parameters: Parameter[], +): Parameter[] => { + const fields = settings.fields || {}; + return parameters.map(parameter => { + const field = fields[parameter.id]; + return { + ...parameter, + type: field + ? getParameterTypeFromFieldSettings(field.fieldType, field.inputType) + : "string/=", + }; + }); +}; diff --git a/frontend/src/metabase/actions/containers/ActionCreator/tests/utils.unit.spec.ts b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/utils.unit.spec.ts similarity index 97% rename from frontend/src/metabase/actions/containers/ActionCreator/tests/utils.unit.spec.ts rename to frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/utils.unit.spec.ts index e5c5e51a424bb..e8195de2f9650 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/tests/utils.unit.spec.ts +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider/utils.unit.spec.ts @@ -11,7 +11,7 @@ import { getUnsavedNativeQuestion } from "metabase-lib/mocks"; import { setParameterTypesFromFieldSettings, setTemplateTagTypesFromFieldSettings, -} from "../utils"; +} from "./utils"; const createQuestionWithTemplateTags = (tagType: TemplateTagType) => getUnsavedNativeQuestion({ @@ -39,7 +39,7 @@ const createQuestionWithTemplateTags = (tagType: TemplateTagType) => }, }); -describe("entities > actions > utils", () => { +describe("actions > containers > ActionCreator > QueryActionContextProvider > utils", () => { describe("setParameterTypesFromFieldSettings", () => { it("should set string parameter types", () => { const formSettings = getDefaultFormSettings({ diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx index 6101b3474946e..b9bb2464420b5 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx @@ -28,8 +28,8 @@ import type Metadata from "metabase-lib/metadata/Metadata"; import { isSavedAction } from "../../utils"; import ActionContext, { useActionContext } from "./ActionContext"; +import { ACE_ELEMENT_ID } from "./ActionContext/QueryActionContextProvider"; import ActionCreatorView from "./ActionCreatorView"; -import { ACE_ELEMENT_ID } from "./QueryActionEditor"; import CreateActionForm, { FormValues as CreateActionFormValues, } from "./CreateActionForm"; @@ -184,9 +184,7 @@ function ActionCreator({ function ensureAceEditorClosed() { // @ts-expect-error — `ace` isn't typed yet const editor = window.ace?.edit(ACE_ELEMENT_ID); - if (editor) { - editor.completer.popup.hide(); - } + editor?.completer?.popup?.hide(); } function ActionCreatorWithContext({ diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorView.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorView.tsx index 881c1a000ca79..64adadb7a1340 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorView.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorView.tsx @@ -120,7 +120,7 @@ export default function ActionCreatorView({ {activeSideView === "actionForm" ? ( {hasOptions && ( diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.styled.tsx index edca3060dfe2e..ad03e56af412f 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.styled.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.styled.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; -import { color, lighten } from "metabase/lib/colors"; +import { color } from "metabase/lib/colors"; import { space } from "metabase/styled-components/theme"; import Icon from "metabase/components/Icon"; @@ -34,8 +34,8 @@ export const ToggleContainer = styled.div` `; export const SettingsTriggerIcon = styled(Icon)` - color: ${color("brand")}; + color: ${color("text-medium")}; &:hover { - color: ${lighten("brand", 0.1)}; + color: ${color("brand")}; } `; diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.tsx index e80752291b045..3005e590a2210 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.tsx @@ -1,20 +1,21 @@ -import React, { useMemo } from "react"; +import React, { ChangeEvent, useMemo } from "react"; import { t } from "ttag"; -import type { - FieldSettings, - FieldType, - InputSettingType, -} from "metabase-types/api"; - import Input from "metabase/core/components/Input"; import Radio from "metabase/core/components/Radio"; import Toggle from "metabase/core/components/Toggle"; import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger"; -import { isEmpty } from "metabase/lib/validate"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; +import { getInputTypes } from "metabase/actions/constants"; -import { getFieldTypes, getInputTypes } from "./constants"; +import type { + FieldSettings, + FieldType, + InputSettingType, +} from "metabase-types/api"; + +import { getDefaultValueInputType } from "./utils"; import { SettingsTriggerIcon, ToggleContainer, @@ -39,7 +40,7 @@ export function FieldSettingsPopover({ triggerContent={ @@ -55,19 +56,6 @@ export function FieldSettingsPopover({ ); } -function cleanDefaultValue(fieldType: FieldType, value?: string | number) { - if (isEmpty(value)) { - return; - } - - if (fieldType === "number") { - const clean = Number(value); - return !Number.isNaN(clean) ? clean : 0; - } - - return value; -} - export function FormCreatorPopoverBody({ fieldSettings, onChange, @@ -75,15 +63,6 @@ export function FormCreatorPopoverBody({ fieldSettings: FieldSettings; onChange: (fieldSettings: FieldSettings) => void; }) { - const inputTypes = useMemo(getInputTypes, []); - - const handleUpdateFieldType = (newFieldType: FieldType) => - onChange({ - ...fieldSettings, - fieldType: newFieldType, - inputType: inputTypes[newFieldType][0].value, - }); - const handleUpdateInputType = (newInputType: InputSettingType) => onChange({ ...fieldSettings, @@ -96,17 +75,19 @@ export function FormCreatorPopoverBody({ placeholder: newPlaceholder, }); - const handleUpdateRequired = ({ - required, - defaultValue, - }: { - required: boolean; - defaultValue?: string | number; - }) => + const handleUpdateRequired = (required: boolean) => onChange({ ...fieldSettings, required, - defaultValue: cleanDefaultValue(fieldSettings.fieldType, defaultValue), + defaultValue: undefined, + }); + + const handleUpdateDefaultValue = ( + defaultValue: string | number | undefined, + ) => + onChange({ + ...fieldSettings, + defaultValue, }); const hasPlaceholder = @@ -114,11 +95,6 @@ export function FormCreatorPopoverBody({ return ( - - ); } -function FieldTypeSelect({ - value, - onChange, -}: { - value: FieldType; - onChange: (newFieldType: FieldType) => void; -}) { - const fieldTypes = useMemo(getFieldTypes, []); - - return ( -
- {t`Field type`} - -
- ); -} - function InputTypeSelect({ fieldType, value, @@ -192,10 +145,13 @@ function PlaceholderInput({ value: string; onChange: (newPlaceholder: string) => void; }) { + const id = useUniqueId(); + return (
- {t`Placeholder text`} + {t`Placeholder text`} onChange(e.target.value)} @@ -205,42 +161,54 @@ function PlaceholderInput({ ); } +interface RequiredInputProps { + fieldSettings: FieldSettings; + onChangeRequired: (required: boolean) => void; + onChangeDefaultValue: (defaultValue: string | number | undefined) => void; +} + function RequiredInput({ - value, - defaultValue, - onChange, -}: { - value: boolean; - defaultValue?: string | number; - onChange: ({ - required, - defaultValue, - }: { - required: boolean; - defaultValue?: string | number; - }) => void; -}) { + fieldSettings: { fieldType, inputType, required, defaultValue }, + onChangeRequired, + onChangeDefaultValue, +}: RequiredInputProps) { + const id = useUniqueId(); + + const handleDefaultValueChange = ({ + target: { value }, + }: ChangeEvent) => { + if (!value) { + onChangeDefaultValue(undefined); + } else if (fieldType === "number") { + onChangeDefaultValue(Number(value)); + } else { + onChangeDefaultValue(value); + } + }; + return (
- {t`Required`} + {t`Required`} onChange({ required, defaultValue })} + id={`${id}-required`} + value={required} + onChange={onChangeRequired} /> - {!value && ( + {required && ( <> - {t`Default value`} + + {t`Default value`} + - onChange({ required: false, defaultValue: e.target.value }) - } - data-testid="placeholder-input" + onChange={handleDefaultValueChange} /> )} diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.unit.spec.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.unit.spec.tsx index 408a39ce17254..57a844cdc8368 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.unit.spec.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.unit.spec.tsx @@ -36,63 +36,23 @@ function setup({ settings = getDefaultFieldSettings() } = {}) { } describe("actions > FormCreator > FieldSettingsPopover", () => { - it("should show the popover", async () => { - setup(); - - userEvent.click(screen.getByLabelText("Field settings")); - - expect( - await screen.findByTestId("field-settings-popover"), - ).toBeInTheDocument(); - }); - - it("should fire onChange handler clicking a different field type", async () => { - const { settings, onChange } = setup(); - - userEvent.click(screen.getByLabelText("Field settings")); - - expect( - await screen.findByTestId("field-settings-popover"), - ).toBeInTheDocument(); - - userEvent.click(screen.getByText("Date")); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ - ...settings, - fieldType: "date", - inputType: "date", // should set default input type for new field type - }); - }); - - it("should fire onChange handler clicking a different input type", async () => { + it("should allow to change the input type", async () => { const { settings, onChange } = setup(); userEvent.click(screen.getByLabelText("Field settings")); + userEvent.click(await screen.findByText("Dropdown")); - expect( - await screen.findByTestId("field-settings-popover"), - ).toBeInTheDocument(); - - userEvent.click(screen.getByText("Dropdown")); - - expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith({ ...settings, inputType: "select", }); }); - it("should fire onChange handler editing placeholder", async () => { + it("should allow to change the placeholder", async () => { const { settings, onChange } = setup(); userEvent.click(screen.getByLabelText("Field settings")); - - expect( - await screen.findByTestId("field-settings-popover"), - ).toBeInTheDocument(); - - await userEvent.type(screen.getByTestId("placeholder-input"), "$"); + userEvent.type(await screen.findByLabelText("Placeholder text"), "$"); expect(onChange).toHaveBeenLastCalledWith({ ...settings, @@ -100,7 +60,7 @@ describe("actions > FormCreator > FieldSettingsPopover", () => { }); }); - it("should fire onChange handler after changing required and default value properties", async () => { + it("should allow to make the field required and optional", async () => { const settings = getDefaultFieldSettings({ fieldType: "number", required: true, @@ -108,24 +68,36 @@ describe("actions > FormCreator > FieldSettingsPopover", () => { const { onChange } = setup({ settings }); userEvent.click(screen.getByLabelText("Field settings")); - await screen.findByTestId("field-settings-popover"); + userEvent.click(await screen.findByLabelText("Required")); + expect(onChange).toHaveBeenLastCalledWith({ + ...settings, + required: false, + }); + expect(screen.queryByLabelText("Default value")).not.toBeInTheDocument(); userEvent.click(screen.getByLabelText("Required")); - expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenLastCalledWith({ ...settings, - defaultValue: undefined, - required: false, + required: true, }); + expect(screen.getByLabelText("Default value")).toBeInTheDocument(); + }); - const defaultValueInput = screen.getByLabelText("Default value"); - expect(defaultValueInput).not.toHaveValue(); - await userEvent.type(defaultValueInput, "5"); + it("should allow to set the default value", async () => { + const settings = getDefaultFieldSettings({ + fieldType: "number", + required: true, + }); + const { onChange } = setup({ settings }); + userEvent.click(screen.getByLabelText("Field settings")); + + const input = await screen.findByLabelText("Default value"); + userEvent.clear(input); + userEvent.type(input, "10"); expect(onChange).toHaveBeenLastCalledWith({ ...settings, - required: false, - defaultValue: 5, + defaultValue: 10, }); }); }); diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.styled.tsx index 37de6cb0610fb..6349ec51bcae6 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.styled.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.styled.tsx @@ -12,6 +12,10 @@ export const FormContainer = styled.div` background-color: ${color("white")}; `; +export const FormFieldEditorDragContainer = styled.div` + margin-bottom: ${space(1)}; +`; + export const InfoText = styled.span` display: block; color: ${color("text-medium")}; @@ -19,14 +23,9 @@ export const InfoText = styled.span` `; export const FieldSettingsButtonsContainer = styled.div` - position: absolute; - bottom: 0; - right: 0; - padding: ${space(0)}; display: flex; - gap: ${space(1)}; align-items: center; - justify-content: flex-end; + gap: ${space(1)}; `; export const EmptyFormPlaceholderWrapper = styled.div` diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.tsx index 7c9422d1b7e9b..9cfdcd09b527d 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.tsx @@ -1,32 +1,61 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useEffect, useCallback, useMemo, useState } from "react"; import { jt, t } from "ttag"; import _ from "underscore"; +import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; + +import type { + DraggableProvided, + DroppableProvided, + DropResult, +} from "react-beautiful-dnd"; import ExternalLink from "metabase/core/components/ExternalLink"; -import { ActionForm } from "metabase/actions/components/ActionForm"; -import SidebarContent from "metabase/query_builder/components/SidebarContent"; +import Form from "metabase/core/components/Form"; +import FormProvider from "metabase/core/components/FormProvider"; import MetabaseSettings from "metabase/lib/settings"; -import type { ActionFormSettings, Parameter } from "metabase-types/api"; +import SidebarContent from "metabase/query_builder/components/SidebarContent"; + +import type { + ActionFormSettings, + FieldSettings, + Parameter, +} from "metabase-types/api"; -import { getDefaultFormSettings, sortActionParams } from "../../../utils"; +import { + getForm, + getFormValidationSchema, + getDefaultFormSettings, + sortActionParams, +} from "../../../utils"; import { syncFieldsWithParameters } from "../utils"; +import { reorderFields } from "./utils"; import { EmptyFormPlaceholder } from "./EmptyFormPlaceholder"; -import { FormContainer, InfoText } from "./FormCreator.styled"; +import FormFieldEditor from "./FormFieldEditor"; +import { + FormContainer, + FormFieldEditorDragContainer, + InfoText, +} from "./FormCreator.styled"; + +// FormEditor's can't be submitted as it serves as a form preview +const ON_SUBMIT_NOOP = _.noop; + +interface FormCreatorProps { + parameters: Parameter[]; + formSettings?: ActionFormSettings; + isEditable: boolean; + onChange: (formSettings: ActionFormSettings) => void; +} function FormCreator({ - params, - isEditable, + parameters, formSettings: passedFormSettings, + isEditable, onChange, -}: { - params: Parameter[]; - isEditable: boolean; - formSettings?: ActionFormSettings; - onChange: (formSettings: ActionFormSettings) => void; -}) { +}: FormCreatorProps) { const [formSettings, setFormSettings] = useState( passedFormSettings?.fields ? passedFormSettings : getDefaultFormSettings(), ); @@ -37,14 +66,70 @@ function FormCreator({ useEffect(() => { // add default settings for new parameters - if (formSettings && params) { - setFormSettings(syncFieldsWithParameters(formSettings, params)); + if (formSettings && parameters) { + setFormSettings(syncFieldsWithParameters(formSettings, parameters)); } - }, [params, formSettings]); + }, [parameters, formSettings]); + + const form = useMemo( + () => getForm(parameters, formSettings?.fields), + [parameters, formSettings?.fields], + ); + + // Validation schema here should only be used to get default values + // for a form preview. We don't want error messages on the preview though. + const validationSchema = useMemo( + () => getFormValidationSchema(parameters, formSettings.fields), + [parameters, formSettings], + ); + + const displayValues = useMemo( + () => validationSchema.getDefault(), + [validationSchema], + ); const sortedParams = useMemo( - () => params.sort(sortActionParams(formSettings)), - [params, formSettings], + () => parameters.sort(sortActionParams(formSettings)), + [parameters, formSettings], + ); + + const handleDragEnd = useCallback( + ({ source, destination }: DropResult) => { + if (!formSettings.fields) { + return; + } + + const oldOrder = source.index; + const newOrder = destination?.index ?? source.index; + + const reorderedFields = reorderFields( + formSettings.fields, + oldOrder, + newOrder, + ); + setFormSettings({ + ...formSettings, + fields: reorderedFields, + }); + }, + [formSettings], + ); + + const handleChangeFieldSettings = useCallback( + (newFieldSettings: FieldSettings) => { + if (!newFieldSettings?.id) { + return; + } + + setFormSettings({ + ...formSettings, + fields: { + ...formSettings.fields, + [newFieldSettings.id]: newFieldSettings, + }, + }); + }, + [formSettings], ); if (!sortedParams.length) { @@ -57,6 +142,8 @@ function FormCreator({ ); } + const fieldSettings = formSettings.fields || {}; + const docsLink = ( {jt`Configure your parameters' types and properties here. The values for these parameters can come from user input, or from a dashboard filter. ${docsLink}`} - + +
+ + + {(provided: DroppableProvided) => ( +
+ {form.fields.map((field, index) => ( + + {(provided: DraggableProvided) => ( + + + + )} + + ))} +
+ )} +
+
+ +
); diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.unit.spec.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.unit.spec.tsx new file mode 100644 index 0000000000000..308b62afdaca6 --- /dev/null +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.unit.spec.tsx @@ -0,0 +1,241 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import type { + ActionFormSettings, + FieldSettings, + WritebackParameter, +} from "metabase-types/api"; +import { createMockActionParameter } from "metabase-types/api/mocks"; + +import FormCreator from "./FormCreator"; + +const makeFieldSettings = ( + overrides: Partial = {}, +): FieldSettings => ({ + id: "abc-123", + name: "form field name", + title: "form field name", + order: 1, + fieldType: "string", + inputType: "string", + required: false, + hidden: false, + ...overrides, +}); + +const makeParameter = ({ + id = "abc-123", + ...params +}: Partial = {}): WritebackParameter => { + return createMockActionParameter({ + id, + target: ["variable", ["template-tag", id]], + type: "type/Text", + required: false, + ...params, + }); +}; + +type SetupOpts = { + parameters: WritebackParameter[]; + formSettings: ActionFormSettings; +}; + +const setup = ({ parameters, formSettings }: SetupOpts) => { + const onChange = jest.fn(); + + render( + , + ); + + return { onChange }; +}; + +describe("actions > containers > ActionCreator > FormCreator", () => { + it("renders the form editor", () => { + setup({ + parameters: [makeParameter()], + formSettings: { + type: "form", + fields: { + "abc-123": makeFieldSettings({ inputType: "string" }), + }, + }, + }); + + expect(screen.getByTestId("action-form-editor")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("can change a string field to a numeric field", () => { + const formSettings: ActionFormSettings = { + type: "form", + fields: { + "abc-123": makeFieldSettings({ inputType: "string" }), + }, + }; + const { onChange } = setup({ + parameters: [makeParameter()], + formSettings, + }); + + userEvent.click( + screen.getByRole("radio", { + name: /number/i, + }), + ); + + expect(onChange).toHaveBeenCalledWith({ + ...formSettings, + fields: { + "abc-123": makeFieldSettings({ + fieldType: "number", + inputType: "number", + }), + }, + }); + }); + + it("can change a string field to a text(area) field", async () => { + const formSettings: ActionFormSettings = { + type: "form", + fields: { + "abc-123": makeFieldSettings({ inputType: "string" }), + }, + }; + + const { onChange } = setup({ + parameters: [makeParameter()], + formSettings, + }); + + // click the settings cog then the number input type + userEvent.click(screen.getByLabelText("Field settings")); + userEvent.click(await screen.findByText("Long text")); + + expect(onChange).toHaveBeenCalledWith({ + ...formSettings, + fields: { + "abc-123": makeFieldSettings({ + fieldType: "string", + inputType: "text", + }), + }, + }); + }); + + it("can change a numeric field to a date field", () => { + const formSettings: ActionFormSettings = { + type: "form", + fields: { + "abc-123": makeFieldSettings({ inputType: "number" }), + }, + }; + + const { onChange } = setup({ + parameters: [makeParameter()], + formSettings, + }); + + userEvent.click( + screen.getByRole("radio", { + name: /date/i, + }), + ); + + expect(onChange).toHaveBeenCalledWith({ + ...formSettings, + fields: { + "abc-123": makeFieldSettings({ + fieldType: "date", + inputType: "date", + }), + }, + }); + }); + + it("can change a date field to a number field", async () => { + const formSettings: ActionFormSettings = { + type: "form", + fields: { + "abc-123": makeFieldSettings({ inputType: "date" }), + }, + }; + const { onChange } = setup({ + parameters: [makeParameter()], + formSettings, + }); + + userEvent.click( + screen.getByRole("radio", { + name: /number/i, + }), + ); + + expect(onChange).toHaveBeenCalledWith({ + ...formSettings, + fields: { + "abc-123": makeFieldSettings({ + fieldType: "number", + inputType: "number", + }), + }, + }); + }); + + it("can toggle required state", async () => { + const formSettings: ActionFormSettings = { + type: "form", + fields: { + "abc-123": makeFieldSettings({ inputType: "string" }), + }, + }; + const { onChange } = setup({ + parameters: [makeParameter()], + formSettings, + }); + + userEvent.click(screen.getByLabelText("Field settings")); + userEvent.click(await screen.findByRole("switch")); + + expect(onChange).toHaveBeenCalledWith({ + ...formSettings, + fields: { + "abc-123": makeFieldSettings({ + required: true, + inputType: "string", + }), + }, + }); + }); + + it("displays default values", () => { + const defaultValue = "foo bar"; + const parameter = makeParameter(); + const fieldSettings = makeFieldSettings({ + inputType: "string", + required: true, + defaultValue, + }); + setup({ + parameters: [parameter], + formSettings: { + type: "form", + fields: { + [parameter.id]: fieldSettings, + }, + }, + }); + + expect(screen.getByLabelText(fieldSettings.title)).toHaveValue( + defaultValue, + ); + }); +}); diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.styled.tsx new file mode 100644 index 0000000000000..dd63b06fa5729 --- /dev/null +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.styled.tsx @@ -0,0 +1,89 @@ +import styled from "@emotion/styled"; + +import FormField from "metabase/core/components/FormField"; +import Radio from "metabase/core/components/Radio"; +import Icon from "metabase/components/Icon"; + +import { space } from "metabase/styled-components/theme"; +import { color, darken } from "metabase/lib/colors"; + +const DRAG_HANDLE_SIZE = 12; + +export const FormFieldContainer = styled.div` + background-color: ${color("bg-white")}; + border: 1px solid ${color("border")}; + border-radius: ${space(1)}; + overflow: hidden; +`; + +const ContentContainer = styled.div` + display: flex; + gap: ${space(1)}; +`; + +export const EditorContainer = styled(ContentContainer)` + display: flex; + padding: 1rem 1rem 0.85rem 0.85rem; + gap: ${space(1)}; + + ${Radio.RadioGroupVariants.join(", ")} { + margin-top: 10px; + } + + ${Radio.RadioContainerVariants.join(", ")} { + padding: 4px 10px; + } +`; + +export const Column = styled.div<{ full?: boolean }>` + display: flex; + flex-direction: column; + flex: ${props => (props.full ? 1 : "unset")}; + + min-width: ${DRAG_HANDLE_SIZE}px; +`; + +export const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const Title = styled.div` + color: ${color("text-dark")}; + font-weight: 700; +`; + +export const Subtitle = styled.div` + color: ${color("text-medium")}; + font-size: 0.85rem; + font-weight: 700; + + margin-top: 1.2rem; +`; + +export const DragHandle = styled(Icon)` + color: ${color("text-medium")}; + margin-top: 4px; +`; + +DragHandle.defaultProps = { size: DRAG_HANDLE_SIZE }; + +export const PreviewContainer = styled(ContentContainer)` + background-color: ${color("bg-light")}; + border-top: 1px solid ${darken("bg-light", 0.1)}; + + padding: 1rem 1rem 2rem 1rem; + + ${FormField.Root} { + margin-bottom: 0; + } + + ${FormField.Label} { + color: ${color("text-dark")}; + } +`; + +export const InputContainer = styled.div` + flex: 1 0 1; +`; diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.tsx new file mode 100644 index 0000000000000..95c8f6eaad2bc --- /dev/null +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from "react"; +import { t } from "ttag"; + +import Radio from "metabase/core/components/Radio"; +import { isNotNull } from "metabase/core/utils/types"; + +import ActionFormFieldWidget from "metabase/actions/components/ActionFormFieldWidget"; +import { getFieldTypes, getInputTypes } from "metabase/actions/constants"; +import { inputTypeHasOptions } from "metabase/actions/utils"; + +import type { + FieldSettings, + FieldType, + FieldValueOptions, +} from "metabase-types/api"; +import type { ActionFormFieldProps } from "metabase/actions/types"; + +import { FieldSettingsButtons } from "../FieldSettingsButtons"; + +import { + Column, + DragHandle, + EditorContainer, + FormFieldContainer, + Header, + InputContainer, + Title, + Subtitle, + PreviewContainer, +} from "./FormFieldEditor.styled"; + +export interface FormFieldEditorProps { + field: ActionFormFieldProps; + fieldSettings: FieldSettings; + isEditable: boolean; + onChange: (settings: FieldSettings) => void; +} + +function cleanFieldValue( + value: string | number | undefined, + fieldType: FieldType, +) { + if (value == null) { + return value; + } else if (fieldType === "string") { + return String(value); + } else if (fieldType === "number") { + const number = Number(value); + return !Number.isNaN(number) ? number : undefined; + } else { + return undefined; + } +} + +function cleanOptionValues(values: FieldValueOptions, fieldType: FieldType) { + return values + .map(value => cleanFieldValue(value, fieldType)) + .filter(isNotNull); +} + +function FormFieldEditor({ + field, + fieldSettings, + isEditable, + onChange, +}: FormFieldEditorProps) { + const fieldTypeOptions = useMemo(getFieldTypes, []); + const inputTypeOptions = useMemo(getInputTypes, []); + + const handleChangeFieldType = (nextFieldType: FieldType) => { + const { inputType, valueOptions } = fieldSettings; + + const inputTypesForNextFieldType = inputTypeOptions[nextFieldType].map( + option => option.value, + ); + + // Allows to preserve dropdown/radio input types across number/string field types + const nextInputType = inputTypesForNextFieldType.includes(inputType) + ? inputType + : inputTypesForNextFieldType[0]; + + const nextValueOptions = inputTypeHasOptions(nextInputType) + ? cleanOptionValues(valueOptions || [], nextFieldType) + : undefined; + + const nextDefaultValue = cleanFieldValue( + fieldSettings.defaultValue, + nextFieldType, + ); + + onChange({ + ...fieldSettings, + fieldType: nextFieldType, + inputType: nextInputType, + valueOptions: nextValueOptions, + defaultValue: nextDefaultValue, + }); + }; + + return ( + + + {isEditable && } + +
+ {field.title} + {isEditable && ( + + )} +
+ {isEditable && fieldSettings && ( + <> + {t`Field type`} + + + )} + {t`Appearance`} +
+
+ + + + + + + + +
+ ); +} + +export default FormFieldEditor; diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.unit.spec.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.unit.spec.tsx new file mode 100644 index 0000000000000..02b68179cd5a0 --- /dev/null +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/FormFieldEditor.unit.spec.tsx @@ -0,0 +1,207 @@ +import React, { useState } from "react"; +import userEvent from "@testing-library/user-event"; +import { + getIcon, + queryIcon, + render, + screen, + waitFor, + within, +} from "__support__/ui"; +import FormProvider from "metabase/core/components/FormProvider"; +import { getDefaultFieldSettings } from "metabase/actions/utils"; +import type { FieldSettings } from "metabase-types/api"; +import FormFieldEditor, { FormFieldEditorProps } from "./FormFieldEditor"; + +const DEFAULT_FIELD: FormFieldEditorProps["field"] = { + name: "uuid", + title: "First Name", + type: "text", +}; + +function setup({ + field = DEFAULT_FIELD, + fieldSettings: initialFieldSettings = getDefaultFieldSettings(), + isEditable = true, + onChange = jest.fn(), +}: Partial = {}) { + function WrappedFormFieldEditor() { + const [fieldSettings, setFieldSettings] = useState(initialFieldSettings); + return ( + { + onChange(nextFieldSettings); + setFieldSettings(nextFieldSettings); + }} + /> + ); + } + + render( + + + , + ); + + return { onChange }; +} + +describe("FormFieldEditor", () => { + it("renders correctly", () => { + const field = { + ...DEFAULT_FIELD, + description: "Well, it's a first name", + placeholder: "John Doe", + }; + setup({ field }); + + expect(screen.getByLabelText(field.title)).toBeInTheDocument(); + expect(screen.getByText(field.description)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(field.placeholder)).toBeInTheDocument(); + expect(screen.getByLabelText("Field settings")).toBeInTheDocument(); + expect(getIcon("grabber2")).toBeInTheDocument(); + }); + + it("handles field type change", () => { + const { onChange } = setup(); + + userEvent.click(screen.getByText("Date")); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ + ...getDefaultFieldSettings(), + fieldType: "date", + inputType: "date", + }); + + userEvent.click(screen.getByText("Number")); + + expect(onChange).toHaveBeenLastCalledWith({ + ...getDefaultFieldSettings(), + fieldType: "number", + inputType: "number", + }); + }); + + it("respects uneditable state", () => { + setup({ isEditable: false }); + + expect(screen.queryByText("Field type")).not.toBeInTheDocument(); + expect( + screen.queryByRole("radiogroup", { name: "Field type" }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Field settings")).not.toBeInTheDocument(); + expect(queryIcon("grabber2")).not.toBeInTheDocument(); + }); + + describe("field values", () => { + const TEST_STRING_FIELD_SETTINGS: FieldSettings = { + ...getDefaultFieldSettings(), + fieldType: "string", + inputType: "select", + valueOptions: ["1", "2", "3", "not-a-number"], + }; + + it("keeps value options when switching between input types", async () => { + const { onChange } = setup({ fieldSettings: TEST_STRING_FIELD_SETTINGS }); + userEvent.click(screen.getByLabelText("Field settings")); + const popover = await screen.findByRole("tooltip"); + + userEvent.click(within(popover).getByRole("radio", { name: "Text" })); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + ...TEST_STRING_FIELD_SETTINGS, + inputType: "string", + }), + ); + + userEvent.click( + within(popover).getByRole("radio", { name: "Inline select" }), + ); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + ...TEST_STRING_FIELD_SETTINGS, + inputType: "radio", + }), + ); + }); + + it("handles value options when switching between field types", async () => { + const { onChange } = setup({ fieldSettings: TEST_STRING_FIELD_SETTINGS }); + + userEvent.click(screen.getByText("Number")); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + ...TEST_STRING_FIELD_SETTINGS, + fieldType: "number", + valueOptions: [1, 2, 3], + }), + ); + + userEvent.click(screen.getByText("Date")); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + ...TEST_STRING_FIELD_SETTINGS, + fieldType: "date", + inputType: "date", + valueOptions: undefined, + }), + ); + }); + }); + + describe("default values", () => { + it("keeps default value when converting between input types", async () => { + const fieldSettings = getDefaultFieldSettings({ + fieldType: "string", + inputType: "string", + defaultValue: "default", + }); + + const { onChange } = setup({ fieldSettings }); + + userEvent.click(screen.getByLabelText("Field settings")); + expect(await screen.findByRole("tooltip")).toBeInTheDocument(); + + userEvent.click(screen.getByRole("radio", { name: "Long text" })); + + expect(onChange).toHaveBeenLastCalledWith({ + ...fieldSettings, + inputType: "text", + defaultValue: "default", + }); + }); + + it("handles default value when switching between field types", async () => { + const fieldSettings = getDefaultFieldSettings({ + fieldType: "string", + inputType: "text", + defaultValue: "123", + valueOptions: undefined, + }); + + const { onChange } = setup({ fieldSettings }); + userEvent.click(screen.getByLabelText("Field settings")); + expect(await screen.findByRole("tooltip")).toBeInTheDocument(); + + userEvent.click(screen.getByText("Number")); + expect(onChange).toHaveBeenLastCalledWith({ + ...fieldSettings, + fieldType: "number", + inputType: "number", + defaultValue: 123, + }); + + userEvent.click(screen.getByText("Date")); + expect(onChange).toHaveBeenLastCalledWith({ + ...fieldSettings, + fieldType: "date", + inputType: "date", + defaultValue: undefined, + }); + }); + }); +}); diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/index.ts b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/index.ts new file mode 100644 index 0000000000000..7ae8622829db5 --- /dev/null +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormFieldEditor/index.ts @@ -0,0 +1 @@ +export { default } from "./FormFieldEditor"; diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.styled.tsx index 9b82aa960b951..6351147e16322 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.styled.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.styled.tsx @@ -7,15 +7,22 @@ export const OptionEditorContainer = styled.div` display: flex; flex-direction: column; padding: ${space(2)}; + gap: ${space(1)}; `; -export const AddMorePrompt = styled.div` +export const AddMorePrompt = styled.div<{ isVisible: boolean }>` text-align: center; font-size: 0.875rem; - margin: ${space(1)} 0; height: 1.25rem; color: ${color("text-light")}; transition: opacity 0.2s ease-in-out; + opacity: ${props => (props.isVisible ? 1 : 0)}; +`; + +export const ErrorMessage = styled.div` + text-align: center; + font-size: 0.875rem; + color: ${color("error")}; `; export const TextArea = styled.textarea` diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.tsx index af1768c314ef5..f0935e12a2027 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.tsx @@ -1,53 +1,109 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { t } from "ttag"; import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger"; import Button from "metabase/core/components/Button"; import Icon from "metabase/components/Icon"; +import type { FieldType, FieldValueOptions } from "metabase-types/api"; + import { OptionEditorContainer, AddMorePrompt, + ErrorMessage, TextArea, } from "./OptionEditor.styled"; -type ValueOptions = (string | number)[]; - -const optionsToText = (options: ValueOptions) => options.join("\n"); -const textToOptions = (text: string): ValueOptions => +const optionsToText = (options: FieldValueOptions) => options.join("\n"); +const textToOptions = (text: string): FieldValueOptions => text.split("\n").map(option => option.trim()); +function cleanOptions(options: FieldValueOptions, fieldType: FieldType) { + if (fieldType === "number") { + return options.map(option => Number(option)); + } + return options; +} + +function getValidationError(options: FieldValueOptions, fieldType: FieldType) { + if (fieldType === "number") { + const isValid = options.every(option => !Number.isNaN(option)); + return isValid ? undefined : t`Invalid number format`; + } + return; +} + +export interface OptionEditorProps { + fieldType: FieldType; + options: FieldValueOptions; + onChange: (options: FieldValueOptions) => void; +} + export const OptionPopover = ({ + fieldType, options, onChange, -}: { - options: ValueOptions; - onChange: (options: ValueOptions) => void; -}) => { +}: OptionEditorProps) => { const [text, setText] = useState(optionsToText(options)); - const save = (closePopover: () => void) => { - onChange(textToOptions(text)); - closePopover(); + const [error, setError] = useState(null); + + const hasOptions = text.length > 0; + const isDirty = text !== optionsToText(options); + const hasError = Boolean(error); + const canSave = hasOptions && isDirty && !hasError; + + useEffect(() => { + if (optionsToText(options) !== text) { + setText(optionsToText(options)); + } + // Ignore text changes caused by user edits, + // and only trigger when options change from outside + // (e.g. on field type change) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options]); + + const handleTextChange = (e: React.ChangeEvent) => { + setText(e.target.value); + if (hasError) { + setError(null); + } + }; + + const handleSave = (closePopover: () => void) => { + setError(null); + const nextOptions = cleanOptions(textToOptions(text), fieldType); + const error = getValidationError(nextOptions, fieldType); + if (error) { + setError(error); + } else { + onChange(nextOptions); + closePopover(); + } }; return ( + } maxWidth={400} popoverContent={({ closePopover }) => (