diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index fc501686e51365d13beec78c705d7f0cb9948f7f..2a3a25c052afc52ad7908095d34c2796a159b121 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -146,6 +146,7 @@ ;; module drivers :athena :bigquery-cloud-sdk + :databricks :druid :druid-jdbc :googleanalytics @@ -372,7 +373,10 @@ metabase.sync.util} ; TODO -- consolidate these into a real API namespace. metabase.task #{metabase.task metabase.task.index-values - metabase.task.persist-refresh} ; TODO -- consolidate these into a real API namespace. + metabase.task.persist-refresh + ;; We need to expose this to the metabase.search module. + ;; Ideally, it would just live inside that module. + metabase.task.search-index} ; TODO -- consolidate these into a real API namespace. metabase.troubleshooting #{metabase.troubleshooting} metabase.types #{metabase.types} metabase.upload #{metabase.upload} diff --git a/.github/workflows/drivers.yml b/.github/workflows/drivers.yml index 1aec8908c344da3a28ee15ad96680bc181a8bdaa..74edc2cc843849100a413637e4122db479b55bcf 100644 --- a/.github/workflows/drivers.yml +++ b/.github/workflows/drivers.yml @@ -129,6 +129,37 @@ jobs: aws-region: ${{ vars.AWS_REGION }} trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }} + be-tests-databricks-ee: + needs: files-changed + if: github.event.pull_request.draft == false && needs.files-changed.outputs.backend_all == 'true' + runs-on: ubuntu-22.04 + timeout-minutes: 90 + env: + CI: 'true' + DRIVERS: databricks + MB_DATABRICKS_TEST_HOST: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HOST }} + MB_DATABRICKS_TEST_HTTP_PATH: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HTTP_PATH }} + MB_DATABRICKS_TEST_TOKEN: ${{ secrets.MB_DATABRICKS_JDBC_TEST_TOKEN }} + MB_DATABRICKS_TEST_CATALOG: 'metabase_ci' + steps: + - uses: actions/checkout@v4 + - name: Test Databricks driver + uses: ./.github/actions/test-driver + with: + junit-name: 'be-tests-databricks-ee' + test-args: ":exclude-tags '[:mb/once]'" + - name: Upload Test Results + uses: ./.github/actions/upload-test-results + if: always() + with: + input-path: ./target/junit/ + output-name: ${{ github.job }} + bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }} + aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION }} + trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }} + be-tests-druid-jdbc-ee: needs: files-changed if: github.event.pull_request.draft == false && needs.files-changed.outputs.backend_all == 'true' diff --git a/.github/workflows/release-log.yml b/.github/workflows/release-log.yml index cd235a4cc1c823c963d371bb56c6f8dd53a9210c..817a3ee8bfb26b6f3210411bd148cba99139fee6 100644 --- a/.github/workflows/release-log.yml +++ b/.github/workflows/release-log.yml @@ -14,7 +14,7 @@ on: inputs: version: description: 'Major Metabase version (e.g. 45, 52, 68)' - type: number + type: number # needs to be a number to pass variables required: true jobs: @@ -22,7 +22,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 env: - VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || vars.CURRENT_VERSION }} + VERSION: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') + && inputs.version || vars.CURRENT_VERSION }} steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 @@ -36,7 +37,7 @@ jobs: - name: Install Dependencies run: yarn --cwd release --frozen-lockfile && npm i -g tsx - name: generate release Log - run: cd release && tsx ./src/release-log.ts $VERSION > v$VERSION.html + run: cd release && tsx ./src/release-log-run.ts $VERSION > v$VERSION.html - name: generate release channel log run: cd release && tsx ./src/release-channel-log.ts > channels.html - name: upload release log to the web @@ -54,4 +55,4 @@ jobs: run: | aws cloudfront create-invalidation \ --distribution-id ${{ vars.AWS_CLOUDFRONT_STATIC_ID }} \ - --paths /release-log/v$VERSION.html + --paths "/release-log/v$VERSION.html" "/release-log/channels.html" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 4e2032dcf1aedc315540083f08829e29ccc574d1..993f365e69d467026d2489218424cdc9fc8dc415 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -28,6 +28,30 @@ on: description: Apply to OSS type: boolean default: true + workflow_call: + inputs: + version: + type: string + required: true + tag_name: + type: string + # options: + # - nightly + # - beta + # - latest + required: true + tag_rollout: + description: Rollout % (0-100) + type: number + default: 100 + tag_ee: + description: Apply to EE + type: boolean + default: true + tag_oss: + description: Apply to OSS + type: boolean + default: true jobs: check-version: @@ -262,7 +286,7 @@ jobs: needs: check-version uses: ./.github/workflows/release-log.yml with: - version: ${{ vars.CURRENT_VERSION }} + version: ${{ fromJSON(vars.CURRENT_VERSION) }} # cast string to number secrets: inherit update-version-info: @@ -287,9 +311,10 @@ jobs: sparse-checkout: release - name: Prepare build scripts run: cd ${{ github.workspace }}/release && yarn && yarn build - - name: Publish release notes + - name: Generate new version info uses: actions/github-script@v7 id: new_version_info + if: ${{ inputs.tag_name }} == "latest" with: result-encoding: string script: | # js @@ -318,6 +343,7 @@ jobs: aws s3 cp version-info.json s3://${{ vars.AWS_S3_STATIC_BUCKET }}/version-info.json fi - name: Create cloudfront invalidation for version-info.json and version-info-ee.json + if: ${{ inputs.tag_name }} == "latest" run: | aws cloudfront create-invalidation \ --distribution-id ${{ vars.AWS_CLOUDFRONT_STATIC_ID }} \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baf4950c5d7315752e951b978ec297bffbdd83a9..ee13322d4abd3931f35cca04ae48337c362c7fad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -687,6 +687,18 @@ jobs: --distribution-id ${{ vars.AWS_CLOUDFRONT_STATIC_ID }} \ --paths "/version-info.json" "/version-info-ee.json" + tag-nightly: + if: ${{ inputs.auto }} + needs: [push-tags, verify-docker-pull, verify-s3-download] + uses: ./.github/workflows/release-tag.yml + secrets: inherit + with: + version: ${{ inputs.version }} + tag_name: nightly + tag_rollout: 100 + tag_ee: true + tag_oss: true + publish-complete-message: if: ${{ !inputs.auto }} runs-on: ubuntu-22.04 @@ -719,7 +731,7 @@ jobs: auto-publish-complete-message: if: ${{ inputs.auto }} runs-on: ubuntu-22.04 - needs: [push-tags, verify-docker-pull, verify-s3-download] + needs: tag-nightly timeout-minutes: 5 steps: - uses: actions/checkout@v4 diff --git a/.storybook/main.js b/.storybook/main.js index a522fb6c9c8bea3aa193ef98f0272adf24be21dd..3342d8e98677d546ff6f3aeb7c8c9321aee90755 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -55,7 +55,7 @@ module.exports = { }), new webpack.EnvironmentPlugin({ EMBEDDING_SDK_VERSION, - IS_EMBEDDING_SDK_BUILD: isEmbeddingSDK, + IS_EMBEDDING_SDK: isEmbeddingSDK, }), ], module: { diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index cd48e8781ad1cf95a11a1882b19fc80ad2cd8c45..52a0d45125d5642425743b27dc7bcc3070a54042 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,10 +1,14 @@ import React from "react"; import { ThemeProvider } from "metabase/ui"; -import "metabase/css/core/index.css"; -import "metabase/css/vendor.css"; -import "metabase/css/index.module.css"; -import "metabase/lib/dayjs"; +const isEmbeddingSDK = process.env.IS_EMBEDDING_SDK === "true"; + +if (!isEmbeddingSDK) { + require("metabase/css/core/index.css"); + require("metabase/css/vendor.css"); + require("metabase/css/index.module.css"); + require("metabase/lib/dayjs"); +} import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider"; import { getMetabaseCssVariables } from "metabase/styled-components/theme/css-variables"; @@ -35,17 +39,19 @@ const globalStyles = css` ${baseStyle} `; -export const decorators = [ - renderStory => ( - <EmotionCacheProvider> - <ThemeProvider> - <Global styles={globalStyles} /> - <CssVariables /> - {renderStory()} - </ThemeProvider> - </EmotionCacheProvider> - ), -]; +export const decorators = isEmbeddingSDK + ? [] // No decorators for Embedding SDK stories, as we want to simulate real use cases + : [ + renderStory => ( + <EmotionCacheProvider> + <ThemeProvider> + <Global styles={globalStyles} /> + <CssVariables /> + {renderStory()} + </ThemeProvider> + </EmotionCacheProvider> + ), + ]; function CssVariables() { const theme = useTheme(); diff --git a/bin/build/resources/overrides.edn b/bin/build/resources/overrides.edn index 1856ea25f60788a8ed2e52f9657029340e491c28..69c1aa0afb88d23558d543d5434f58c787834ffc 100644 --- a/bin/build/resources/overrides.edn +++ b/bin/build/resources/overrides.edn @@ -20,6 +20,7 @@ "amalloy" {"ring-gzip-middleware" {:resource "MIT.txt"}} "buddy" {"buddy-core" {:resource "apache2_0.txt"} "buddy-sign" {:resource "apache2_0.txt"}} + "dev.failsafe" {"failsafe" {:resource "LICENSE"}} "colorize" {"colorize" {:resource "EPL.txt"}} "com.github.jnr" {"jffi$native" {:resource "apache2_0.txt"}} "com.google.api-client" {"google-api-client" {:resource "apache2_0.txt"}} diff --git a/deps.edn b/deps.edn index 80e8fc4ec484f51d91647d467b6f60ff6bc0b75b..b763a1ffdf918002bcbe92cd91e5194edac9174a 100644 --- a/deps.edn +++ b/deps.edn @@ -66,6 +66,7 @@ compojure/compojure {:mvn/version "1.7.1" ; 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 + diehard/diehard {:mvn/version "0.11.12"} dk.ative/docjure {:mvn/version "1.19.0" ; excel export :exclusions [org.apache.poi/poi org.apache.poi/poi-ooxml]} @@ -387,6 +388,7 @@ {:extra-paths ["modules/drivers/athena/test" "modules/drivers/bigquery-cloud-sdk/test" + "modules/drivers/databricks/test" "modules/drivers/druid/test" "modules/drivers/druid-jdbc/test" "modules/drivers/mongo/test" @@ -478,6 +480,7 @@ "enterprise/backend/src" "modules/drivers/athena/src" "modules/drivers/bigquery-cloud-sdk/src" + "modules/drivers/databricks/src" "modules/drivers/druid/src" "modules/drivers/druid-jdbc/src" "modules/drivers/mongo/src" @@ -500,6 +503,7 @@ "enterprise/backend/src" "modules/drivers/athena/src" "modules/drivers/bigquery-cloud-sdk/src" + "modules/drivers/databricks/src" "modules/drivers/druid/src" "modules/drivers/druid-jdbc/src" "modules/drivers/mongo/src" diff --git a/dev/src/dev/search.clj b/dev/src/dev/search.clj index 703e6c61a7e06b37b8700d8f93fe928d4c4d2fcd..0b628cf6182f6549b8b918706d5941f5ee35c14e 100644 --- a/dev/src/dev/search.clj +++ b/dev/src/dev/search.clj @@ -5,8 +5,6 @@ [metabase.search.postgres.core :as search.postgres] [metabase.search.postgres.index :as search.index] [metabase.search.postgres.index-test :refer [legacy-results]] - [metabase.server.middleware.offset-paging :as mw.offset-paging] - [metabase.test :as mt] [toucan2.core :as t2])) (defn- basic-view [xs] @@ -37,12 +35,13 @@ (defn- mini-bench [n engine search-term & args] #_{:clj-kondo/ignore [:discouraged-var]} - (let [f (case engine - :index-only search.index/search - :legacy legacy-results - :hybrid @#'search.postgres/hybrid - :hybrid-multi @#'search.postgres/hybrid-multi - :minimal @#'search.postgres/minimal)] + (let [f (case (keyword "search.engine" (name engine)) + :search.engine/index-only search.index/search + :search.engine/legacy legacy-results + :search.engine/hybrid @#'search.postgres/hybrid + :search.engine/hybrid-multi @#'search.postgres/hybrid-multi + :search.engine/minimal @#'search.postgres/minimal + :search.engine/minimal-wth-perms @#'search.postgres/minimal-with-perms)] (time (dotimes [_ n] (doall (apply f search-term args)))))) @@ -51,7 +50,7 @@ (mini-bench 500 :legacy "sample") ;; 30x speed-up for test-data on my machine (mini-bench 500 :index-only "sample") - ;; No noticeaable degradation, without permissions and filters + ;; No noticeable degradation, without permissions and filters (mini-bench 500 :minimal "sample") ;; but joining to the "hydrated query" reverses the advantage @@ -61,14 +60,14 @@ (mini-bench 100 :hybrid "sample") ;; using index + LIKE on the join ... still a little bit more overhead (mini-bench 100 :hybrid "sample" {:search-string "sample"}) - ;; oh! this monstrocity is actually 2x faster than baseline B-) + ;; oh! this monstrosity is actually 2x faster than baseline B-) (mini-bench 100 :hybrid-multi "sample") (mini-bench 100 :minimal "sample")) -(defn- test-search [search-string & [search-engine]] - (let [user-id (mt/user->id :crowberto) +(defn- test-search [user search-string & [search-engine]] + (let [user-id (:id user) user-perms #{"/"}] - (binding [api/*current-user* (atom (t2/select-one :model/User user-id)) + (binding [api/*current-user* (atom user) api/*current-user-id* user-id api/*is-superuser?* true api/*current-user-permissions-set* (atom user-perms)] @@ -77,17 +76,17 @@ {:archived nil :created-at nil :created-by #{} - :current-user-id 379 + :current-user-id user-id :is-superuser? true :current-user-perms user-perms :filter-items-in-personal-collection nil :last-edited-at nil :last-edited-by #{} - :limit mw.offset-paging/*limit* + :limit 50 :model-ancestors? nil :models search/all-models - :offset mw.offset-paging/*offset* - :search-engine search-engine + :offset 0 + :search-engine (some-> search-engine name) :search-native-query nil :search-string search-string :table-db-id nil @@ -96,14 +95,21 @@ (comment (require '[clj-async-profiler.core :as prof]) - (prof/serve-ui 8080) + (prof/serve-ui 8081) - (prof/profile - (count - (dotimes [_ 100] - (test-search "trivia")))) + (let [user (t2/select-one :model/User :is_superuser true)] + (prof/profile + #_{:clj-kondo/ignore [:discouraged-var]} + (time + (count + (dotimes [_ 1000] + (test-search user "trivia")))))) - (prof/profile - (count - (dotimes [_ 1000] - (test-search "trivia" "minimal"))))) + (let [user (t2/select-one :model/User :is_superuser true)] + (prof/profile + #_{:event :alloc} + #_{:clj-kondo/ignore [:discouraged-var]} + (time + (count + (dotimes [_ 1000] + (test-search user "trivia" :minimal))))))) diff --git a/docs/developers-guide/driver-changelog.md b/docs/developers-guide/driver-changelog.md index 68dfbc800aca11c8fba67529e9af636af6c901d8..d48ebf16b6b788a45e46d0c86c0b8ab8c7edd6ee 100644 --- a/docs/developers-guide/driver-changelog.md +++ b/docs/developers-guide/driver-changelog.md @@ -123,6 +123,14 @@ title: Driver interface changelog `:metabase.driver.sql.query-processor/nfc-path` to include the nfc-path in the field identifier. So that record-like fields can be referenced with `<table>.<record>.<record-field>`. See `bigquery-cloud-sdk` for an example. Defaults to `nil` to indicate that the path should not be part of the identifier. +- `:test/dynamic-dataset-loading` feature has been added. It enables drivers to bail out of tests that require + creation of new, not pre-loaded, dataset during test run time. + +- The `:temporal/requires-default-unit` feature has been added. It should be false for most drivers, but it's necessary + for a few (like the old, pre-JDBC Druid driver) to find all temporal field refs and put a `:temporal-unit :default` on them. + That default setting was previously done for all drivers, but it introduced some downstream issues, so now only those + drivers which need it can set the feature. + ## Metabase 0.50.17 - Added method `metabase.driver/incorporate-auth-provider-details` for driver specific behavior required to diff --git a/docs/developers-guide/partner-and-community-drivers.md b/docs/developers-guide/partner-and-community-drivers.md index 98d6300455c44e5db5516a5209ec0e7dad6bfd79..c02ea4b619bcb6a3e04b2581d658756707760132 100644 --- a/docs/developers-guide/partner-and-community-drivers.md +++ b/docs/developers-guide/partner-and-community-drivers.md @@ -75,6 +75,7 @@ Anyone can build a community driver. These are the currently known third-party d | [Netsuite SuiteAnalytics Connect](https://github.com/ericcj/metabase-netsuite-driver) |  |  | | [Databend](https://github.com/databendcloud/metabase-databend-driver) |  |  | | [Peaka](https://github.com/peakacom/metabase-driver) |  |  | +| [GreptimeDB](https://github.com/greptimeteam/greptimedb-metabase-driver) |  |  | If you don't see a driver for your database, then try looking in the comments of the [issue related to the database](https://github.com/metabase/metabase/labels/Database%2F). You might also find more by [searching on GitHub](https://github.com/search?q=metabase+driver). diff --git a/docs/releases.md b/docs/releases.md index 22a194c0525759b0d62ca207e49545fe9665ec38..27103f5bba6defee7e2f8202f2b5664496dc5784 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -19,6 +19,7 @@ To see what's new, check out all the [major release announcements](https://www.m ## Metabase Enterprise Edition releases +- [v1.50.27](https://github.com/metabase/metabase/releases/tag/v1.50.27) - [v1.50.26](https://github.com/metabase/metabase/releases/tag/v1.50.26) - [v1.50.25](https://github.com/metabase/metabase/releases/tag/v1.50.25) - [v1.50.24](https://github.com/metabase/metabase/releases/tag/v1.50.24) @@ -205,6 +206,7 @@ To see what's new, check out all the [major release announcements](https://www.m ## Metabase Open Source Edition releases +- [v0.50.27](https://github.com/metabase/metabase/releases/tag/v0.50.27) - [v0.50.26](https://github.com/metabase/metabase/releases/tag/v0.50.26) - [v0.50.25](https://github.com/metabase/metabase/releases/tag/v0.50.25) - [v0.50.24](https://github.com/metabase/metabase/releases/tag/v0.50.24) diff --git a/e2e/snapshot-creators/default.cy.snap.js b/e2e/snapshot-creators/default.cy.snap.js index 3ba07cbb2c0fd57df3118d6b2d6257a422805678..1951da5c3d55fbffe26a90b87ad6009b69d8baeb 100644 --- a/e2e/snapshot-creators/default.cy.snap.js +++ b/e2e/snapshot-creators/default.cy.snap.js @@ -7,7 +7,12 @@ import { USERS, USER_GROUPS, } from "e2e/support/cypress_data"; -import { restore, snapshot, withSampleDatabase } from "e2e/support/helpers"; +import { + restore, + snapshot, + updateSetting, + withSampleDatabase, +} from "e2e/support/helpers"; const { STATIC_ORDERS_ID, @@ -85,14 +90,10 @@ describe("snapshots", () => { } function updateSettings() { - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); - cy.request("PUT", "/api/setting/enable-embedding", { value: true }).then( - () => { - cy.request("PUT", "/api/setting/embedding-secret-key", { - value: METABASE_SECRET_KEY, - }); - }, - ); + updateSetting("enable-public-sharing", true); + updateSetting("enable-embedding", true).then(() => { + updateSetting("embedding-secret-key", METABASE_SECRET_KEY); + }); // update the Sample db connection string so it is valid in both CI and locally cy.request("GET", `/api/database/${SAMPLE_DB_ID}`).then(response => { diff --git a/e2e/support/helpers/api/index.ts b/e2e/support/helpers/api/index.ts index a889503db956cf21ec799bd95855fd0341bd105d..47d6073d9dcc105ff94c0e8c3ad74d55302b48bc 100644 --- a/e2e/support/helpers/api/index.ts +++ b/e2e/support/helpers/api/index.ts @@ -24,3 +24,4 @@ export { createTimelineWithEvents } from "./createTimelineWithEvents"; export { getCurrentUser } from "./getCurrentUser"; export { remapDisplayValueToFK } from "./remapDisplayValueToFK"; export { updateDashboardCards } from "./updateDashboardCards"; +export { updateSetting } from "./updateSetting"; diff --git a/e2e/support/helpers/api/updateSetting.ts b/e2e/support/helpers/api/updateSetting.ts new file mode 100644 index 0000000000000000000000000000000000000000..e20773441bb09c0951862cf9400fa4d0260c0a8f --- /dev/null +++ b/e2e/support/helpers/api/updateSetting.ts @@ -0,0 +1,11 @@ +import type { Settings } from "metabase-types/api"; + +export const updateSetting = < + TKey extends keyof Settings, + TValue extends Settings[TKey], +>( + setting: TKey, + value: TValue, +): Cypress.Chainable<Cypress.Response<never>> => { + return cy.request<never>("PUT", `/api/setting/${setting}`, { value }); +}; diff --git a/e2e/support/helpers/e2e-cloud-helpers.js b/e2e/support/helpers/e2e-cloud-helpers.js index 193fb9a47460d06cc8155beb32c10ebf4dab1e60..d90d31bc39dda1c64ae7def0ddb9506adac7956e 100644 --- a/e2e/support/helpers/e2e-cloud-helpers.js +++ b/e2e/support/helpers/e2e-cloud-helpers.js @@ -1,5 +1,5 @@ +import { updateSetting } from "./api"; + export const setupMetabaseCloud = () => { - cy.request("PUT", "/api/setting/site-url", { - value: "https://CYPRESSTESTENVIRONMENT.metabaseapp.com", - }); + updateSetting("site-url", "https://CYPRESSTESTENVIRONMENT.metabaseapp.com"); }; diff --git a/e2e/support/helpers/e2e-embedding-helpers.js b/e2e/support/helpers/e2e-embedding-helpers.js index bcfb853341150b6b77db593672f35cdf1a15a282..c6f03faf7158d380d878caef5e51409bf9acfd4c 100644 --- a/e2e/support/helpers/e2e-embedding-helpers.js +++ b/e2e/support/helpers/e2e-embedding-helpers.js @@ -262,6 +262,12 @@ export function createPublicDashboardLink(dashboardId) { return cy.request("POST", `/api/dashboard/${dashboardId}/public_link`, {}); } +/** + * @param {Object} options + * @param {string} options.url + * @param {Object} options.qs + * @param {Function} [options.onBeforeLoad] + */ export const visitFullAppEmbeddingUrl = ({ url, qs, onBeforeLoad }) => { cy.visit({ url, diff --git a/e2e/support/helpers/e2e-filter-helpers.js b/e2e/support/helpers/e2e-filter-helpers.js index 2d34c01694865a6c3b3bbd59634369278d9465e0..cf89cb8eaa4e709f747f26f403e4cef691e35e28 100644 --- a/e2e/support/helpers/e2e-filter-helpers.js +++ b/e2e/support/helpers/e2e-filter-helpers.js @@ -4,6 +4,8 @@ import { popover, } from "e2e/support/helpers/e2e-ui-elements-helpers"; +import { updateSetting } from "./api"; + export function setDropdownFilterType() { cy.findByText("Dropdown list").click(); } @@ -83,7 +85,5 @@ export function setConnectedFieldSource(table, field) { } export function changeSynchronousBatchUpdateSetting(value) { - cy.request("PUT", "/api/setting/synchronous-batch-updates", { - value: value, - }); + updateSetting("synchronous-batch-updates", value); } diff --git a/e2e/support/helpers/e2e-snowplow-helpers.js b/e2e/support/helpers/e2e-snowplow-helpers.js index 4f193c556e95e29456271422be2e4b380512c4f8..5549444691b879b088d242d8ba15dda58c24d593 100644 --- a/e2e/support/helpers/e2e-snowplow-helpers.js +++ b/e2e/support/helpers/e2e-snowplow-helpers.js @@ -1,4 +1,4 @@ -import { isEE } from "e2e/support/helpers"; +import { isEE, updateSetting } from "e2e/support/helpers"; const HAS_SNOWPLOW = Cypress.env("HAS_SNOWPLOW_MICRO"); const SNOWPLOW_URL = Cypress.env("SNOWPLOW_MICRO_URL"); @@ -10,7 +10,7 @@ export const describeWithSnowplowEE = HAS_SNOWPLOW && isEE ? describe : describe.skip; export const enableTracking = () => { - cy.request("PUT", "/api/setting/anon-tracking-enabled", { value: true }); + updateSetting("anon-tracking-enabled", true); }; export const resetSnowplow = () => { diff --git a/e2e/support/helpers/index.js b/e2e/support/helpers/index.ts similarity index 100% rename from e2e/support/helpers/index.js rename to e2e/support/helpers/index.ts diff --git a/e2e/test/scenarios/admin-2/settings.cy.spec.js b/e2e/test/scenarios/admin-2/settings.cy.spec.js index b2285149b9ba62a42fc2723043b8c6426aaeb489..ed57e240bf4b5201b6b47e7dbfdbf805d7039448 100644 --- a/e2e/test/scenarios/admin-2/settings.cy.spec.js +++ b/e2e/test/scenarios/admin-2/settings.cy.spec.js @@ -28,6 +28,7 @@ import { setupSMTP, tableHeaderClick, undoToast, + updateSetting, visitQuestion, visitQuestionAdhoc, } from "e2e/support/helpers"; @@ -794,9 +795,7 @@ describe("scenarios > admin > license and billing", () => { describe("scenarios > admin > localization", () => { function setFirstWeekDayTo(day) { - cy.request("PUT", "/api/setting/start-of-week", { - value: day.toLowerCase(), - }); + updateSetting("start-of-week", day.toLowerCase()); } beforeEach(() => { diff --git a/e2e/test/scenarios/admin-2/sso/ldap.cy.spec.js b/e2e/test/scenarios/admin-2/sso/ldap.cy.spec.js index d2fe58d4f17553c07a0109216416c193c0993908..30ac40c260162b9d35bb3d20da51c36f4173a186 100644 --- a/e2e/test/scenarios/admin-2/sso/ldap.cy.spec.js +++ b/e2e/test/scenarios/admin-2/sso/ldap.cy.spec.js @@ -6,6 +6,7 @@ import { setTokenFeatures, setupLdap, typeAndBlurUsingLabel, + updateSetting, } from "e2e/support/helpers"; import { @@ -188,7 +189,7 @@ describeEE( it("should show the login form when ldap is enabled but password login isn't (metabase#25661)", () => { setupLdap(); - cy.request("PUT", "/api/setting/enable-password-login", { value: false }); + updateSetting("enable-password-login", false); cy.signOut(); cy.visit("/auth/login"); diff --git a/e2e/test/scenarios/admin-2/whitelabel.cy.spec.js b/e2e/test/scenarios/admin-2/whitelabel.cy.spec.js index 9c79fd898bafc7319c0b06cc67f5dffb510004b4..85a874a896adf9bf8d3f70708ee909bc84c860ac 100644 --- a/e2e/test/scenarios/admin-2/whitelabel.cy.spec.js +++ b/e2e/test/scenarios/admin-2/whitelabel.cy.spec.js @@ -9,6 +9,7 @@ import { restore, setTokenFeatures, undoToast, + updateSetting, visitDashboard, visitQuestion, } from "e2e/support/helpers"; @@ -72,9 +73,10 @@ describeEE("formatting > whitelabel", () => { cy.log("Add a logo"); 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}`, - }); + updateSetting( + "application-logo-url", + `data:image/jpeg;base64,${logo_data}`, + ); }, ); }); @@ -101,9 +103,7 @@ describeEE("formatting > whitelabel", () => { it("should work for people that set favicon URL before we change the input to file input", () => { const faviconUrl = "https://cdn.ecosia.org/assets/images/ico/favicon.ico"; - cy.request("PUT", "/api/setting/application-favicon-url", { - value: faviconUrl, - }); + updateSetting("application-favicon-url", faviconUrl); checkFavicon(faviconUrl); cy.signInAsNormalUser(); cy.visit("/"); @@ -730,9 +730,7 @@ function changeLoadingMessage(message) { } function setApplicationFontTo(font) { - cy.request("PUT", "/api/setting/application-font", { - value: font, - }); + updateSetting("application-font", font); } const openSettingsMenu = () => appBar().icon("gear").click(); diff --git a/e2e/test/scenarios/admin/query-validator.cy.spec.js b/e2e/test/scenarios/admin/query-validator.cy.spec.js index 67d3b3dbf7a308c09e01cb8fedb6d864b4b0e8f8..fb5379d9280b932db472c0c7934e0576f461d224 100644 --- a/e2e/test/scenarios/admin/query-validator.cy.spec.js +++ b/e2e/test/scenarios/admin/query-validator.cy.spec.js @@ -16,130 +16,174 @@ const SCOREBOARD_TABLE = "scoreboard_actions"; const COLORS_TABLE = "colors27745"; describeEE("query validator", { tags: "@external" }, () => { - beforeEach(() => { - restore("postgres-writable"); - cy.signInAsAdmin(); - setTokenFeatures("all"); - }); - - it("picks up inactive and unknown fields and tables", () => { - resetTestTable({ type: "postgres", table: SCOREBOARD_TABLE }); - resetTestTable({ type: "postgres", table: COLORS_TABLE }); - - resyncDatabase({ - dbId: WRITABLE_DB_ID, + describe("feature disbaled", () => { + beforeEach(() => { + restore("postgres-writable"); + cy.signInAsAdmin(); + setTokenFeatures("none"); }); - createNativeQuestion({ - name: "Native inactive field", - native: { query: `Select team_name from ${SCOREBOARD_TABLE}` }, - display: "table", - database: WRITABLE_DB_ID, + it("Should not show the page in troubleshooting or the setting in general", () => { + cy.visit("/admin/troubleshooting"); + cy.findByTestId("admin-layout-sidebar").should( + "not.contain", + "Query Validator", + ); + cy.visit("/admin/settings/general"); + cy.findByRole("switch", { name: /ENABLE QUERY ANALYSIS/i }).should( + "not.exist", + ); }); + }); - createNativeQuestion({ - name: "Native unknown field", - native: { query: `Select foo from ${SCOREBOARD_TABLE}` }, - display: "table", - database: WRITABLE_DB_ID, + describe("feature enabled", () => { + beforeEach(() => { + restore("postgres-writable"); + cy.signInAsAdmin(); + setTokenFeatures("all"); }); - createNativeQuestion({ - name: "Native inactive table", - native: { query: `Select team_name from ${COLORS_TABLE}` }, - display: "table", - database: WRITABLE_DB_ID, - }); + it("enable query analysis setting", () => { + cy.visit("/admin/settings/general"); - createNativeQuestion({ - name: "Native unknown table", - native: { query: "Select * from electric_bugaloo" }, - display: "line", - }); + cy.findByRole("switch", { name: /ENABLE QUERY ANALYSIS/i }).should( + "have.attr", + "checked", + ); + cy.findByRole("switch", { name: /ENABLE QUERY ANALYSIS/i }).click(); - cy.request(`/api/database/${WRITABLE_DB_ID}/schema/public`).then( - ({ body: tables }) => { - const scoreboardTable = tables.find( - table => table.name === SCOREBOARD_TABLE, - ); - - cy.request(`/api/table/${scoreboardTable.id}/query_metadata`).then( - ({ body: { fields } }) => { - const teamNameField = fields.find( - field => field.name === "team_name", - ); - - createQuestion({ - name: "Structured inactive field", - query: { - "source-table": scoreboardTable.id, - fields: [["field", teamNameField.id]], - }, - display: "table", - database: WRITABLE_DB_ID, - }); - }, - ); - }, - ); + cy.findByRole("link", { name: /troubleshooting/i }).click(); - cy.request(`/api/database/${WRITABLE_DB_ID}/schema/public`).then( - ({ body: tables }) => { - const colorsTable = tables.find(table => table.name === COLORS_TABLE); - - cy.request(`/api/table/${colorsTable.id}/query_metadata`).then( - ({ body: { fields } }) => { - const colorNameField = fields.find(field => field.name === "name"); - - createQuestion({ - name: "Structured inactive table", - query: { - "source-table": colorsTable.id, - fields: [["field", colorNameField.id]], - }, - display: "table", - database: WRITABLE_DB_ID, - }); - }, - ); - }, - ); + cy.findByTestId("admin-layout-sidebar") + .findByText("Query Validator") + .click(); - queryWritableDB( - `ALTER TABLE ${SCOREBOARD_TABLE} RENAME COLUMN team_name TO team_name_`, - ); - - queryWritableDB(`DROP TABLE ${COLORS_TABLE}`); - - resyncDatabase({ - dbId: WRITABLE_DB_ID, + cy.findByRole("link", { + name: "Please enable query analysis here.", + }).should("exist"); }); - cy.visit("/admin/troubleshooting/query-validator"); - - cy.findAllByRole("row") - .contains("tr", "Native inactive field") - .and("contain.text", "Field team_name is inactive"); - - cy.findAllByRole("row") - .contains("tr", "Native unknown field") - .and("contain.text", "Field foo is unknown"); - - cy.findAllByRole("row") - .contains("tr", "Structured inactive field") - .and("contain.text", "Field team_name is inactive"); - - cy.findAllByRole("row") - .contains("tr", "Native inactive table") - .and("contain.text", "Table colors27745 is inactive"); - - cy.findAllByRole("row") - .contains("tr", "Native unknown table") - .and("contain.text", "Table electric_bugaloo is unknown"); - - cy.findAllByRole("row") - .contains("tr", "Structured inactive table") - .and("contain.text", "Table colors27745 is inactive"); + it("picks up inactive and unknown fields and tables", () => { + resetTestTable({ type: "postgres", table: SCOREBOARD_TABLE }); + resetTestTable({ type: "postgres", table: COLORS_TABLE }); + + resyncDatabase({ + dbId: WRITABLE_DB_ID, + }); + + createNativeQuestion({ + name: "Native inactive field", + native: { query: `Select team_name from ${SCOREBOARD_TABLE}` }, + display: "table", + database: WRITABLE_DB_ID, + }); + + createNativeQuestion({ + name: "Native unknown field", + native: { query: `Select foo from ${SCOREBOARD_TABLE}` }, + display: "table", + database: WRITABLE_DB_ID, + }); + + createNativeQuestion({ + name: "Native inactive table", + native: { query: `Select team_name from ${COLORS_TABLE}` }, + display: "table", + database: WRITABLE_DB_ID, + }); + + createNativeQuestion({ + name: "Native unknown table", + native: { query: "Select * from electric_bugaloo" }, + display: "line", + }); + + cy.request(`/api/database/${WRITABLE_DB_ID}/schema/public`).then( + ({ body: tables }) => { + const scoreboardTable = tables.find( + table => table.name === SCOREBOARD_TABLE, + ); + + cy.request(`/api/table/${scoreboardTable.id}/query_metadata`).then( + ({ body: { fields } }) => { + const teamNameField = fields.find( + field => field.name === "team_name", + ); + + createQuestion({ + name: "Structured inactive field", + query: { + "source-table": scoreboardTable.id, + fields: [["field", teamNameField.id]], + }, + display: "table", + database: WRITABLE_DB_ID, + }); + }, + ); + }, + ); + + cy.request(`/api/database/${WRITABLE_DB_ID}/schema/public`).then( + ({ body: tables }) => { + const colorsTable = tables.find(table => table.name === COLORS_TABLE); + + cy.request(`/api/table/${colorsTable.id}/query_metadata`).then( + ({ body: { fields } }) => { + const colorNameField = fields.find( + field => field.name === "name", + ); + + createQuestion({ + name: "Structured inactive table", + query: { + "source-table": colorsTable.id, + fields: [["field", colorNameField.id]], + }, + display: "table", + database: WRITABLE_DB_ID, + }); + }, + ); + }, + ); + + queryWritableDB( + `ALTER TABLE ${SCOREBOARD_TABLE} RENAME COLUMN team_name TO team_name_`, + ); + + queryWritableDB(`DROP TABLE ${COLORS_TABLE}`); + + resyncDatabase({ + dbId: WRITABLE_DB_ID, + }); + + cy.visit("/admin/troubleshooting/query-validator"); + + cy.findAllByRole("row") + .contains("tr", "Native inactive field") + .and("contain.text", "Field team_name is inactive"); + + cy.findAllByRole("row") + .contains("tr", "Native unknown field") + .and("contain.text", "Field foo is unknown"); + + cy.findAllByRole("row") + .contains("tr", "Structured inactive field") + .and("contain.text", "Field team_name is inactive"); + + cy.findAllByRole("row") + .contains("tr", "Native inactive table") + .and("contain.text", "Table colors27745 is inactive"); + + cy.findAllByRole("row") + .contains("tr", "Native unknown table") + .and("contain.text", "Table electric_bugaloo is unknown"); + + cy.findAllByRole("row") + .contains("tr", "Structured inactive table") + .and("contain.text", "Table colors27745 is inactive"); + }); }); }); @@ -156,5 +200,9 @@ describe("OSS", { tags: "@OSS" }, () => { "not.contain", "Query Validator", ); + cy.visit("/admin/settings/general"); + cy.findByRole("switch", { name: /ENABLE QUERY ANALYSIS/i }).should( + "not.exist", + ); }); }); diff --git a/e2e/test/scenarios/binning/binning-reproductions.cy.spec.js b/e2e/test/scenarios/binning/binning-reproductions.cy.spec.js index fe880333dae6422af5e9b358728cf5c3946e2242..56ead259777297c6b983aa7e9e96825c6de1958a 100644 --- a/e2e/test/scenarios/binning/binning-reproductions.cy.spec.js +++ b/e2e/test/scenarios/binning/binning-reproductions.cy.spec.js @@ -48,7 +48,7 @@ describe("binning related reproductions", () => { // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText(/CREATED_AT/i).realHover(); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("by day").click({ force: true }); + cy.findByText("by month").click({ force: true }); // Implicit assertion - it fails if there is more than one instance of the string, which is exactly what we need for this repro // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage diff --git a/e2e/test/scenarios/collections/cleanup.cy.spec.js b/e2e/test/scenarios/collections/cleanup.cy.spec.js index 9e5cc65a0de6c0457b7fe73f26fbcd6d485fe1fc..b92cfdce420a9c4c2bcffc785e337c305df3d666 100644 --- a/e2e/test/scenarios/collections/cleanup.cy.spec.js +++ b/e2e/test/scenarios/collections/cleanup.cy.spec.js @@ -44,7 +44,7 @@ describe("scenarios > collections > clean up", () => { cy.log("should not show in custom analytics collections"); visitCollection("root"); navigationSidebar().within(() => { - cy.findByText("Metabase analytics").click(); + cy.findByText("Usage analytics").click(); cy.findByText("Custom reports").click(); }); collectionMenu().click(); diff --git a/e2e/test/scenarios/collections/instance-analytics.cy.spec.js b/e2e/test/scenarios/collections/instance-analytics.cy.spec.js index a41b9f4a771494fda9e591420c83fd2925552cd0..27a1d04bd362e31c8b4b85349719df12e48a583c 100644 --- a/e2e/test/scenarios/collections/instance-analytics.cy.spec.js +++ b/e2e/test/scenarios/collections/instance-analytics.cy.spec.js @@ -18,7 +18,7 @@ import { visitQuestion, } from "e2e/support/helpers"; -const ANALYTICS_COLLECTION_NAME = "Metabase analytics"; +const ANALYTICS_COLLECTION_NAME = "Usage analytics"; const CUSTOM_REPORTS_COLLECTION_NAME = "Custom reports"; const PEOPLE_MODEL_NAME = "People"; const METRICS_DASHBOARD_NAME = "Metabase metrics"; diff --git a/e2e/test/scenarios/collections/permissions.cy.spec.js b/e2e/test/scenarios/collections/permissions.cy.spec.js index 42eadbb6a8ccd6074df4b42995064951e4dcd198..42282b71c615034a572782c91c7caa513f481c96 100644 --- a/e2e/test/scenarios/collections/permissions.cy.spec.js +++ b/e2e/test/scenarios/collections/permissions.cy.spec.js @@ -417,9 +417,9 @@ describe("collection permissions", () => { ); cy.findByTestId("permission-table"); - sidebar().findByText("Metabase analytics").click(); + sidebar().findByText("Usage analytics").click(); cy.findByTestId("permissions-editor").findByText( - "Permissions for Metabase analytics", + "Permissions for Usage analytics", ); cy.findByTestId("permission-table"); }); diff --git a/e2e/test/scenarios/custom-column/custom-column.cy.spec.js b/e2e/test/scenarios/custom-column/custom-column.cy.spec.js index 85207891ff577e121642947c32df2361bd693abe..6efec2cecd056740a341690ef6d75d4cf7b4f590 100644 --- a/e2e/test/scenarios/custom-column/custom-column.cy.spec.js +++ b/e2e/test/scenarios/custom-column/custom-column.cy.spec.js @@ -146,7 +146,7 @@ describe("scenarios > question > custom column", () => { .click(); getNotebookStep("summarize") - .findByText("Product Date: Day") + .findByText("Product Date: Month") .should("be.visible"); }); diff --git a/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js b/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js index 87a6401d13077464830aa29ebacb6dc8ff7d181d..5b19e0856d9a887696f744bbe5b6e4abcbe150fa 100644 --- a/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js +++ b/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js @@ -28,6 +28,7 @@ import { saveDashboard, setTokenFeatures, updateDashboardCards, + updateSetting, visitDashboard, visitEmbeddedPage, visitIframe, @@ -1807,9 +1808,7 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => { }); it("allows opening custom URL destination that is not a Metabase instance URL using link (metabase#33379)", () => { - cy.request("PUT", "/api/setting/site-url", { - value: "https://localhost:4000/subpath", - }); + updateSetting("site-url", "https://localhost:4000/subpath"); const dashboardDetails = { enable_embedding: true, }; diff --git a/e2e/test/scenarios/dashboard-cards/dashboard-card-reproductions.cy.spec.js b/e2e/test/scenarios/dashboard-cards/dashboard-card-reproductions.cy.spec.js index e7c3680cf3f90f4b2213feab2a195f7c7016b0c1..59b27a553a8d04264b53c66907fa64b8d42b269b 100644 --- a/e2e/test/scenarios/dashboard-cards/dashboard-card-reproductions.cy.spec.js +++ b/e2e/test/scenarios/dashboard-cards/dashboard-card-reproductions.cy.spec.js @@ -21,6 +21,7 @@ import { saveDashboard, showDashboardCardActions, sidebar, + updateSetting, visitDashboard, } from "e2e/support/helpers"; import { createMockParameter } from "metabase-types/api/mocks"; @@ -869,11 +870,9 @@ describe("issues 27020 and 27105: static-viz fails to render for certain date fo // This is currently the default setting, anyway. // But we want to explicitly set it in case something changes in the future, // because it is a crucial step for this reproduction. - cy.request("PUT", "/api/setting/custom-formatting", { - value: { - "type/Temporal": { - date_style: "MMMM D, YYYY", - }, + updateSetting("custom-formatting", { + "type/Temporal": { + date_style: "MMMM D, YYYY", }, }); diff --git a/e2e/test/scenarios/dashboard-filters/dashboard-filter-defaults.cy.spec.ts b/e2e/test/scenarios/dashboard-filters/dashboard-filter-defaults.cy.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e4a087566079c8e1690ea59bd86d6158d60685a --- /dev/null +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filter-defaults.cy.spec.ts @@ -0,0 +1,192 @@ +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { + type StructuredQuestionDetails, + clearFilterWidget, + createQuestionAndDashboard, + editDashboard, + filterWidget, + popover, + restore, + saveDashboard, + sidebar, + visitDashboard, +} from "e2e/support/helpers"; + +const { PRODUCTS_ID, PRODUCTS } = SAMPLE_DATABASE; + +const QUESTION: StructuredQuestionDetails = { + name: "Return input value", + display: "scalar", + query: { + "source-table": PRODUCTS_ID, + }, +}; + +const FILTER_ONE = { + name: "Filter One", + slug: "filter_one", + id: "904aa8b7", + type: "string/=", + sectionId: "string", + default: undefined, +}; + +const FILTER_TWO = { + name: "Filter Two", + slug: "filter_two", + id: "904aa8b8", + type: "string/=", + sectionId: "string", + default: "Bar", +}; + +const DASHBOARD = { + name: "Filters Dashboard", + parameters: [FILTER_ONE, FILTER_TWO], +}; + +describe("scenarios > dashboard > filters > reset", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + it("should reset a filters value when editing the default", () => { + createQuestionAndDashboard({ + questionDetails: QUESTION, + dashboardDetails: DASHBOARD, + }).then(({ body: dashboardCard }) => { + const { card_id, dashboard_id } = dashboardCard; + + cy.editDashboardCard(dashboardCard, { + parameter_mappings: [ + { + parameter_id: FILTER_ONE.id, + card_id, + target: ["dimension", ["field", PRODUCTS.CATEGORY, null]], + }, + { + parameter_id: FILTER_TWO.id, + card_id, + target: ["dimension", ["field", PRODUCTS.TITLE, null]], + }, + ], + }).then(() => { + visitDashboard(dashboard_id, { + params: { + filter_one: "", + filter_two: "Bar", + }, + }); + }); + }); + + cy.log("Default dashboard filter"); + + filterWidget().contains("Filter One").should("be.visible"); + filterWidget().contains("Bar").should("be.visible"); + + cy.location("search").should("eq", "?filter_one=&filter_two=Bar"); + + clearFilterWidget(1); + + cy.location("search").should("eq", "?filter_one=&filter_two="); + + cy.log( + "Finally, when we remove dashboard filter's default value, the url should reflect that by removing the placeholder", + ); + editDashboard(); + + openFilterOptions("Filter Two"); + + sidebar().within(() => { + cy.findByLabelText("Input box").click(); + clearDefaultFilterValue(); + setDefaultFilterValue("Foo"); + }); + + popover().button("Add filter").click(); + + cy.location("search").should("eq", "?filter_one=&filter_two=Foo"); + + saveDashboard(); + + cy.location("search").should("eq", "?filter_one=&filter_two=Foo"); + + filterWidget().contains("Filter One").should("be.visible"); + filterWidget().contains("Foo").should("be.visible"); + }); + + it("should reset a filters value when editing the default, and leave other filters alone", () => { + createQuestionAndDashboard({ + questionDetails: QUESTION, + dashboardDetails: DASHBOARD, + }).then(({ body: dashboardCard }) => { + const { card_id, dashboard_id } = dashboardCard; + + cy.editDashboardCard(dashboardCard, { + parameter_mappings: [ + { + parameter_id: FILTER_ONE.id, + card_id, + target: ["dimension", ["field", PRODUCTS.CATEGORY, null]], + }, + { + parameter_id: FILTER_TWO.id, + card_id, + target: ["dimension", ["field", PRODUCTS.TITLE, null]], + }, + ], + }).then(() => { + visitDashboard(dashboard_id, { + params: { + filter_one: "", + filter_two: "Bar", + }, + }); + }); + }); + + cy.log("Default dashboard filter"); + + filterWidget().contains("Filter One").should("be.visible"); + filterWidget().contains("Bar").should("be.visible"); + + cy.location("search").should("eq", "?filter_one=&filter_two=Bar"); + + cy.log( + "Finally, when we remove dashboard filter's default value, the url should reflect that by removing the placeholder", + ); + editDashboard(); + + openFilterOptions("Filter One"); + + sidebar().within(() => { + cy.findByLabelText("Input box").click(); + setDefaultFilterValue("Foo"); + }); + + popover().button("Add filter").click(); + + cy.location("search").should("eq", "?filter_one=Foo&filter_two=Bar"); + + saveDashboard(); + + cy.location("search").should("eq", "?filter_one=Foo&filter_two=Bar"); + + filterWidget().contains("Filter One").should("be.visible"); + filterWidget().contains("Foo").should("be.visible"); + }); +}); + +function openFilterOptions(name: string) { + cy.findByText(name).parent().icon("gear").click(); +} + +function clearDefaultFilterValue() { + cy.findByLabelText("No default").parent().icon("close").click(); +} + +function setDefaultFilterValue(value: string) { + cy.findByLabelText("No default").type(value); +} diff --git a/e2e/test/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 index 6882888205911096986aabd8fc57183737ca79a3..7b1b1285a20c660d7aa4318a538ee88485d9e3b5 100644 --- a/e2e/test/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 @@ -113,8 +113,8 @@ describe("scenarios > dashboard > filters > SQL > simple filter > required ", () saveDashboard(); - // The URL query params should include the last used parameter value - cy.location("search").should("eq", "?text=Bar"); + // The URL query params should include the value from the dashboard filter default + cy.location("search").should("eq", "?text="); }); }); diff --git a/e2e/test/scenarios/dashboard-filters/temporal-unit-parameters.cy.spec.js b/e2e/test/scenarios/dashboard-filters/temporal-unit-parameters.cy.spec.js index 2d126c889cb0f365a27daddfeae74cda4eb1a093..402960790cce30bc0547dd2dc2d91b2bc1e2d788 100644 --- a/e2e/test/scenarios/dashboard-filters/temporal-unit-parameters.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/temporal-unit-parameters.cy.spec.js @@ -24,6 +24,7 @@ import { undoToast, undoToastList, updateDashboardCards, + updateSetting, visitDashboard, visitEmbeddedPage, } from "e2e/support/helpers"; @@ -998,7 +999,7 @@ describe("scenarios > dashboard > temporal unit parameters", () => { describe("embedding", () => { beforeEach(() => { cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + updateSetting("enable-public-sharing", true); }); it("should be able to use temporal unit parameters in a public dashboard", () => { diff --git a/e2e/test/scenarios/dashboard/tabs.cy.spec.js b/e2e/test/scenarios/dashboard/tabs.cy.spec.js index 8ae991d740b5096fb387f3e3b300ee2a2bf48189..f347df34d2a242376c8572847fc42ddeacd22d11 100644 --- a/e2e/test/scenarios/dashboard/tabs.cy.spec.js +++ b/e2e/test/scenarios/dashboard/tabs.cy.spec.js @@ -46,6 +46,7 @@ import { sidebar, undo, updateDashboardCards, + updateSetting, visitCollection, visitDashboard, visitDashboardAndCreateTab, @@ -510,7 +511,7 @@ describe("scenarios > dashboard > tabs", () => { }); // Go to public dashboard - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + updateSetting("enable-public-sharing", true); cy.request( "POST", `/api/dashboard/${ORDERS_DASHBOARD_ID}/public_link`, diff --git a/e2e/test/scenarios/dashboard/text-cards.cy.spec.js b/e2e/test/scenarios/dashboard/text-cards.cy.spec.js index 09df1831895d3221985a5e368e5af7597268e103..04632394dda92c30d47c03186aea60718a7e5cbb 100644 --- a/e2e/test/scenarios/dashboard/text-cards.cy.spec.js +++ b/e2e/test/scenarios/dashboard/text-cards.cy.spec.js @@ -18,6 +18,7 @@ import { saveDashboard, selectDashboardFilter, setFilter, + updateSetting, visitDashboard, } from "e2e/support/helpers"; import { createMockParameter } from "metabase-types/api/mocks"; @@ -342,7 +343,7 @@ describe("scenarios > dashboard > parameters in text and heading cards", () => { cy.request("GET", "/api/user/current").then(({ body: { id: USER_ID } }) => { cy.request("PUT", `/api/user/${USER_ID}`, { locale: "en" }); }); - cy.request("PUT", "/api/setting/site-locale", { value: "fr" }); + updateSetting("site-locale", "fr"); cy.reload(); editDashboard(); @@ -382,7 +383,7 @@ describe("scenarios > dashboard > parameters in text and heading cards", () => { cy.request("GET", "/api/user/current").then(({ body: { id: USER_ID } }) => { cy.request("PUT", `/api/user/${USER_ID}`, { locale: "en" }); }); - cy.request("PUT", "/api/setting/site-locale", { value: "fr" }); + updateSetting("site-locale", "fr"); // Create dashboard with a single date parameter, and a single question cy.createQuestionAndDashboard({ diff --git a/e2e/test/scenarios/embedding-sdk/metabase-sdk-styles-tests.cy.spec.ts b/e2e/test/scenarios/embedding-sdk/metabase-sdk-styles-tests.cy.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c76f6ecda869a206ddaf253434f8ef4730ef0123 --- /dev/null +++ b/e2e/test/scenarios/embedding-sdk/metabase-sdk-styles-tests.cy.spec.ts @@ -0,0 +1,212 @@ +/* eslint-disable no-unscoped-text-selectors */ +import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data"; +import { + restore, + setTokenFeatures, + updateSetting, + visitFullAppEmbeddingUrl, +} from "e2e/support/helpers"; +import { + EMBEDDING_SDK_STORY_HOST, + describeSDK, +} from "e2e/support/helpers/e2e-embedding-sdk-helpers"; +import { + JWT_SHARED_SECRET, + setupJwt, +} from "e2e/support/helpers/e2e-jwt-helpers"; + +const STORIES = { + NO_STYLES_SUCCESS: "embeddingsdk-styles-tests--no-styles-success", + NO_STYLES_ERROR: "embeddingsdk-styles-tests--no-styles-error", + FONT_FROM_CONFIG: "embeddingsdk-styles-tests--font-from-config", + GET_BROWSER_DEFAUL_FONT: + "embeddingsdk-styles-tests--get-browser-default-font", +} as const; + +describeSDK("scenarios > embedding-sdk > static-dashboard", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setTokenFeatures("all"); + setupJwt(); + cy.signOut(); + }); + + const wrapBrowserDefaultFont = () => { + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.GET_BROWSER_DEFAUL_FONT, + viewMode: "story", + }, + }); + + cy.findByText("paragraph with default browser font").then($element => { + const fontFamily = $element.css("font-family"); + cy.wrap(fontFamily).as("defaultBrowserFonteFamily"); + }); + }; + + describe("style leaking", () => { + it("[success scenario] should use the default fonts outside of our components, and Lato on our components", () => { + wrapBrowserDefaultFont(); + + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.NO_STYLES_SUCCESS, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + cy.get("@defaultBrowserFonteFamily").then(defaultBrowserFonteFamily => { + cy.findByText("This is outside of the provider").should( + "have.css", + "font-family", + defaultBrowserFonteFamily, + ); + cy.findByText("This is inside of the provider").should( + "have.css", + "font-family", + defaultBrowserFonteFamily, + ); + cy.findByText("Product ID").should( + "have.css", + "font-family", + "Lato, sans-serif", + ); + }); + }); + + it("[error scenario] should use the default fonts outside of our components, and Lato on our components", () => { + wrapBrowserDefaultFont(); + + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.NO_STYLES_ERROR, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + cy.get("@defaultBrowserFonteFamily").then(defaultBrowserFonteFamily => { + cy.findByText("This is outside of the provider").should( + "have.css", + "font-family", + defaultBrowserFonteFamily, + ); + + cy.findByText("This is inside of the provider").should( + "have.css", + "font-family", + defaultBrowserFonteFamily, + ); + + cy.findByText( + "Could not authenticate: invalid JWT URI or JWT provider did not return a valid JWT token", + ).should("have.css", "font-family", "Lato, sans-serif"); + }); + }); + }); + + describe("fontFamily", () => { + it("should use the font from the theme if set", () => { + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.FONT_FROM_CONFIG, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + cy.findByText("Product ID").should( + "have.css", + "font-family", + "Impact, sans-serif", + ); + }); + + it("should fallback to the font from the instance if no fontFamily is set on the theme", () => { + cy.signInAsAdmin(); + updateSetting("application-font", "Roboto Mono"); + cy.signOut(); + + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.NO_STYLES_SUCCESS, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + cy.findByText("Product ID").should( + "have.css", + "font-family", + '"Roboto Mono", sans-serif', + ); + }); + + it("should work with 'Custom' fontFamily, using the font files linked in the instance", () => { + cy.signInAsAdmin(); + + const fontUrl = + Cypress.config().baseUrl + + "/app/fonts/Open_Sans/OpenSans-Regular.woff2"; + // setting `application-font-files` will make getFont return "Custom" + updateSetting("application-font-files", [ + { + src: fontUrl, + fontWeight: 400, + fontFormat: "woff2", + }, + ]); + + cy.signOut(); + + cy.intercept("GET", fontUrl).as("fontFile"); + + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.NO_STYLES_SUCCESS, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + // this test only tests if the file is loaded, not really if it is rendered + // we'll probably need visual regression tests for that + cy.wait("@fontFile"); + + cy.findByText("Product ID").should( + "have.css", + "font-family", + "Custom, sans-serif", + ); + }); + }); +}); diff --git a/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js index 677962253bb90d8ffca777f0b3b0b70eabe22a7e..26f23a7bd5d59192b243df342bc42a139a5cee58 100644 --- a/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js @@ -10,6 +10,7 @@ import { restore, sharingMenu, sharingMenuButton, + updateSetting, visitDashboard, visitIframe, visitQuestion, @@ -310,10 +311,8 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); function resetEmbedding() { - cy.request("PUT", "/api/setting/enable-embedding", { value: false }); - cy.request("PUT", "/api/setting/embedding-secret-key", { - value: null, - }); + updateSetting("enable-embedding", false); + updateSetting("embedding-secret-key", null); } function getTokenValue() { diff --git a/e2e/test/scenarios/embedding/interactive-embedding.cy.spec.js b/e2e/test/scenarios/embedding/interactive-embedding.cy.spec.js index 9d14348d3e5d9438ef92f4f40e4115f9f8302b5e..3c37c277ff8fb032a1e0808a179e5b6d37937f63 100644 --- a/e2e/test/scenarios/embedding/interactive-embedding.cy.spec.js +++ b/e2e/test/scenarios/embedding/interactive-embedding.cy.spec.js @@ -532,7 +532,7 @@ describeEE("scenarios > embedding > full app", () => { cy.get("@postMessage").invoke("resetHistory"); cy.findByTestId("app-bar").findByText("Our analytics").click(); - cy.findByRole("heading", { name: "Metabase analytics" }).should( + cy.findByRole("heading", { name: "Usage analytics" }).should( "be.visible", ); cy.get("@postMessage").should("have.been.calledWith", { diff --git a/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js b/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js index d1606a97af1bbe99895cde054acd7efcb0e0cb86..a8206e1f9030e4c069c3fea9d9f73763bb42819e 100644 --- a/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js +++ b/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js @@ -13,6 +13,7 @@ import { saveDashboard, setFilter, snapshot, + updateSetting, visitDashboard, visitEmbeddedPage, visitQuestion, @@ -221,9 +222,7 @@ describe("issue 35954", () => { }); // Discard the legalese modal so we don't need to do an extra click in the UI - cy.request("PUT", "/api/setting/show-static-embed-terms", { - value: false, - }); + updateSetting("show-static-embed-terms", false); visitDashboard(id); openSharingMenu("Embed"); diff --git a/e2e/test/scenarios/models/reproductions.cy.spec.ts b/e2e/test/scenarios/models/reproductions.cy.spec.ts index d4f30b7635dde4df46e30fa05de88cd2b1dc4a34..ac217311ab4fb1b51cc97bbe6eea1681f14ba495 100644 --- a/e2e/test/scenarios/models/reproductions.cy.spec.ts +++ b/e2e/test/scenarios/models/reproductions.cy.spec.ts @@ -914,7 +914,7 @@ describeEE("issue 43088", () => { it("should be able to create ad-hoc questions based on instance analytics models (metabase#43088)", () => { cy.visit("/"); - navigationSidebar().findByText("Metabase analytics").click(); + navigationSidebar().findByText("Usage analytics").click(); getPinnedSection().findByText("People").scrollIntoView().click(); cy.wait("@dataset"); summarize(); diff --git a/e2e/test/scenarios/native-filters/sql-filters-source.cy.spec.js b/e2e/test/scenarios/native-filters/sql-filters-source.cy.spec.js index d3d87e7cab1db96bb82dfb2f5ff3968c2a209ff8..2131f1e3e9f72c08b8c2def68c0e5e60d982db72 100644 --- a/e2e/test/scenarios/native-filters/sql-filters-source.cy.spec.js +++ b/e2e/test/scenarios/native-filters/sql-filters-source.cy.spec.js @@ -16,6 +16,7 @@ import { setFilterQuestionSource, setSearchBoxFilterType, setTokenFeatures, + updateSetting, visitEmbeddedPage, visitPublicQuestion, visitQuestion, @@ -48,7 +49,7 @@ describe("scenarios > filters > sql filters > values source", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + updateSetting("enable-public-sharing", true); cy.intercept("POST", "/api/dataset").as("dataset"); cy.intercept("GET", "/api/session/properties").as("sessionProperties"); cy.intercept("PUT", "/api/card/*").as("updateQuestion"); @@ -616,7 +617,7 @@ describe("scenarios > filters > sql filters > values source > number parameter", beforeEach(() => { restore(); cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + updateSetting("enable-public-sharing", true); cy.intercept("POST", "/api/dataset").as("dataset"); cy.intercept("GET", "/api/session/properties").as("sessionProperties"); cy.intercept("PUT", "/api/card/*").as("updateQuestion"); diff --git a/e2e/test/scenarios/native/native-database-source.cy.spec.js b/e2e/test/scenarios/native/native-database-source.cy.spec.js index 79ce6b64a036e2d348780cd4f03ed1bb3a0c5972..5a1af0f75612de54ecac9c0548ce9452bd5d41fe 100644 --- a/e2e/test/scenarios/native/native-database-source.cy.spec.js +++ b/e2e/test/scenarios/native/native-database-source.cy.spec.js @@ -6,6 +6,7 @@ import { popover, restore, setTokenFeatures, + updateSetting, } from "e2e/support/helpers"; const PG_DB_ID = 2; @@ -107,9 +108,7 @@ describe( }); it("should not update the setting when the same database is selected again", () => { - cy.request("PUT", "/api/setting/last-used-native-database-id", { - value: SAMPLE_DB_ID, - }); + updateSetting("last-used-native-database-id", SAMPLE_DB_ID); startNativeQuestion(); cy.findByTestId("selected-database") diff --git a/e2e/test/scenarios/native/native-reproductions.cy.spec.js b/e2e/test/scenarios/native/native-reproductions.cy.spec.js index f89ec1ba6555e50563d784e2043e66c97cf8915d..d94aecc305a57474517cfef2539975a5e2d45394 100644 --- a/e2e/test/scenarios/native/native-reproductions.cy.spec.js +++ b/e2e/test/scenarios/native/native-reproductions.cy.spec.js @@ -13,6 +13,7 @@ import { runNativeQuery, sidebar, startNewNativeModel, + updateSetting, visitQuestion, visitQuestionAdhoc, } from "e2e/support/helpers"; @@ -358,9 +359,7 @@ describe("issue 20625", { tags: "@quarantine" }, () => { beforeEach(() => { restore(); cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/native-query-autocomplete-match-style", { - value: "prefix", - }); + updateSetting("native-query-autocomplete-match-style", "prefix"); cy.signInAsNormalUser(); cy.intercept("GET", "/api/database/*/autocomplete_suggestions**").as( "autocomplete", diff --git a/e2e/test/scenarios/native/native.cy.spec.js b/e2e/test/scenarios/native/native.cy.spec.js index eba547cc53d0b7ae4ba1828bb5107d4e22f09656..09360b37e78a007e6f56d829eb1b20d6ba744d65 100644 --- a/e2e/test/scenarios/native/native.cy.spec.js +++ b/e2e/test/scenarios/native/native.cy.spec.js @@ -14,6 +14,7 @@ import { restore, rightSidebar, summarize, + updateSetting, visitCollection, visitQuestionAdhoc, } from "e2e/support/helpers"; @@ -297,7 +298,7 @@ describe("scenarios > question > native", () => { beforeEach(() => { cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/is-metabot-enabled", { value: true }); + updateSetting("is-metabot-enabled", true); cy.intercept( "POST", "/api/metabot/database/**/query", diff --git a/e2e/test/scenarios/navigation/navbar.cy.spec.js b/e2e/test/scenarios/navigation/navbar.cy.spec.js index 017779151b8b1c64a60653ad1a1a24e766108c6f..5f61690c9f50b58c5203fadb634abf703088fe3b 100644 --- a/e2e/test/scenarios/navigation/navbar.cy.spec.js +++ b/e2e/test/scenarios/navigation/navbar.cy.spec.js @@ -6,6 +6,7 @@ import { popover, restore, setTokenFeatures, + updateSetting, visitDashboard, } from "e2e/support/helpers"; @@ -71,10 +72,8 @@ describe("scenarios > navigation > navbar", () => { it("should be open when visiting home with a custom home page configured", () => { cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/custom-homepage", { value: true }); - cy.request("PUT", "/api/setting/custom-homepage-dashboard", { - value: ORDERS_DASHBOARD_ID, - }); + updateSetting("custom-homepage", true); + updateSetting("custom-homepage-dashboard", ORDERS_DASHBOARD_ID); cy.visit("/"); cy.reload(); cy.url().should("contain", "question"); @@ -83,10 +82,8 @@ describe("scenarios > navigation > navbar", () => { it("should preserve state when clicking the mb logo and a custom home page is configured", () => { cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/custom-homepage", { value: true }); - cy.request("PUT", "/api/setting/custom-homepage-dashboard", { - value: ORDERS_DASHBOARD_ID, - }); + updateSetting("custom-homepage", true); + updateSetting("custom-homepage-dashboard", ORDERS_DASHBOARD_ID); visitDashboard(ORDERS_DASHBOARD_ID); cy.findByTestId("main-logo-link").click(); navigationSidebar().should("not.be.visible"); @@ -101,18 +98,14 @@ describe("scenarios > navigation > navbar", () => { }); it("should be open when logging in with a landing page configured", () => { - cy.request("PUT", "/api/setting/landing-page", { - value: "/question/76", - }); + updateSetting("landing-page", "/question/76"); cy.visit("/"); cy.url().should("contain", "question"); navigationSidebar().should("be.visible"); }); it("should preserve state when clicking the mb logo and landing page is configured", () => { - cy.request("PUT", "/api/setting/landing-page", { - value: "/question/76", - }); + updateSetting("landing-page", "/question/76"); visitDashboard(ORDERS_DASHBOARD_ID); cy.findByTestId("main-logo-link").click(); navigationSidebar().should("not.be.visible"); diff --git a/e2e/test/scenarios/onboarding/command-palette.cy.spec.js b/e2e/test/scenarios/onboarding/command-palette.cy.spec.js index 9fe27636540195e41b10dc2e154cdcd299afc63f..335a6dcefea9c88432f292da0d2cf4dc3c3970ff 100644 --- a/e2e/test/scenarios/onboarding/command-palette.cy.spec.js +++ b/e2e/test/scenarios/onboarding/command-palette.cy.spec.js @@ -49,6 +49,7 @@ describe("command palette", () => { cy.findByText("New dashboard"); cy.findByText("New collection"); cy.findByText("New model"); + cy.findByText("New metric").should("not.exist"); cy.log("Should show recent items"); cy.findByRole("option", { name: "Orders in a dashboard" }).should( @@ -94,6 +95,9 @@ describe("command palette", () => { cy.findByRole("option", { name: "REVIEWS" }).should("exist"); cy.findByRole("option", { name: "PRODUCTS" }).should("exist"); commandPaletteInput().clear(); + + commandPaletteInput().clear().type("New met"); + cy.findByText("New metric").should("exist"); }); cy.log("We can close the command palette using escape"); @@ -208,4 +212,16 @@ describe("command palette", () => { cy.visit("/"); commandPaletteButton().should("not.contain.text", "search"); }); + + it("Should have a new metric item", () => { + cy.visit("/"); + cy.findByRole("button", { name: /Search/ }).click(); + + commandPalette().within(() => { + commandPaletteInput().should("exist").type("Me"); + cy.findByText("New metric").should("be.visible").click(); + + cy.location("pathname").should("eq", "/metric/query"); + }); + }); }); diff --git a/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts b/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts index 3f0be43433c3505991b190710454d78fe9c9f371..008b853d894a089239b238250cfb32fd2001e04a 100644 --- a/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts +++ b/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts @@ -15,6 +15,11 @@ import { const { PRODUCTS_ID } = SAMPLE_DATABASE; +const filterButton = () => + cy + .findByTestId("browse-models-header") + .findByRole("button", { name: /Filters/i }); + describeWithSnowplow("scenarios > browse", () => { beforeEach(() => { resetSnowplow(); @@ -100,7 +105,7 @@ describeWithSnowplow("scenarios > browse", () => { it("on an open-source instance, the Browse models page has no controls for setting filters", () => { cy.visit("/"); navigationSidebar().findByLabelText("Browse models").click(); - cy.findByRole("button", { name: /filter icon/i }).should("not.exist"); + filterButton().should("not.exist"); cy.findByRole("switch", { name: /Show verified models only/ }).should( "not.exist", ); @@ -119,8 +124,7 @@ describeWithSnowplowEE("scenarios > browse (EE)", () => { ); cy.intercept("POST", "/api/moderation-review").as("updateVerification"); }); - const openFilterPopover = () => - cy.findByRole("button", { name: /filter icon/i }).click(); + const openFilterPopover = () => filterButton().click(); const toggle = () => cy.findByRole("switch", { name: /Show verified models only/ }); @@ -132,10 +136,6 @@ describeWithSnowplowEE("scenarios > browse (EE)", () => { const recentModel2 = () => recentsGrid().findByText("Model 2"); const model1Row = () => modelsTable().findByRole("row", { name: /Model 1/i }); const model2Row = () => modelsTable().findByRole("row", { name: /Model 2/i }); - const filterButton = () => - cy - .findByTestId("browse-models-header") - .findByRole("button", { name: /filter icon/i }); const setVerification = (linkSelector: RegExp | string) => { cy.findByLabelText("Move, trash, and more...").click(); diff --git a/e2e/test/scenarios/onboarding/home/homepage.cy.spec.js b/e2e/test/scenarios/onboarding/home/homepage.cy.spec.js index eabb54a580f19abd54bcbff79feb2ee78f4bb72e..20d7f99536cb5c757020f253100213811ab62199 100644 --- a/e2e/test/scenarios/onboarding/home/homepage.cy.spec.js +++ b/e2e/test/scenarios/onboarding/home/homepage.cy.spec.js @@ -24,6 +24,7 @@ import { restore, setTokenFeatures, undoToast, + updateSetting, visitDashboard, visitQuestion, } from "e2e/support/helpers"; @@ -384,10 +385,8 @@ describe("scenarios > home > custom homepage", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/custom-homepage", { value: true }); - cy.request("PUT", "/api/setting/custom-homepage-dashboard", { - value: ORDERS_DASHBOARD_ID, - }); + updateSetting("custom-homepage", true); + updateSetting("custom-homepage-dashboard", ORDERS_DASHBOARD_ID); }); it("should not flash the homescreen before redirecting (#37089)", () => { diff --git a/e2e/test/scenarios/onboarding/metabot.cy.spec.js b/e2e/test/scenarios/onboarding/metabot.cy.spec.js index 8b441eed52236ab0287eb5d07de5f315fbad8cd0..aa19acb52c51c7adba67fa4d51361eec93a735e8 100644 --- a/e2e/test/scenarios/onboarding/metabot.cy.spec.js +++ b/e2e/test/scenarios/onboarding/metabot.cy.spec.js @@ -11,6 +11,7 @@ import { resetSnowplow, restore, sidebar, + updateSetting, visitModel, } from "e2e/support/helpers"; @@ -149,7 +150,7 @@ describeWithSnowplow.skip("scenarios > metabot", () => { }); const enableMetabot = () => { - cy.request("PUT", "/api/setting/is-metabot-enabled", { value: true }); + updateSetting("is-metabot-enabled", true); }; const verifyTableVisibility = () => { diff --git a/e2e/test/scenarios/onboarding/setup/setup.cy.spec.ts b/e2e/test/scenarios/onboarding/setup/setup.cy.spec.ts index b62e8df52a1d83955a7bdfaa763900011b38e6c6..f0e348459998d74e4f83c144c782f7e1b5987661 100644 --- a/e2e/test/scenarios/onboarding/setup/setup.cy.spec.ts +++ b/e2e/test/scenarios/onboarding/setup/setup.cy.spec.ts @@ -8,9 +8,12 @@ import { expectNoBadSnowplowEvents, isEE, main, + mockSessionProperty, + onlyOnEE, popover, resetSnowplow, restore, + setTokenFeatures, } from "e2e/support/helpers"; import { SUBSCRIBE_URL } from "metabase/setup/constants"; @@ -233,6 +236,29 @@ describe("scenarios > setup", () => { }); }); + it("should pre-fill user info for hosted instances (infra-frontend#1109)", () => { + onlyOnEE(); + setTokenFeatures("none"); + mockSessionProperty("is-hosted?", true); + + cy.visit( + "/setup?first_name=John&last_name=Doe&email=john@doe.test&site_name=Doe%20Unlimited", + ); + + skipWelcomePage(); + selectPreferredLanguageAndContinue(); + + cy.findByTestId("setup-forms").within(() => { + cy.findByDisplayValue("John").should("exist"); + cy.findByDisplayValue("Doe").should("exist"); + cy.findByDisplayValue("john@doe.test").should("exist"); + cy.findByDisplayValue("Doe Unlimited").should("exist"); + cy.findByLabelText("Create a password") + .should("be.focused") + .and("be.empty"); + }); + }); + it("should allow you to connect a db during setup", () => { const dbName = "SQLite db"; diff --git a/e2e/test/scenarios/permissions/permissions-reproductions.cy.spec.js b/e2e/test/scenarios/permissions/permissions-reproductions.cy.spec.js index fbd202090c8b3eb23d7fef98f79b000aaa2782d0..9444886f0bb99c3415f1665a8ac260c1f0f711bb 100644 --- a/e2e/test/scenarios/permissions/permissions-reproductions.cy.spec.js +++ b/e2e/test/scenarios/permissions/permissions-reproductions.cy.spec.js @@ -312,7 +312,7 @@ describe("UI elements that make no sense for users without data permissions (met cy.findByTestId("display-options-sensible"); cy.icon("line").click(); cy.findByTestId("Line-button").realHover(); - cy.findByTestId("Line-button").within(() => { + cy.findByTestId("Line-container").within(() => { cy.icon("gear").click(); }); diff --git a/e2e/test/scenarios/permissions/view-data.cy.spec.js b/e2e/test/scenarios/permissions/view-data.cy.spec.js index 5b52028664b1d1fb5dc390323492e3efeec6fa61..db20787026f2513b877e231dc3c64f54063ccc94 100644 --- a/e2e/test/scenarios/permissions/view-data.cy.spec.js +++ b/e2e/test/scenarios/permissions/view-data.cy.spec.js @@ -68,7 +68,7 @@ describeEE("scenarios > admin > permissions > view data > blocked", () => { cy.findByRole("tooltip") .findByText( - /Users in groups with Blocked on a table can't view native queries on this database/, + /Groups with a database, schema, or table set to Blocked can't view native queries on this database/, ) .should("exist"); diff --git a/e2e/test/scenarios/question-reproductions/reproductions-3.cy.spec.js b/e2e/test/scenarios/question-reproductions/reproductions-3.cy.spec.js index af59d9f3cae3dceb95a215fc6245d4fe3f945b9e..bc78adc57e27c4bfe07161310c4ed09dc309a98d 100644 --- a/e2e/test/scenarios/question-reproductions/reproductions-3.cy.spec.js +++ b/e2e/test/scenarios/question-reproductions/reproductions-3.cy.spec.js @@ -1250,10 +1250,11 @@ describe("issue 43294", () => { createQuestion(questionDetails, { visitQuestion: true }); queryBuilderFooter().findByLabelText("Switch to data").click(); - cy.log("compare action"); - cy.button("Add column").click(); - popover().findByText("Compare to the past").click(); - popover().button("Done").click(); + // TODO: reenable this test when we reenable the "Compare to the past" components. + // cy.log("compare action"); + // cy.button("Add column").click(); + // popover().findByText("Compare to the past").click(); + // popover().button("Done").click(); cy.log("extract action"); cy.button("Add column").click(); diff --git a/e2e/test/scenarios/question/column-compare.cy.spec.ts b/e2e/test/scenarios/question/column-compare.cy.spec.ts index a57c55840b08dd081a879d90ddcc2eea6eb2c576..28f326da43e6cd127352c7992110742134be8824 100644 --- a/e2e/test/scenarios/question/column-compare.cy.spec.ts +++ b/e2e/test/scenarios/question/column-compare.cy.spec.ts @@ -196,1597 +196,1615 @@ const CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE = [ "count", ]; -describeWithSnowplow("scenarios > question > column compare", () => { - beforeEach(() => { - restore(); - resetSnowplow(); - cy.signInAsAdmin(); - }); - - afterEach(() => { - expectNoBadSnowplowEvents(); - }); - - describe("no aggregations", () => { - it("does not show column compare shortcut", () => { - createQuestion( - { query: QUERY_NO_AGGREGATION }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - cy.log("chill mode - summarize sidebar"); - cy.button("Summarize").click(); - rightSidebar().button("Count").icon("close").click(); - rightSidebar().button("Add aggregation").click(); - verifyNoColumnCompareShortcut(); - - cy.log("chill mode - column drill"); - tableHeaderClick("Title"); - verifyNoColumnCompareShortcut(); - - cy.log("chill mode - plus button"); - cy.button("Add column").click(); - verifyNoColumnCompareShortcut(); - - cy.log("notebook editor"); - openNotebook(); - cy.button("Summarize").click(); - verifyNoColumnCompareShortcut(); - }); - }); - - describe("no temporal columns", () => { +// TODO: reenable test when we reenable the "Compare to the past" components. +describe.skip("scenarios > question", () => { + describeWithSnowplow("column compare", () => { beforeEach(() => { - cy.request("PUT", `/api/field/${PRODUCTS.CREATED_AT}`, { - base_type: "type/Text", - }); - }); - - it("no breakout", () => { - createQuestion( - { query: QUERY_NO_AGGREGATION }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - cy.log("chill mode - summarize sidebar"); - cy.button("Summarize").click(); - rightSidebar().button("Count").icon("close").click(); - rightSidebar().button("Add aggregation").click(); - verifyNoColumnCompareShortcut(); - - cy.log("chill mode - column drill"); - tableHeaderClick("Title"); - verifyNoColumnCompareShortcut(); - - cy.log("chill mode - plus button"); - cy.button("Add column").click(); - verifyNoColumnCompareShortcut(); - - cy.log("notebook editor"); - openNotebook(); - cy.button("Summarize").click(); - verifyNoColumnCompareShortcut(); + restore(); + resetSnowplow(); + cy.signInAsAdmin(); }); - it("one breakout", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NON_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - cy.log("chill mode - summarize sidebar"); - cy.button("Summarize").click(); - rightSidebar().button("Count").icon("close").click(); - rightSidebar().button("Add aggregation").click(); - verifyNoColumnCompareShortcut(); - - cy.log("chill mode - column drill"); - tableHeaderClick("Category"); - verifyNoColumnCompareShortcut(); - - cy.log("chill mode - plus button"); - cy.button("Add column").click(); - verifyNoColumnCompareShortcut(); - - cy.log("notebook editor"); - openNotebook(); - cy.button("Summarize").click(); - verifyNoColumnCompareShortcut(); + afterEach(() => { + expectNoBadSnowplowEvents(); }); - }); - - describe("offset", () => { - it("should be possible to change the temporal bucket through a preset", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - openNotebook(); - getNotebookStep("summarize") - .findAllByTestId("aggregate-step") - .last() - .icon("add") - .click(); - - popover().within(() => { - cy.findByText("Basic Metrics").click(); - cy.findByText("Compare to the past").click(); - - cy.findByText("Previous year").click(); - cy.findByText("Done").click(); - }); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Year", - }); - - verifyAggregations([ - { - name: "Count (previous year)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (% vs previous year)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - }); - - it("should be possible to change the temporal bucket with a custom offset", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - openNotebook(); - getNotebookStep("summarize") - .findAllByTestId("aggregate-step") - .last() - .icon("add") - .click(); - popover().within(() => { - cy.findByText("Basic Metrics").click(); - cy.findByText("Compare to the past").click(); - - cy.findByText("Custom...").click(); - - cy.findByLabelText("Offset").clear().type("2"); - cy.findByLabelText("Unit").click(); - }); - - popover().last().findByText("Weeks").click(); - - popover().within(() => { - cy.findByText("Done").click(); - }); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Week", - }); - - verifyAggregations([ - { - name: "Count (2 weeks ago)", - expression: "Offset(Count, -2)", - }, - { - name: "Count (% vs 2 weeks ago)", - expression: "Count / Offset(Count, -2) - 1", - }, - ]); - }); - - describe("single aggregation", () => { - it("no breakout", () => { + describe("no aggregations", () => { + it("does not show column compare shortcut", () => { createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, + { query: QUERY_NO_AGGREGATION }, { visitQuestion: true, wrapId: true, idAlias: "questionId" }, ); - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", - }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - }); - - it("breakout on binned datetime column", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_BINNED_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - - tableHeaderClick("Created At: Month"); + cy.log("chill mode - summarize sidebar"); + cy.button("Summarize").click(); + rightSidebar().button("Count").icon("close").click(); + rightSidebar().button("Add aggregation").click(); verifyNoColumnCompareShortcut(); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", - }); - - verifyColumns([ - "Count (previous month)", - "Count (vs previous month)", - "Count (% vs previous month)", - ]); - }); - - it("breakout on non-binned datetime column", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NON_BINNED_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - - tableHeaderClick("Created At: Day"); + cy.log("chill mode - column drill"); + tableHeaderClick("Title"); verifyNoColumnCompareShortcut(); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous period)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous period)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous period)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyColumns([ - "Count (previous period)", - "Count (vs previous period)", - "Count (% vs previous period)", - ]); - }); - - it("breakout on non-datetime column", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NON_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - - tableHeaderClick("Category"); + cy.log("chill mode - plus button"); + cy.button("Add column").click(); verifyNoColumnCompareShortcut(); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - + cy.log("notebook editor"); openNotebook(); - cy.button("Summarize").click(); verifyNoColumnCompareShortcut(); - cy.realPress("Escape"); - - cy.button("Show Visualization").click(); - queryBuilderMain().findByText("42").should("be.visible"); - - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", - }); - - verifyColumns([ - "Count (previous month)", - "Count (vs previous month)", - "Count (% vs previous month)", - ]); - }); - - it("breakout on temporal column which is an expression", () => { - createQuestion( - { query: QUERY_TEMPORAL_EXPRESSION_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - - tableHeaderClick("Created At plus one month: Month"); - verifyNoColumnCompareShortcut(); - - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At plus one month", - bucket: "Month", - }); - - verifyColumns([ - "Count (previous month)", - "Count (vs previous month)", - "Count (% vs previous month)", - ]); - }); - - it("multiple breakouts", () => { - createQuestion( - { query: QUERY_MULTIPLE_BREAKOUTS }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", - }); - breakout({ column: "Category" }).should("exist"); - - verifyColumns([ - "Count (previous month)", - "Count (vs previous month)", - "Count (% vs previous month)", - ]); - }); - - it("multiple temporal breakouts", () => { - createQuestion( - { query: QUERY_MULTIPLE_TEMPORAL_BREAKOUTS }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", - }); - breakout({ column: "Category" }).should("exist"); - breakout({ column: "Created At" }).should("exist"); - - verifyColumns([ - "Count (previous month)", - "Count (vs previous month)", - "Count (% vs previous month)", - ]); - }); - - it("one breakout on non-default datetime column", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_OTHER_DATETIME }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - - tableHeaderClick("Count"); - verifyNoColumnCompareShortcut(); - - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyBreakoutExistsAndIsFirst({ - column: "User → Created At", - bucket: "Month", - }); - breakout({ column: "Created At", bucket: "Month" }).should("not.exist"); - - verifyColumns([ - "Count (previous month)", - "Count (vs previous month)", - "Count (% vs previous month)", - ]); }); }); - describe("multiple aggregations", () => { - it("no breakout", () => { - createQuestion( - { query: QUERY_MULTIPLE_AGGREGATIONS_NO_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step1Title: "Compare one of these to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", - }); - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - }); - - it("breakout on binned datetime column", () => { - createQuestion( - { query: QUERY_MULTIPLE_AGGREGATIONS_BINNED_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step1Title: "Compare one of these to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - - tableHeaderClick("Created At: Month"); - verifyNoColumnCompareShortcut(); - - verifyColumnDrillText(_.omit(info, "step1Title")); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); + describe("no temporal columns", () => { + beforeEach(() => { + cy.request("PUT", `/api/field/${PRODUCTS.CREATED_AT}`, { + base_type: "type/Text", }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyColumns([ - "Count (previous month)", - "Count (vs previous month)", - "Count (% vs previous month)", - ]); }); - it("breakout on non-binned datetime column", () => { - createQuestion( - { query: QUERY_MULTIPLE_AGGREGATIONS_NON_BINNED_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - itemName: "Compare to the past", - step1Title: "Compare one of these to the past", - step2Title: "Compare “Count†to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - - tableHeaderClick("Created At: Day"); - verifyNoColumnCompareShortcut(); - - verifyColumnDrillText(_.omit(info, "step1Title")); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous period)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous period)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous period)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyColumns([ - "Count (previous period)", - "Count (vs previous period)", - "Count (% vs previous period)", - ]); - }); - - it("breakout on non-datetime column", () => { + it("no breakout", () => { createQuestion( - { query: QUERY_MULTIPLE_AGGREGATIONS_NON_DATETIME_BREAKOUT }, + { query: QUERY_NO_AGGREGATION }, { visitQuestion: true, wrapId: true, idAlias: "questionId" }, ); - const info = { - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - step1Title: "Compare one of these to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "ago", - }; - - verifySummarizeText(info); - - tableHeaderClick("Category"); + cy.log("chill mode - summarize sidebar"); + cy.button("Summarize").click(); + rightSidebar().button("Count").icon("close").click(); + rightSidebar().button("Add aggregation").click(); verifyNoColumnCompareShortcut(); - verifyColumnDrillText(_.omit(info, "step1Title")); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); - }); - - verifyAggregations([ - { - name: "Count (previous month)", - expression: "Offset(Count, -1)", - }, - { - name: "Count (vs previous month)", - expression: "Count - Offset(Count, -1)", - }, - { - name: "Count (% vs previous month)", - expression: "Count / Offset(Count, -1) - 1", - }, - ]); - - verifyColumns([ - "Count (previous month)", - "Count (vs previous month)", - "Count (% vs previous month)", - ]); - }); - }); - }); - - describe("moving average", () => { - it("should be possible to change the temporal bucket with a custom offset", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); + cy.log("chill mode - column drill"); + tableHeaderClick("Title"); + verifyNoColumnCompareShortcut(); - openNotebook(); - getNotebookStep("summarize") - .findAllByTestId("aggregate-step") - .last() - .icon("add") - .click(); + cy.log("chill mode - plus button"); + cy.button("Add column").click(); + verifyNoColumnCompareShortcut(); - popover().within(() => { - cy.findByText("Basic Metrics").click(); - cy.findByText("Compare to the past").click(); + cy.log("notebook editor"); + openNotebook(); + cy.button("Summarize").click(); + verifyNoColumnCompareShortcut(); + }); - cy.findByText("Moving average").click(); + it("one breakout", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_NON_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); - cy.findByLabelText("Offset").clear().type("3"); - cy.findByLabelText("Unit").click(); - }); + cy.log("chill mode - summarize sidebar"); + cy.button("Summarize").click(); + rightSidebar().button("Count").icon("close").click(); + rightSidebar().button("Add aggregation").click(); + verifyNoColumnCompareShortcut(); - popover().last().findByText("Week").click(); + cy.log("chill mode - column drill"); + tableHeaderClick("Category"); + verifyNoColumnCompareShortcut(); - popover().within(() => { - cy.findByText("Done").click(); - }); + cy.log("chill mode - plus button"); + cy.button("Add column").click(); + verifyNoColumnCompareShortcut(); - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Week", + cy.log("notebook editor"); + openNotebook(); + cy.button("Summarize").click(); + verifyNoColumnCompareShortcut(); }); - - verifyAggregations([ - { - name: "Count (3-week moving average)", - expression: - "(Offset(Count, -1) + Offset(Count, -2) + Offset(Count, -3)) / 3", - }, - { - name: "Count (% vs 3-week moving average)", - expression: - "Count / ((Offset(Count, -1) + Offset(Count, -2) + Offset(Count, -3)) / 3)", - }, - ]); }); - describe("single aggregation", () => { - it("no breakout", () => { + describe("offset", () => { + it("should be possible to change the temporal bucket through a preset", () => { createQuestion( { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, { visitQuestion: true, wrapId: true, idAlias: "questionId" }, ); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; - - verifySummarizeText(info); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); + openNotebook(); + getNotebookStep("summarize") + .findAllByTestId("aggregate-step") + .last() + .icon("add") + .click(); + + popover().within(() => { + cy.findByText("Basic Metrics").click(); + cy.findByText("Compare to the past").click(); + + cy.findByText("Previous year").click(); + cy.findByText("Done").click(); }); verifyBreakoutExistsAndIsFirst({ column: "Created At", - bucket: "Month", + bucket: "Year", }); verifyAggregations([ { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + name: "Count (previous year)", + expression: "Offset(Count, -1)", }, { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + name: "Count (% vs previous year)", + expression: "Count / Offset(Count, -1) - 1", }, ]); - - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); }); - it("breakout on binned datetime column", () => { + it("should be possible to change the temporal bucket with a custom offset", () => { createQuestion( - { query: QUERY_SINGLE_AGGREGATION_BINNED_DATETIME_BREAKOUT }, + { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, { visitQuestion: true, wrapId: true, idAlias: "questionId" }, ); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; + openNotebook(); + getNotebookStep("summarize") + .findAllByTestId("aggregate-step") + .last() + .icon("add") + .click(); - verifySummarizeText(info); + popover().within(() => { + cy.findByText("Basic Metrics").click(); + cy.findByText("Compare to the past").click(); - tableHeaderClick("Created At: Month"); - verifyNoColumnCompareShortcut(); + cy.findByText("Custom...").click(); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); + cy.findByLabelText("Offset").clear().type("2"); + cy.findByLabelText("Unit").click(); + }); - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); + popover().last().findByText("Weeks").click(); - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); + popover().within(() => { + cy.findByText("Done").click(); + }); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Week", }); verifyAggregations([ { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + name: "Count (2 weeks ago)", + expression: "Offset(Count, -2)", }, { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + name: "Count (% vs 2 weeks ago)", + expression: "Count / Offset(Count, -2) - 1", }, ]); - - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", - }); - - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); }); - it("breakout on non-binned datetime column", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NON_BINNED_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; - - verifySummarizeText(info); + describe("single aggregation", () => { + it("no breakout", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - tableHeaderClick("Created At: Day"); - verifyNoColumnCompareShortcut(); + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", + }); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + }); - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); + it("breakout on binned datetime column", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_BINNED_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At: Month"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", }); + + verifyColumns([ + "Count (previous month)", + "Count (vs previous month)", + "Count (% vs previous month)", + ]); }); - verifyAggregations([ - { - name: "Count (2-period moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-period moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (% vs 2-period moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", - }, - ]); + it("breakout on non-binned datetime column", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_NON_BINNED_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At: Day"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - verifyBreakoutExistsAndIsFirst({ - column: "Created At", + verifyAggregations([ + { + name: "Count (previous period)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous period)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous period)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyColumns([ + "Count (previous period)", + "Count (vs previous period)", + "Count (% vs previous period)", + ]); }); - verifyColumns([ - "Count (2-period moving average)", - "Count (vs 2-period moving average)", - "Count (% vs 2-period moving average)", - ]); - }); - - it("breakout on non-datetime column", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_NON_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); + it("breakout on non-datetime column", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_NON_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; - verifySummarizeText(info); + verifySummarizeText(info); - tableHeaderClick("Category"); - verifyNoColumnCompareShortcut(); + tableHeaderClick("Category"); + verifyNoColumnCompareShortcut(); - verifyColumnDrillText(info); - verifyPlusButtonText(info); + verifyColumnDrillText(info); + verifyPlusButtonText(info); - openNotebook(); + openNotebook(); - cy.button("Summarize").click(); - verifyNoColumnCompareShortcut(); - cy.realPress("Escape"); + cy.button("Summarize").click(); + verifyNoColumnCompareShortcut(); + cy.realPress("Escape"); - cy.button("Show Visualization").click(); - queryBuilderMain().findByText("42").should("be.visible"); + cy.button("Show Visualization").click(); + queryBuilderMain().findByText("42").should("be.visible"); - verifyNotebookText(info); + verifyNotebookText(info); - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); }); - }); - verifyAggregations([ - { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", - }, - ]); + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", + }); - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", + verifyColumns([ + "Count (previous month)", + "Count (vs previous month)", + "Count (% vs previous month)", + ]); }); - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); - }); - - it("multiple breakouts", () => { - createQuestion( - { query: QUERY_MULTIPLE_BREAKOUTS }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); + it("breakout on temporal column which is an expression", () => { + createQuestion( + { query: QUERY_TEMPORAL_EXPRESSION_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At plus one month: Month"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; - - verifySummarizeText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At plus one month", + bucket: "Month", }); + + verifyColumns([ + "Count (previous month)", + "Count (vs previous month)", + "Count (% vs previous month)", + ]); }); - verifyAggregations([ - { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", - }, - ]); + it("multiple breakouts", () => { + createQuestion( + { query: QUERY_MULTIPLE_BREAKOUTS }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", - }); - breakout({ column: "Category" }).should("exist"); + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", + }); + breakout({ column: "Category" }).should("exist"); - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); - }); + verifyColumns([ + "Count (previous month)", + "Count (vs previous month)", + "Count (% vs previous month)", + ]); + }); - it("multiple temporal breakouts", () => { - createQuestion( - { query: QUERY_MULTIPLE_TEMPORAL_BREAKOUTS }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); + it("multiple temporal breakouts", () => { + createQuestion( + { query: QUERY_MULTIPLE_TEMPORAL_BREAKOUTS }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; - - verifySummarizeText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", }); + breakout({ column: "Category" }).should("exist"); + breakout({ column: "Created At" }).should("exist"); + + verifyColumns([ + "Count (previous month)", + "Count (vs previous month)", + "Count (% vs previous month)", + ]); }); - verifyAggregations([ - { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", - }, - ]); + it("one breakout on non-default datetime column", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_OTHER_DATETIME }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + + tableHeaderClick("Count"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - verifyBreakoutExistsAndIsFirst({ - column: "Created At", - bucket: "Month", + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "User → Created At", + bucket: "Month", + }); + breakout({ column: "Created At", bucket: "Month" }).should( + "not.exist", + ); + + verifyColumns([ + "Count (previous month)", + "Count (vs previous month)", + "Count (% vs previous month)", + ]); }); - breakout({ column: "Category" }).should("exist"); - - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); }); - it("one breakout on non-default datetime column", () => { - createQuestion( - { query: QUERY_SINGLE_AGGREGATION_OTHER_DATETIME }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); - - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; - - verifySummarizeText(info); + describe("multiple aggregations", () => { + it("no breakout", () => { + createQuestion( + { query: QUERY_MULTIPLE_AGGREGATIONS_NO_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step1Title: "Compare one of these to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - tableHeaderClick("Count"); - verifyNoColumnCompareShortcut(); + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", + }); + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + }); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); + it("breakout on binned datetime column", () => { + createQuestion( + { query: QUERY_MULTIPLE_AGGREGATIONS_BINNED_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step1Title: "Compare one of these to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At: Month"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(_.omit(info, "step1Title")); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyColumns([ + "Count (previous month)", + "Count (vs previous month)", + "Count (% vs previous month)", + ]); + }); - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, + it("breakout on non-binned datetime column", () => { + createQuestion( + { query: QUERY_MULTIPLE_AGGREGATIONS_NON_BINNED_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step1Title: "Compare one of these to the past", + step2Title: "Compare “Count†to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At: Day"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(_.omit(info, "step1Title")); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); }); + + verifyAggregations([ + { + name: "Count (previous period)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous period)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous period)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyColumns([ + "Count (previous period)", + "Count (vs previous period)", + "Count (% vs previous period)", + ]); }); - verifyAggregations([ - { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", - }, - ]); + it("breakout on non-datetime column", () => { + createQuestion( + { query: QUERY_MULTIPLE_AGGREGATIONS_NON_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + step1Title: "Compare one of these to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "ago", + }; + + verifySummarizeText(info); + + tableHeaderClick("Category"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(_.omit(info, "step1Title")); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - verifyBreakoutExistsAndIsFirst({ - column: "User → Created At", - bucket: "Month", + verifyAggregations([ + { + name: "Count (previous month)", + expression: "Offset(Count, -1)", + }, + { + name: "Count (vs previous month)", + expression: "Count - Offset(Count, -1)", + }, + { + name: "Count (% vs previous month)", + expression: "Count / Offset(Count, -1) - 1", + }, + ]); + + verifyColumns([ + "Count (previous month)", + "Count (vs previous month)", + "Count (% vs previous month)", + ]); }); - breakout({ column: "Created At", bucket: "Month" }).should("not.exist"); - - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); }); }); - describe("multiple aggregations", () => { - it("no breakout", () => { + describe("moving average", () => { + it("should be possible to change the temporal bucket with a custom offset", () => { createQuestion( - { query: QUERY_MULTIPLE_AGGREGATIONS_NO_BREAKOUT }, + { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, { visitQuestion: true, wrapId: true, idAlias: "questionId" }, ); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step1Title: "Compare one of these to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; - - verifySummarizeText(info); - verifyColumnDrillText(info); - verifyPlusButtonText(info); - verifyNotebookText(info); - - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); - - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, - }); + openNotebook(); + getNotebookStep("summarize") + .findAllByTestId("aggregate-step") + .last() + .icon("add") + .click(); + + popover().within(() => { + cy.findByText("Basic Metrics").click(); + cy.findByText("Compare to the past").click(); + + cy.findByText("Moving average").click(); + + cy.findByLabelText("Offset").clear().type("3"); + cy.findByLabelText("Unit").click(); + }); + + popover().last().findByText("Week").click(); + + popover().within(() => { + cy.findByText("Done").click(); }); verifyBreakoutExistsAndIsFirst({ column: "Created At", - bucket: "Month", + bucket: "Week", }); + verifyAggregations([ { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + name: "Count (3-week moving average)", + expression: + "(Offset(Count, -1) + Offset(Count, -2) + Offset(Count, -3)) / 3", }, { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + name: "Count (% vs 3-week moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2) + Offset(Count, -3)) / 3)", }, ]); - - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); }); - it("breakout on binned datetime column", () => { - createQuestion( - { query: QUERY_MULTIPLE_AGGREGATIONS_BINNED_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); + describe("single aggregation", () => { + it("no breakout", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_NO_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", + }); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step1Title: "Compare one of these to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); + }); - verifySummarizeText(info); + it("breakout on binned datetime column", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_BINNED_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At: Month"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - tableHeaderClick("Created At: Month"); - verifyNoColumnCompareShortcut(); + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", + }); - verifyColumnDrillText(_.omit(info, "step1Title")); - verifyPlusButtonText(info); - verifyNotebookText(info); + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); + }); - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); + it("breakout on non-binned datetime column", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_NON_BINNED_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At: Day"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, + verifyAggregations([ + { + name: "Count (2-period moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-period moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-period moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", }); + + verifyColumns([ + "Count (2-period moving average)", + "Count (vs 2-period moving average)", + "Count (% vs 2-period moving average)", + ]); }); - verifyAggregations([ - { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", - }, - ]); + it("breakout on non-datetime column", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_NON_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); - }); + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; - it("breakout on non-binned datetime column", () => { - createQuestion( - { query: QUERY_MULTIPLE_AGGREGATIONS_NON_BINNED_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); + verifySummarizeText(info); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step1Title: "Compare one of these to the past", - step2Title: "Compare “Count†to the past", - offsetHelp: "moving average", - }; + tableHeaderClick("Category"); + verifyNoColumnCompareShortcut(); - verifySummarizeText(info); + verifyColumnDrillText(info); + verifyPlusButtonText(info); - tableHeaderClick("Created At: Day"); - verifyNoColumnCompareShortcut(); + openNotebook(); + + cy.button("Summarize").click(); + verifyNoColumnCompareShortcut(); + cy.realPress("Escape"); + + cy.button("Show Visualization").click(); + queryBuilderMain().findByText("42").should("be.visible"); + + verifyNotebookText(info); - verifyColumnDrillText(_.omit(info, "step1Title")); - verifyPlusButtonText(info); - verifyNotebookText(info); + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", }); + + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); }); - verifyAggregations([ - { - name: "Count (2-period moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-period moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (% vs 2-period moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", - }, - ]); + it("multiple breakouts", () => { + createQuestion( + { query: QUERY_MULTIPLE_BREAKOUTS }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - verifyColumns([ - "Count (2-period moving average)", - "Count (vs 2-period moving average)", - "Count (% vs 2-period moving average)", - ]); - }); + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", + }); + breakout({ column: "Category" }).should("exist"); - it("breakout on non-datetime column", () => { - createQuestion( - { query: QUERY_MULTIPLE_AGGREGATIONS_NON_DATETIME_BREAKOUT }, - { visitQuestion: true, wrapId: true, idAlias: "questionId" }, - ); + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); + }); + + it("multiple temporal breakouts", () => { + createQuestion( + { query: QUERY_MULTIPLE_TEMPORAL_BREAKOUTS }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - const info = { - type: "moving-average" as const, - itemName: "Compare to the past", - step2Title: "Compare “Count†to the past", - step1Title: "Compare one of these to the past", - presets: ["Previous month", "Previous year"], - offsetHelp: "moving average", - }; + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", + }); + breakout({ column: "Category" }).should("exist"); - verifySummarizeText(info); + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); + }); - tableHeaderClick("Category"); - verifyNoColumnCompareShortcut(); + it("one breakout on non-default datetime column", () => { + createQuestion( + { query: QUERY_SINGLE_AGGREGATION_OTHER_DATETIME }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + + tableHeaderClick("Count"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - verifyColumnDrillText(_.omit(info, "step1Title")); - verifyPlusButtonText(info); - verifyNotebookText(info); + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyBreakoutExistsAndIsFirst({ + column: "User → Created At", + bucket: "Month", + }); + breakout({ column: "Created At", bucket: "Month" }).should( + "not.exist", + ); + + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); + }); + }); - toggleColumnPickerItems(["Value difference"]); - popover().button("Done").click(); + describe("multiple aggregations", () => { + it("no breakout", () => { + createQuestion( + { query: QUERY_MULTIPLE_AGGREGATIONS_NO_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step1Title: "Compare one of these to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + verifyColumnDrillText(info); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - cy.get("@questionId").then(questionId => { - expectGoodSnowplowEvent({ - event: "column_compare_via_shortcut", - custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, - database_id: SAMPLE_DB_ID, - question_id: questionId, + verifyBreakoutExistsAndIsFirst({ + column: "Created At", + bucket: "Month", }); + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); }); - verifyAggregations([ - { - name: "Count (2-month moving average)", - expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (vs 2-month moving average)", - expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", - }, - { - name: "Count (% vs 2-month moving average)", - expression: "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", - }, - ]); + it("breakout on binned datetime column", () => { + createQuestion( + { query: QUERY_MULTIPLE_AGGREGATIONS_BINNED_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step1Title: "Compare one of these to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At: Month"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(_.omit(info, "step1Title")); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); - verifyColumns([ - "Count (2-month moving average)", - "Count (vs 2-month moving average)", - "Count (% vs 2-month moving average)", - ]); + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); + }); + + it("breakout on non-binned datetime column", () => { + createQuestion( + { query: QUERY_MULTIPLE_AGGREGATIONS_NON_BINNED_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step1Title: "Compare one of these to the past", + step2Title: "Compare “Count†to the past", + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + + tableHeaderClick("Created At: Day"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(_.omit(info, "step1Title")); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); + + verifyAggregations([ + { + name: "Count (2-period moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-period moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-period moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyColumns([ + "Count (2-period moving average)", + "Count (vs 2-period moving average)", + "Count (% vs 2-period moving average)", + ]); + }); + + it("breakout on non-datetime column", () => { + createQuestion( + { query: QUERY_MULTIPLE_AGGREGATIONS_NON_DATETIME_BREAKOUT }, + { visitQuestion: true, wrapId: true, idAlias: "questionId" }, + ); + + const info = { + type: "moving-average" as const, + itemName: "Compare to the past", + step2Title: "Compare “Count†to the past", + step1Title: "Compare one of these to the past", + presets: ["Previous month", "Previous year"], + offsetHelp: "moving average", + }; + + verifySummarizeText(info); + + tableHeaderClick("Category"); + verifyNoColumnCompareShortcut(); + + verifyColumnDrillText(_.omit(info, "step1Title")); + verifyPlusButtonText(info); + verifyNotebookText(info); + + toggleColumnPickerItems(["Value difference"]); + popover().button("Done").click(); + + cy.get("@questionId").then(questionId => { + expectGoodSnowplowEvent({ + event: "column_compare_via_shortcut", + custom_expressions_used: CUSTOM_EXPRESSIONS_USED_MOVING_AVERAGE, + database_id: SAMPLE_DB_ID, + question_id: questionId, + }); + }); + + verifyAggregations([ + { + name: "Count (2-month moving average)", + expression: "(Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (vs 2-month moving average)", + expression: "Count - (Offset(Count, -1) + Offset(Count, -2)) / 2", + }, + { + name: "Count (% vs 2-month moving average)", + expression: + "Count / ((Offset(Count, -1) + Offset(Count, -2)) / 2)", + }, + ]); + + verifyColumns([ + "Count (2-month moving average)", + "Count (vs 2-month moving average)", + "Count (% vs 2-month moving average)", + ]); + }); }); }); }); diff --git a/e2e/test/scenarios/question/multiple-column-breakouts.cy.spec.ts b/e2e/test/scenarios/question/multiple-column-breakouts.cy.spec.ts index 38055af3ec53a80e3bb6c2019afc42c680e8cffa..39d0596929b8b1b03113cfede09f47672dfcea34 100644 --- a/e2e/test/scenarios/question/multiple-column-breakouts.cy.spec.ts +++ b/e2e/test/scenarios/question/multiple-column-breakouts.cy.spec.ts @@ -272,6 +272,19 @@ function getNestedQuestionDetails(cardId: number) { }; } +// This is used in several places for the same query. +function assertTableDataForFilteredTemporalBreakouts() { + assertTableData({ + columns: ["Created At: Year", "Created At: Month", "Count"], + firstRows: [ + ["2023", "March 2023", "256"], + ["2023", "April 2023", "238"], + ["2023", "May 2023", "271"], + ], + }); + assertQueryBuilderRowCount(3); +} + describe("scenarios > question > multiple column breakouts", () => { beforeEach(() => { restore(); @@ -776,16 +789,16 @@ describe("scenarios > question > multiple column breakouts", () => { }); assertTableData({ columns: [ - "Created At", - "Created At", + "Created At: Year", + "Created At: Month", "Count", "Expression1", "Expression2", ], firstRows: [ [ - "January 1, 2022, 12:00 AM", - "April 1, 2022, 12:00 AM", + "2022", + "April 2022", "1", "January 1, 2023, 12:00 AM", "May 1, 2022, 12:00 AM", @@ -955,15 +968,7 @@ describe("scenarios > question > multiple column breakouts", () => { column2MinValue: "March 1, 2023", column2MaxValue: "May 31, 2023", }); - assertTableData({ - columns: ["Created At", "Created At", "Count"], - firstRows: [ - ["January 1, 2023, 12:00 AM", "March 1, 2023, 12:00 AM", "256"], - ["January 1, 2023, 12:00 AM", "April 1, 2023, 12:00 AM", "238"], - ["January 1, 2023, 12:00 AM", "May 1, 2023, 12:00 AM", "271"], - ], - }); - assertQueryBuilderRowCount(3); + assertTableDataForFilteredTemporalBreakouts(); cy.log("'num-bins' breakouts"); testNumericPostAggregationFilter({ @@ -1285,15 +1290,7 @@ describe("scenarios > question > multiple column breakouts", () => { column2MinValue: "March 1, 2023", column2MaxValue: "May 31, 2023", }); - assertTableData({ - columns: ["Created At", "Created At", "Count"], - firstRows: [ - ["January 1, 2023, 12:00 AM", "March 1, 2023, 12:00 AM", "256"], - ["January 1, 2023, 12:00 AM", "April 1, 2023, 12:00 AM", "238"], - ["January 1, 2023, 12:00 AM", "May 1, 2023, 12:00 AM", "271"], - ], - }); - assertQueryBuilderRowCount(3); + assertTableDataForFilteredTemporalBreakouts(); cy.log("'num-bins' breakouts"); testNumericPostAggregationFilter({ @@ -1393,8 +1390,8 @@ describe("scenarios > question > multiple column breakouts", () => { questionDetails: multiStageQuestionWith2TemporalBreakoutsDetails, queryColumn1Name: "Created At: Year", queryColumn2Name: "Created At: Month", - tableColumn1Name: "Created At", - tableColumn2Name: "Created At", + tableColumn1Name: "Created At: Year", + tableColumn2Name: "Created At: Month", }); cy.log("'num-bins' breakouts"); @@ -1564,15 +1561,7 @@ describe("scenarios > question > multiple column breakouts", () => { column2MinValue: "March 1, 2023", column2MaxValue: "May 31, 2023", }); - assertTableData({ - columns: ["Created At", "Created At", "Count"], - firstRows: [ - ["January 1, 2023, 12:00 AM", "March 1, 2023, 12:00 AM", "256"], - ["January 1, 2023, 12:00 AM", "April 1, 2023, 12:00 AM", "238"], - ["January 1, 2023, 12:00 AM", "May 1, 2023, 12:00 AM", "271"], - ], - }); - assertQueryBuilderRowCount(3); + assertTableDataForFilteredTemporalBreakouts(); cy.log("'num-bins' breakouts"); testNumericPostAggregationFilter({ @@ -1784,8 +1773,10 @@ describe("scenarios > question > multiple column breakouts", () => { visitQuestion: true, }); }); + const columnNameYear = columnName + ": Year"; + const columnNameMonth = columnName + ": Month"; assertTableData({ - columns: [columnName, columnName, "Count"], + columns: [columnNameYear, columnNameMonth, "Count"], }); cy.findByTestId("viz-settings-button").click(); @@ -1794,7 +1785,7 @@ describe("scenarios > question > multiple column breakouts", () => { .click(); toggleColumn(columnName, 0, false); cy.wait("@dataset"); - assertTableData({ columns: [columnName, "Count"] }); + assertTableData({ columns: [columnNameMonth, "Count"] }); toggleColumn(columnName, 1, false); cy.wait("@dataset"); @@ -1802,11 +1793,11 @@ describe("scenarios > question > multiple column breakouts", () => { toggleColumn(columnName, 0, true); cy.wait("@dataset"); - assertTableData({ columns: ["Count", columnName] }); + assertTableData({ columns: ["Count", columnNameYear] }); toggleColumn(columnName, 1, true); assertTableData({ - columns: ["Count", columnName, columnName], + columns: ["Count", columnNameYear, columnNameMonth], }); } diff --git a/e2e/test/scenarios/question/offset.cy.spec.ts b/e2e/test/scenarios/question/offset.cy.spec.ts index 53f333c46ab01ec9f2801a3dbe8a3f5e69efad9f..f6c1631735a5862343b88f013cffb4a1a37d1fae 100644 --- a/e2e/test/scenarios/question/offset.cy.spec.ts +++ b/e2e/test/scenarios/question/offset.cy.spec.ts @@ -1224,8 +1224,7 @@ describe("scenarios > question > offset", () => { ]); }); - // unskip once https://github.com/metabase/metabase/issues/47854 is fixed - it.skip("should work with metrics (metabase#47854)", () => { + it("should work with metrics (metabase#47854)", () => { const metricName = "Count of orders"; const ORDERS_SCALAR_METRIC: StructuredQuestionDetails = { name: metricName, @@ -1234,6 +1233,13 @@ describe("scenarios > question > offset", () => { query: { "source-table": ORDERS_ID, aggregation: [["count"]], + breakout: [ + [ + "field", + ORDERS.CREATED_AT, + { "base-type": "type/DateTime", "temporal-unit": "month" }, + ], + ], }, display: "scalar", }; diff --git a/e2e/test/scenarios/search/search-typeahead.cy.spec.js b/e2e/test/scenarios/search/search-typeahead.cy.spec.js index 5bfc22309bf1f208458d322cb7350ca341a15717..510b3cc9e1e0950689a939dd633d2f2c223ced7f 100644 --- a/e2e/test/scenarios/search/search-typeahead.cy.spec.js +++ b/e2e/test/scenarios/search/search-typeahead.cy.spec.js @@ -4,6 +4,7 @@ import { commandPaletteButton, commandPaletteInput, restore, + updateSetting, visitFullAppEmbeddingUrl, } from "e2e/support/helpers"; @@ -39,9 +40,7 @@ describe("command palette", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/search-typeahead-enabled", { - value: false, - }); + updateSetting("search-typeahead-enabled", false); cy.visit("/"); }); diff --git a/e2e/test/scenarios/search/search.cy.spec.js b/e2e/test/scenarios/search/search.cy.spec.js index 3f72dce972476d4dec6f289a98f2d72d3f44f7f6..579da6f733f6c8a5c359c5326dab55b874a11f32 100644 --- a/e2e/test/scenarios/search/search.cy.spec.js +++ b/e2e/test/scenarios/search/search.cy.spec.js @@ -10,6 +10,7 @@ import { isScrollableHorizontally, main, restore, + updateSetting, visitFullAppEmbeddingUrl, } from "e2e/support/helpers"; @@ -193,10 +194,8 @@ describe("scenarios > search", () => { }); it("should not dismiss when the homepage redirects to a dashboard (metabase#34226)", () => { - cy.request("PUT", "/api/setting/custom-homepage", { value: true }); - cy.request("PUT", "/api/setting/custom-homepage-dashboard", { - value: ORDERS_DASHBOARD_ID, - }); + updateSetting("custom-homepage", true); + updateSetting("custom-homepage-dashboard", ORDERS_DASHBOARD_ID); cy.intercept( { url: `/api/dashboard/${ORDERS_DASHBOARD_ID}`, diff --git a/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js b/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js index 5cffe5dbb0b1e77b6822ae63dd8b6386ce2ccf6d..d4628231b590c752c5c97bd543373b7c1b451a9b 100644 --- a/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js +++ b/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js @@ -7,6 +7,7 @@ import { popover, restore, setupSMTP, + updateSetting, visitQuestion, } from "e2e/support/helpers"; @@ -44,9 +45,7 @@ describe("scenarios > alert > email_alert", { tags: "@external" }, () => { }); it("should respect email alerts toggled off (metabase#12349)", () => { - cy.request("PUT", "/api/setting/report-timezone", { - value: "America/New_York", - }); + updateSetting("report-timezone", "America/New_York"); openAlertForQuestion(ORDERS_QUESTION_ID); diff --git a/e2e/test/scenarios/sharing/public-dashboard.cy.spec.js b/e2e/test/scenarios/sharing/public-dashboard.cy.spec.js index bfcc23b37d2e7a004bd182b3cb14e0a8dd33c7e3..6b3e4ec17cb76f71f99001eb9bf46c7045fcbac7 100644 --- a/e2e/test/scenarios/sharing/public-dashboard.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-dashboard.cy.spec.js @@ -12,6 +12,7 @@ import { popover, restore, setTokenFeatures, + updateSetting, visitDashboard, visitPublicDashboard, } from "e2e/support/helpers"; @@ -81,7 +82,7 @@ const USERS = { }; const prepareDashboard = () => { - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + updateSetting("enable-public-sharing", true); cy.intercept("/api/dashboard/*/public_link").as("publicLink"); @@ -288,9 +289,7 @@ describeEE("scenarios [EE] > public > dashboard", () => { }); it("should set the window title to `{dashboard name} · {application name}`", () => { - cy.request("PUT", "/api/setting/application-name", { - value: "Custom Application Name", - }); + updateSetting("application-name", "Custom Application Name"); cy.get("@dashboardId").then(id => { visitPublicDashboard(id); diff --git a/e2e/test/scenarios/sharing/public-question.cy.spec.js b/e2e/test/scenarios/sharing/public-question.cy.spec.js index 4e17f39a359d29d403ed87a34b536ba535789671..ac50adeab03793288d87b490b61da2cd503a0d63 100644 --- a/e2e/test/scenarios/sharing/public-question.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-question.cy.spec.js @@ -12,6 +12,7 @@ import { openSharingMenu, restore, saveQuestion, + updateSetting, visitQuestion, } from "e2e/support/helpers"; @@ -61,7 +62,7 @@ describe("scenarios > public > question", () => { restore(); cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + updateSetting("enable-public-sharing", true); }); it("adds filters to url as get params and renders the results correctly (metabase#7120, metabase#17033, metabase#21993)", () => { diff --git a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js index 91b1d5823af9e479313dd4a1e839ebb9f84aeb3f..ee6299725f253e9b2e0bfda12cda540e2c6128f8 100644 --- a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js @@ -18,6 +18,7 @@ import { setTokenFeatures, sharingMenu, startNewQuestion, + updateSetting, visitDashboard, visitQuestion, visualize, @@ -36,7 +37,7 @@ import { describe("when embedding is disabled", () => { beforeEach(() => { - cy.request("PUT", "/api/setting/enable-embedding", { value: false }); + updateSetting("enable-embedding", false); }); describe("when user is admin", () => { @@ -70,10 +71,8 @@ import { describe("when embedding is enabled", () => { describe("when public sharing is enabled", () => { beforeEach(() => { - cy.request("PUT", "/api/setting/enable-public-sharing", { - value: true, - }); - cy.request("PUT", "/api/setting/enable-embedding", { value: true }); + updateSetting("enable-public-sharing", true); + updateSetting("enable-embedding", true); }); describe("when user is admin", () => { @@ -138,10 +137,8 @@ import { describe("when public sharing is disabled", () => { beforeEach(() => { - cy.request("PUT", "/api/setting/enable-public-sharing", { - value: false, - }); - cy.request("PUT", "/api/setting/enable-embedding", { value: true }); + updateSetting("enable-public-sharing", false); + updateSetting("enable-embedding", true); }); describe("when user is admin", () => { @@ -250,7 +247,7 @@ describe("#39152 sharing an unsaved question", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); - cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + updateSetting("enable-public-sharing", true); }); it("should ask the user to save the question before creating a public link", () => { diff --git a/e2e/test/scenarios/sharing/public-sharing.cy.spec.js b/e2e/test/scenarios/sharing/public-sharing.cy.spec.js index 2be35995aeeadef13ba4e01b26045a01a474159c..19592aef894c42cdf65715fd16d8db9be12239df 100644 --- a/e2e/test/scenarios/sharing/public-sharing.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-sharing.cy.spec.js @@ -14,6 +14,7 @@ import { setTokenFeatures, setupSMTP, sidebar, + updateSetting, visitDashboard, visitDashboardAndCreateTab, visitQuestion, @@ -294,9 +295,7 @@ describeEE( } function setAllowedDomains() { - cy.request("PUT", "/api/setting/subscription-allowed-domains", { - value: allowedDomain, - }); + updateSetting("subscription-allowed-domains", allowedDomain); } beforeEach(() => { diff --git a/e2e/test/scenarios/sharing/subscriptions.cy.spec.js b/e2e/test/scenarios/sharing/subscriptions.cy.spec.js index 8c965adcc481c3233c6ac830e884db663b8c0e53..77ff3f2d5539eefef4637ae9c5c6c9f64a643cf8 100644 --- a/e2e/test/scenarios/sharing/subscriptions.cy.spec.js +++ b/e2e/test/scenarios/sharing/subscriptions.cy.spec.js @@ -26,6 +26,7 @@ import { setupSubscriptionWithRecipients, sharingMenu, sidebar, + updateSetting, viewEmailPage, visitDashboard, } from "e2e/support/helpers"; @@ -759,9 +760,7 @@ function openSlackCreationForm() { } function openRecipientsWithUserVisibilitySetting(setting) { - cy.request("PUT", "/api/setting/user-visibility", { - value: setting, - }); + updateSetting("user-visibility", setting); cy.signInAsNormalUser(); openDashboardSubscriptions(); diff --git a/e2e/test/scenarios/stats/instance-stats-snowplow.cy.spec.js b/e2e/test/scenarios/stats/instance-stats-snowplow.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a2423cf64d6f601332823ee83faa8c008c4ced63 --- /dev/null +++ b/e2e/test/scenarios/stats/instance-stats-snowplow.cy.spec.js @@ -0,0 +1,36 @@ +import { + describeEE, + describeWithSnowplow, + enableTracking, + expectGoodSnowplowEvent, + expectNoBadSnowplowEvents, + resetSnowplow, + restore, +} from "e2e/support/helpers"; + +describeWithSnowplow("scenarios > stats > snowplow", () => { + beforeEach(() => { + restore(); + resetSnowplow(); + cy.signInAsAdmin(); + enableTracking(); + }); + + describe("instance stats", () => { + it("should send a snowplow event when the stats ping is triggered on OSS", () => { + cy.request("POST", "api/testing/stats"); + expectGoodSnowplowEvent(); + }); + }); + + describeEE("instance stats", () => { + it("should send a snowplow event when the stats ping is triggered on EE", () => { + cy.request("POST", "api/testing/stats"); + expectGoodSnowplowEvent(); + }); + }); + + afterEach(() => { + expectNoBadSnowplowEvents(); + }); +}); diff --git a/e2e/test/scenarios/visualizations-charts/maps.cy.spec.js b/e2e/test/scenarios/visualizations-charts/maps.cy.spec.js index 6fdb4e13828d655c54d0b924238b854fb2eb4793..125c161abf04b9d54cb3825cd0d5ebb3e193ef84 100644 --- a/e2e/test/scenarios/visualizations-charts/maps.cy.spec.js +++ b/e2e/test/scenarios/visualizations-charts/maps.cy.spec.js @@ -27,7 +27,7 @@ describe("scenarios > visualizations > maps", () => { // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.contains("Visualization").click(); cy.icon("pinmap").click(); - cy.findByTestId("Map-button").within(() => { + cy.findByTestId("Map-container").within(() => { cy.icon("gear").click(); }); diff --git a/e2e/test/scenarios/visualizations-charts/scatter.cy.spec.js b/e2e/test/scenarios/visualizations-charts/scatter.cy.spec.js index 74612c2db25b2d9f05de4d9f64e9f6214dc32699..f8e7c4493f7a342e61d859b35e7be04bf7c1af37 100644 --- a/e2e/test/scenarios/visualizations-charts/scatter.cy.spec.js +++ b/e2e/test/scenarios/visualizations-charts/scatter.cy.spec.js @@ -2,7 +2,9 @@ import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { assertEChartsTooltip, + assertEChartsTooltipNotContain, cartesianChartCircle, + leftSidebar, restore, visitQuestionAdhoc, } from "e2e/support/helpers"; @@ -43,11 +45,8 @@ describe("scenarios > visualizations > scatter", () => { triggerPopoverForBubble(); assertEChartsTooltip({ + header: "May 2023", rows: [ - { - name: "Created At", - value: "May 2023", - }, { name: "Count", value: "271", @@ -80,11 +79,8 @@ describe("scenarios > visualizations > scatter", () => { triggerPopoverForBubble(); assertEChartsTooltip({ + header: "May 2023", rows: [ - { - name: "Created At", - value: "May 2023", - }, { name: "Orders count", value: "271", @@ -145,6 +141,48 @@ select 10 as size, 2 as x, 5 as y`, }); }); }); + + it("should allow adding non-series columns to the tooltip", () => { + visitQuestionAdhoc({ + display: "scatter", + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { "source-table": ORDERS_ID }, + }, + visualization_settings: { + "graph.metrics": ["TAX"], + "graph.dimensions": ["SUBTOTAL"], + }, + }); + + cartesianChartCircle().first().realHover(); + assertEChartsTooltip({ + header: "15.69", + rows: [{ name: "Tax", value: "0.86" }], + }); + assertEChartsTooltipNotContain(["Total", "Discount", "Quantity"]); + + cy.findByTestId("viz-settings-button").click(); + + leftSidebar().within(() => { + cy.findByText("Display").click(); + cy.findByPlaceholderText("Enter metric names").click(); + }); + cy.findByRole("option", { name: "Total" }).click(); + cy.findByRole("option", { name: "Discount" }).click(); + + cartesianChartCircle().first().realHover(); + assertEChartsTooltip({ + header: "15.69", + rows: [ + { name: "Tax", value: "0.86" }, + { name: "Total", value: "16.55" }, + { name: "Discount", value: "(empty)" }, + ], + }); + assertEChartsTooltipNotContain(["Quantity"]); + }); }); function triggerPopoverForBubble(index = 13) { diff --git a/e2e/test/scenarios/visualizations-charts/waterfall.cy.spec.js b/e2e/test/scenarios/visualizations-charts/waterfall.cy.spec.js index 340fa886fd082f81b4ecbaf9e7827bb89cab7bf5..ea90e7a25f6392d528a864ff95f5ce503b8d02f8 100644 --- a/e2e/test/scenarios/visualizations-charts/waterfall.cy.spec.js +++ b/e2e/test/scenarios/visualizations-charts/waterfall.cy.spec.js @@ -2,10 +2,14 @@ import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { assertEChartsTooltip, + assertEChartsTooltipNotContain, chartPathWithFillColor, + createQuestion, echartsContainer, + leftSidebar, openNativeEditor, openOrdersTable, + popover, restore, summarize, visitQuestionAdhoc, @@ -138,32 +142,79 @@ describe("scenarios > visualizations > waterfall", () => { echartsContainer().get("text").contains("Total").should("not.exist"); }); - it("should show error for multi-series questions (metabase#15152)", () => { - visitQuestionAdhoc({ - dataset_query: { - type: "query", - query: { - "source-table": ORDERS_ID, - aggregation: [["count"], ["sum", ["field-id", ORDERS.TOTAL]]], - breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }]], - }, - database: SAMPLE_DB_ID, + describe("multi-series (metabase#15152)", () => { + const DATASET_QUERY = { + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"], ["sum", ["field-id", ORDERS.TOTAL]]], + breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }]], }, - display: "line", - }); + database: SAMPLE_DB_ID, + }; - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("Visualization").click(); - switchToWaterfallDisplay(); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("Waterfall chart does not support multiple series"); + function testSwitchingToWaterfall() { + cy.findByTestId("viz-type-button").click(); + switchToWaterfallDisplay(); - echartsContainer().should("not.exist"); - cy.findByTestId("remove-count").click(); - echartsContainer().should("exist"); // Chart renders after removing the second metric + echartsContainer().within(() => { + cy.findByText("Created At").should("exist"); // x-axis + cy.findByText("Count").should("exist"); // y-axis + cy.findByText("Sum of Total").should("not.exist"); + + // x-axis labels (some) + ["2022", "2023", "2026", "Total"].forEach(label => { + cy.findByText(label).should("exist"); + }); + + // y-axis labels (some) + ["0", "3,000", "6,000", "18,000", "21,000"].forEach(label => { + cy.findByText(label).should("exist"); + }); + }); + + leftSidebar().within(() => { + cy.findByText("Count").should("exist"); + cy.findByText("Sum of Total").should("not.exist"); + cy.findByText(/Add another/).should("not.exist"); + + cy.findByText("Count").click(); + }); + popover().findByText("Sum of Total").click(); + leftSidebar().within(() => { + cy.findByText("Sum of Total").should("exist"); + cy.findByText("Count").should("not.exist"); + }); + + echartsContainer().within(() => { + cy.findByText("Sum of Total").should("exist"); // x-axis + cy.findByText("Created At").should("exist"); // y-axis + cy.findByText("Count").should("not.exist"); + + // x-axis labels (some) + ["2022", "2023", "2026", "Total"].forEach(label => { + cy.findByText(label).should("exist"); + }); + + // y-axis labels (some) + ["0", "300,000", "900,000", "1,800,000"].forEach(label => { + cy.findByText(label).should("exist"); + }); + }); + } - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText(/Add another/).should("not.exist"); + it("should correctly switch into single-series mode for ad-hoc queries", () => { + visitQuestionAdhoc({ dataset_query: DATASET_QUERY, display: "line" }); + testSwitchingToWaterfall(); + }); + + it("should correctly switch into single-series mode for ad-hoc queries", () => { + createQuestion( + { name: "Q1", query: DATASET_QUERY.query, display: "line" }, + { visitQuestion: true }, + ); + testSwitchingToWaterfall(); + }); }); it("should not allow you to choose X-axis breakout", () => { @@ -305,10 +356,6 @@ describe("scenarios > visualizations > waterfall", () => { assertEChartsTooltip({ rows: [ - { - name: "C1", - value: "a", - }, { name: "C2", value: "0.2", @@ -317,6 +364,54 @@ describe("scenarios > visualizations > waterfall", () => { }); }); + it("should allow adding non-series columns to the tooltip", () => { + const INCREASE_COLOR = "#00FF00"; + + function getFirstWaterfallSegment() { + return echartsContainer().find(`path[fill='${INCREASE_COLOR}']`).first(); + } + + visitQuestionAdhoc({ + display: "waterfall", + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + aggregation: [["count"], ["sum", ["field-id", ORDERS.TOTAL]]], + breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }]], + }, + }, + visualization_settings: { + "waterfall.increase_color": INCREASE_COLOR, + }, + }); + + getFirstWaterfallSegment().realHover(); + assertEChartsTooltip({ + header: "2022", + rows: [{ name: "Count", value: "744", color: INCREASE_COLOR }], + }); + assertEChartsTooltipNotContain(["Sum of Total"]); + + cy.findByTestId("viz-settings-button").click(); + + leftSidebar().within(() => { + cy.findByText("Display").click(); + cy.findByPlaceholderText("Enter metric names").click(); + }); + cy.findByRole("option", { name: "Sum of Total" }).click(); + + getFirstWaterfallSegment().realHover(); + assertEChartsTooltip({ + header: "2022", + rows: [ + { name: "Count", value: "744", color: INCREASE_COLOR }, + { name: "Sum of Total", value: "42,156.87" }, + ], + }); + }); + describe("scenarios > visualizations > waterfall settings", () => { beforeEach(() => { restore(); @@ -369,7 +464,7 @@ describe("scenarios > visualizations > waterfall", () => { const switchToWaterfallDisplay = () => { cy.icon("waterfall").click(); - cy.findByTestId("Waterfall-button").within(() => { + cy.findByTestId("Waterfall-container").within(() => { cy.icon("gear").click(); }); }; diff --git a/e2e/test/scenarios/visualizations-tabular/drillthroughs/column_extract_drill.cy.spec.js b/e2e/test/scenarios/visualizations-tabular/drillthroughs/column_extract_drill.cy.spec.js index 9617ad422e7b547d875efa5176e95e3e08bc33c4..d3c157b59e034cb540f40a3662bbbfe06534e9ea 100644 --- a/e2e/test/scenarios/visualizations-tabular/drillthroughs/column_extract_drill.cy.spec.js +++ b/e2e/test/scenarios/visualizations-tabular/drillthroughs/column_extract_drill.cy.spec.js @@ -204,7 +204,7 @@ describeWithSnowplow("extract action", () => { it("should add an expression based on an aggregation column", () => { cy.createQuestion(DATE_QUESTION, { visitQuestion: true }); extractColumnAndCheck({ - column: "Min of Created At: Default", + column: "Min of Created At", option: "Year", value: "2,022", extraction: "Extract day, month…", diff --git a/enterprise/backend/src/metabase_enterprise/advanced_config/models/pulse_channel.clj b/enterprise/backend/src/metabase_enterprise/advanced_config/models/pulse_channel.clj index 5bc61de21ddc6a85eb9158710588d06204ece77a..addac1ae095bb1861f000fdd1d55085e6fed1d67 100644 --- a/enterprise/backend/src/metabase_enterprise/advanced_config/models/pulse_channel.clj +++ b/enterprise/backend/src/metabase_enterprise/advanced_config/models/pulse_channel.clj @@ -8,6 +8,7 @@ (defsetting subscription-allowed-domains (deferred-tru "Allowed email address domain(s) for new Dashboard Subscriptions and Alerts. To specify multiple domains, separate each domain with a comma, with no space in between. To allow all domains, leave the field empty. This setting doesn’t affect existing subscriptions.") + :encryption :no :visibility :public :export? true :feature :email-allow-list diff --git a/enterprise/backend/src/metabase_enterprise/enhancements/integrations/ldap.clj b/enterprise/backend/src/metabase_enterprise/enhancements/integrations/ldap.clj index cae4a48bc4c5fab80bf81bf8cbf7d8c7ca0f6820..2095616f8ecbfc63b915c8851437bcd05dd988bc 100644 --- a/enterprise/backend/src/metabase_enterprise/enhancements/integrations/ldap.clj +++ b/enterprise/backend/src/metabase_enterprise/enhancements/integrations/ldap.clj @@ -29,14 +29,16 @@ ;; TODO - maybe we want to add a csv setting type? (defsetting ldap-sync-user-attributes-blacklist (deferred-tru "Comma-separated list of user attributes to skip syncing for LDAP users.") - :default "userPassword,dn,distinguishedName" - :type :csv - :audit :getter) + :encryption :no + :default "userPassword,dn,distinguishedName" + :type :csv + :audit :getter) (defsetting ldap-group-membership-filter (deferred-tru "Group membership lookup filter. The placeholders '{dn}' and '{uid}' will be replaced by the user''s Distinguished Name and UID, respectively.") - :default "(member={dn})" - :audit :getter) + :encryption :no + :default "(member={dn})" + :audit :getter) (defn- syncable-user-attributes [m] (when (ldap-sync-user-attributes) diff --git a/enterprise/backend/src/metabase_enterprise/llm/settings.clj b/enterprise/backend/src/metabase_enterprise/llm/settings.clj index 021e7472f6a8c98caa53198d0532ee12e86e641f..ccf64778262f61cfef090e7fe1e1b389d903dcbc 100644 --- a/enterprise/backend/src/metabase_enterprise/llm/settings.clj +++ b/enterprise/backend/src/metabase_enterprise/llm/settings.clj @@ -5,6 +5,7 @@ (defsetting ee-openai-model (deferred-tru "The OpenAI Model (e.g. 'gpt-4', 'gpt-3.5-turbo')") + :encryption :no :visibility :settings-manager :default "gpt-4-turbo-preview" :export? false @@ -12,6 +13,7 @@ (defsetting ee-openai-api-key (deferred-tru "The OpenAI API Key used in Metabase Enterprise.") + :encryption :no :visibility :settings-manager :export? false :doc "This feature is experimental.") diff --git a/enterprise/backend/src/metabase_enterprise/sso/integrations/sso_settings.clj b/enterprise/backend/src/metabase_enterprise/sso/integrations/sso_settings.clj index 7119447eb942849e582604c825c06259f53d5fdc..d20a655efa392644c0bbd8589fdb94565d4eea9b 100644 --- a/enterprise/backend/src/metabase_enterprise/sso/integrations/sso_settings.clj +++ b/enterprise/backend/src/metabase_enterprise/sso/integrations/sso_settings.clj @@ -54,8 +54,9 @@ don''t have one.") (defsetting saml-identity-provider-uri (deferred-tru "This is the URL where your users go to log in to your identity provider. Depending on which IdP you''re using, this usually looks like `https://your-org-name.example.com` or `https://example.com/app/my_saml_app/abc123/sso/saml`") - :feature :sso-saml - :audit :getter) + :encryption :when-encryption-key-set + :feature :sso-saml + :audit :getter) (mu/defn- validate-saml-idp-cert "Validate that an encoded identity provider certificate is valid, or throw an Exception." @@ -70,33 +71,38 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e (defsetting saml-identity-provider-certificate (deferred-tru "Encoded certificate for the identity provider. Depending on your IdP, you might need to download this, open it in a text editor, then copy and paste the certificate's contents here.") - :feature :sso-saml - :audit :no-value - :setter (fn [new-value] - ;; when setting the idp cert validate that it's something we - (when new-value - (validate-saml-idp-cert new-value)) - (setting/set-value-of-type! :string :saml-identity-provider-certificate new-value))) + :feature :sso-saml + :audit :no-value + :encryption :no + :setter (fn [new-value] + ;; when setting the idp cert validate that it's something we + (when new-value + (validate-saml-idp-cert new-value)) + (setting/set-value-of-type! :string :saml-identity-provider-certificate new-value))) (defsetting saml-identity-provider-issuer (deferred-tru "This is a unique identifier for the IdP. Often referred to as Entity ID or simply 'Issuer'. Depending on your IdP, this usually looks something like `http://www.example.com/141xkex604w0Q5PN724v`") - :feature :sso-saml - :audit :getter) + :encryption :when-encryption-key-set + :feature :sso-saml + :audit :getter) (defsetting saml-application-name (deferred-tru "This application name will be used for requests to the Identity Provider") - :default "Metabase" - :feature :sso-saml - :audit :getter) + :default "Metabase" + :feature :sso-saml + :audit :getter + :encryption :when-encryption-key-set) (defsetting saml-keystore-path (deferred-tru "Absolute path to the Keystore file to use for signing SAML requests") - :feature :sso-saml - :audit :getter) + :encryption :when-encryption-key-set + :feature :sso-saml + :audit :getter) (defsetting saml-keystore-password (deferred-tru "Password for opening the keystore") + :encryption :when-encryption-key-set :default "changeit" :sensitive? true :feature :sso-saml @@ -105,27 +111,31 @@ on your IdP, this usually looks something like `http://www.example.com/141xkex60 (defsetting saml-keystore-alias (deferred-tru "Alias for the key that {0} should use for signing SAML requests" (public-settings/application-name-for-setting-descriptions)) - :default "metabase" - :feature :sso-saml - :audit :getter) + :encryption :when-encryption-key-set + :default "metabase" + :feature :sso-saml + :audit :getter) (defsetting saml-attribute-email (deferred-tru "SAML attribute for the user''s email address") - :default "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" - :feature :sso-saml - :audit :getter) + :default "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + :feature :sso-saml + :encryption :when-encryption-key-set + :audit :getter) (defsetting saml-attribute-firstname (deferred-tru "SAML attribute for the user''s first name") - :default "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" - :feature :sso-saml - :audit :getter) + :default "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" + :encryption :when-encryption-key-set + :feature :sso-saml + :audit :getter) (defsetting saml-attribute-lastname (deferred-tru "SAML attribute for the user''s last name") - :default "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" - :feature :sso-saml - :audit :getter) + :default "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" + :encryption :when-encryption-key-set + :feature :sso-saml + :audit :getter) (defsetting saml-group-sync (deferred-tru "Enable group membership synchronization with SAML.") @@ -136,21 +146,23 @@ on your IdP, this usually looks something like `http://www.example.com/141xkex60 (defsetting saml-attribute-group (deferred-tru "SAML attribute for group syncing") - :default "member_of" - :feature :sso-saml - :audit :getter) + :default "member_of" + :feature :sso-saml + :audit :getter + :encryption :when-encryption-key-set) (defsetting saml-group-mappings ;; Should be in the form: {"groupName": [1, 2, 3]} where keys are SAML groups and values are lists of MB groups IDs (deferred-tru "JSON containing SAML to {0} group mappings." (public-settings/application-name-for-setting-descriptions)) - :type :json - :cache? false - :default {} - :feature :sso-saml - :audit :getter - :setter (comp (partial setting/set-value-of-type! :json :saml-group-mappings) - (partial mu/validate-throw validate-group-mappings))) + :encryption :when-encryption-key-set + :type :json + :cache? false + :default {} + :feature :sso-saml + :audit :getter + :setter (comp (partial setting/set-value-of-type! :json :saml-group-mappings) + (partial mu/validate-throw validate-group-mappings))) (defsetting saml-configured (deferred-tru "Are the mandatory SAML settings configured?") @@ -187,40 +199,46 @@ on your IdP, this usually looks something like `http://www.example.com/141xkex60 (defsetting jwt-identity-provider-uri (deferred-tru "URL of JWT based login page") - :feature :sso-jwt - :audit :getter) + :encryption :when-encryption-key-set + :feature :sso-jwt + :audit :getter) (defsetting jwt-shared-secret (deferred-tru (str "String used to seed the private key used to validate JWT messages." " " "A hexadecimal-encoded 256-bit key (i.e., a 64-character string) is strongly recommended.")) - :type :string - :feature :sso-jwt - :audit :no-value) + :encryption :when-encryption-key-set + :type :string + :feature :sso-jwt + :audit :no-value) (defsetting jwt-attribute-email (deferred-tru "Key to retrieve the JWT user's email address") - :default "email" - :feature :sso-jwt - :audit :getter) + :encryption :when-encryption-key-set + :default "email" + :feature :sso-jwt + :audit :getter) (defsetting jwt-attribute-firstname (deferred-tru "Key to retrieve the JWT user's first name") - :default "first_name" - :feature :sso-jwt - :audit :getter) + :encryption :when-encryption-key-set + :default "first_name" + :feature :sso-jwt + :audit :getter) (defsetting jwt-attribute-lastname (deferred-tru "Key to retrieve the JWT user's last name") - :default "last_name" - :feature :sso-jwt - :audit :getter) + :encryption :when-encryption-key-set + :default "last_name" + :feature :sso-jwt + :audit :getter) (defsetting jwt-attribute-groups (deferred-tru "Key to retrieve the JWT user's groups") - :default "groups" - :feature :sso-jwt - :audit :getter) + :default "groups" + :feature :sso-jwt + :encryption :when-encryption-key-set + :audit :getter) (defsetting jwt-group-sync (deferred-tru "Enable group membership synchronization with JWT.") @@ -233,14 +251,15 @@ on your IdP, this usually looks something like `http://www.example.com/141xkex60 ;; Should be in the form: {"groupName": [1, 2, 3]} where keys are JWT groups and values are lists of MB groups IDs (deferred-tru "JSON containing JWT to {0} group mappings." (public-settings/application-name-for-setting-descriptions)) - :type :json - :cache? false - :default {} - :feature :sso-jwt - :audit :getter - :setter (comp (partial setting/set-value-of-type! :json :jwt-group-mappings) - (partial mu/validate-throw validate-group-mappings)) - :doc "JSON object containing JWT to Metabase group mappings, where keys are JWT groups and values are lists of Metabase groups IDs.") + :encryption :when-encryption-key-set + :type :json + :cache? false + :default {} + :feature :sso-jwt + :audit :getter + :setter (comp (partial setting/set-value-of-type! :json :jwt-group-mappings) + (partial mu/validate-throw validate-group-mappings)) + :doc "JSON object containing JWT to Metabase group mappings, where keys are JWT groups and values are lists of Metabase groups IDs.") (defsetting jwt-configured (deferred-tru "Are the mandatory JWT settings configured?") diff --git a/enterprise/backend/src/metabase_enterprise/stats.clj b/enterprise/backend/src/metabase_enterprise/stats.clj new file mode 100644 index 0000000000000000000000000000000000000000..cf7011a0009aa7b59b8d06f9e4a83eddb8d3e78b --- /dev/null +++ b/enterprise/backend/src/metabase_enterprise/stats.clj @@ -0,0 +1,30 @@ +(ns metabase-enterprise.stats + (:require + [metabase-enterprise.advanced-config.models.pulse-channel :as advanced-config.models.pulse-channel] + [metabase-enterprise.scim.api :as scim-api] + [metabase-enterprise.sso.integrations.sso-settings :as sso-settings] + [metabase.driver :as driver] + [metabase.public-settings.premium-features :as premium-features :refer [defenterprise]] + [toucan2.core :as t2])) + +(defenterprise ee-snowplow-features-data + "A subset of feature information included in the daily Snowplow stats report. This function only returns information + about features which require calling EE code; other features are defined in [[metabase.analytics.stats/snowplow-features]]" + :feature :none + [] + [{:name :sso-jwt + :available (premium-features/enable-sso-jwt?) + :enabled (sso-settings/jwt-enabled)} + {:name :sso-saml + :available (premium-features/enable-sso-saml?) + :enabled (sso-settings/saml-enabled)} + {:name :scim + :available (premium-features/enable-scim?) + :enabled (boolean (scim-api/scim-enabled))} + {:name :sandboxes + :available (and (premium-features/enable-official-collections?) + (t2/exists? :model/Database :engine [:in (descendants driver/hierarchy :sql)])) + :enabled (t2/exists? :model/GroupTableAccessPolicy)} + {:name :email-allow-list + :available (premium-features/enable-email-allow-list?) + :enabled (boolean (some? (advanced-config.models.pulse-channel/subscription-allowed-domains)))}]) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj index 9ed4550a4f69e9150cb9116d0c85e04078d1a616..1928f868779b9ed6a18bab21d4875a039a5bd663 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj @@ -13,7 +13,8 @@ (defsetting config-from-file-settings-test-setting "Internal test setting." - :visibility :internal) + :visibility :internal + :encryption :no) (deftest settings-test (testing "Should be able to set settings with config-from-file" diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/row_level_restrictions_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/row_level_restrictions_test.clj index deb146fe8fa8e4aab6b7f09f390e50065fb4d2d0..8f94870b413fa738b7a5e2a49e5693919d8fb16b 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/row_level_restrictions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/row_level_restrictions_test.clj @@ -185,8 +185,15 @@ (is (=? (mt/query checkins {:type :query :query {:source-query {:source-table $$checkins - :fields [$id !default.$date $user_id $venue_id] + :fields [$id $date $user_id $venue_id] :filter [:and + ;; This still gets :default bucketing! + ;; auto-bucket-datetimes puts :day bucketing + ;; on both parts of this filter, since it's + ;; matching a YYYY-mm-dd string. Then + ;; optimize-temporal-filters sees that the + ;; :type/Date column already has :day + ;; granularity, and switches both to :default [:> !default.date [:absolute-datetime #t "2014-01-01" :default]] diff --git a/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj index 721904764385bf33efe4bff72f155dfacf8238c5..80f0fa3d4df1027ff2c6dcd5d153bd517fe6cd4f 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj @@ -318,6 +318,7 @@ ;; same native form on one database, then it's likely they would on any, since that is ;; orthogonal to the issues that serialization has when performing this roundtrip). (disj :oracle ; no bare table names allowed + :databricks ; table name requires schema prefix with current implementation :redshift ; bare table name doesn't work; it's test_data_venues instead of venues :snowflake ; bare table name doesn't work; it's test_data_venues instead of venues :sqlserver ; ORDER BY not allowed not allowed in derived tables (subselects) diff --git a/enterprise/backend/test/metabase_enterprise/stats_test.clj b/enterprise/backend/test/metabase_enterprise/stats_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..0aaf241a052eb47f5f8db671869449a7ef91c661 --- /dev/null +++ b/enterprise/backend/test/metabase_enterprise/stats_test.clj @@ -0,0 +1,11 @@ +(ns metabase-enterprise.stats-test + (:require + [clojure.test :refer :all] + [metabase-enterprise.stats :as ee-stats] + [metabase.analytics.stats :as stats])) + +(deftest ee-snowplow-features-test + (testing "Every feature returned by `ee-snowplow-features-data` has a corresponding OSS fallback" + (let [ee-features (map :name (ee-stats/ee-snowplow-features-data)) + oss-features (map :name (@#'stats/ee-snowplow-features-data'))] + (is (= (sort ee-features) (sort oss-features)))))) diff --git a/enterprise/frontend/src/embedding-sdk/README.md b/enterprise/frontend/src/embedding-sdk/README.md index 37acbedd58e093cd43612691e82043f5e843b3f5..321c06ddc8ed88af468a5bc25b1e54734927ce9d 100644 --- a/enterprise/frontend/src/embedding-sdk/README.md +++ b/enterprise/frontend/src/embedding-sdk/README.md @@ -21,7 +21,8 @@ Features currently supported: - embedding dashboards - static - embedding dashboards - w/drill-down - embedding the collection browser -- ability for the user to modify existing questions +- Add new questions +- Modify existing questions - theming with CSS variables - plugins for custom actions, overriding dashboard card menu items - subscribing to events @@ -299,10 +300,26 @@ documentation for more information. ### Embedding an interactive question (with drill-down) - **questionId**: `number | string` (required) – The ID of the question. This is either: - - the numerical ID when accessing a question - link, i.e. `http://localhost:3000/question/1-my-question` where the ID is `1` - - the string ID found in the `entity_id` key of the question object when using the API directly or using the SDK - Collection Browser to return data + + - the numerical ID when accessing a question link, i.e., `http://localhost:3000/question/1-my-question` where the ID is `1` + - the string ID found in the `entity_id` key of the question object when using the API directly or using the SDK Collection Browser to return data + +- **plugins**: `{ mapQuestionClickActions: Function } | null` – Additional mapper function to override or add + drill-down menu. [See this section](#implementing-custom-actions) for more details +- **height**: `number | string` (optional) – A number or string specifying a CSS size value that specifies the height of the component +- **entityTypeFilter**: `("table" | "question" | "model" | "metric")[]` (optional) – An array that specifies which entity types are available to the user in the data picker +- **isSaveEnabled**: `boolean` (optional) – Determines if the save functionality is enabled. + +_Note: These props are only used when using the _ + +- **withResetButton**: `boolean` (optional, default: `true`) – Determines whether a reset button is displayed. +- **withTitle**: `boolean` (optional, default: `false`) – Determines whether the question title is displayed. +- **customTitle**: `string | undefined` (optional) – Allows a custom title to be displayed instead of the default question title. + +_Note: Only enabled when `isSaveEnabled = true`_ + +- **onBeforeSave**: `() => void` (optional) – A callback function that triggers before saving. +- **onSave**: `() => void` (optional) – A callback function that triggers when a user saves the question ```typescript jsx import React from "react"; @@ -507,7 +524,13 @@ With the `CreateQuestion` component, you can create a new question from scratch - **plugins**: `{ mapQuestionClickActions: Function } | null` – Additional mapper function to override or add drill-down menu. [See this section](#implementing-custom-actions) for more details +- **entityTypeFilter**: `("table" | "question" | "model" | "metric")[]` (optional) - An array that specifies which entity types are available to the user in the data picker +- **isSaveEnabled**: `boolean` (optional) – Determines if the save functionality is enabled. + +_Note: Only enabled when `isSaveEnabled = true`_ +- **onBeforeSave**: `() => void` (optional) – A callback function that triggers before saving. +- **onSave**: `() => void` (optional) – A callback function that triggers when a user saves the question ```tsx import React from "react"; import {MetabaseProvider, CreateQuestion} from "@metabase/embedding-sdk-react"; @@ -537,7 +560,13 @@ With the `ModifyQuestion` component, you can edit an existing question using the Collection Browser to return data - **plugins**: `{ mapQuestionClickActions: Function } | null` – Additional mapper function to override or add drill-down menu. [See this section](#implementing-custom-actions) for more details +- **entityTypeFilter**: `("table" | "question" | "model" | "metric")[]` (optional) - An array that specifies which entity types are available to the user in the data picker +- **isSaveEnabled**: `boolean` (optional) – Determines if the save functionality is enabled. + +_Note: Only enabled when `isSaveEnabled = true`_ +- **onBeforeSave**: `() => void` (optional) – A callback function that triggers before saving. +- **onSave**: `() => void` (optional) – A callback function that triggers when a user saves the question ```tsx import React from "react"; import {MetabaseProvider, ModifyQuestion} from "@metabase/embedding-sdk-react"; diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/Notebook.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/Notebook.tsx index ebd37b3e7a7b8c8bad21a670556db0da2794ca24..093ec2b3512ec70dc99f2642a87a4f8931e2c888 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/Notebook.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/Notebook.tsx @@ -18,8 +18,13 @@ export const Notebook = ({ onApply = () => {} }: NotebookProps) => { // Loads databases and metadata so we can show notebook steps for the selected data source useDatabaseListQuery(); - const { question, originalQuestion, updateQuestion, runQuestion } = - useInteractiveQuestionContext(); + const { + question, + originalQuestion, + updateQuestion, + runQuestion, + modelsFilterList, + } = useInteractiveQuestionContext(); const isDirty = useMemo(() => { return isQuestionDirty(question, originalQuestion); @@ -53,6 +58,7 @@ export const Notebook = ({ onApply = () => {} }: NotebookProps) => { }} setQueryBuilderMode={() => {}} hasVisualizeButton={true} + modelsFilterList={modelsFilterList} /> </ScrollArea> ) diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/context/InteractiveQuestionProvider.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/context/InteractiveQuestionProvider.tsx index d117f112d9b6f90ffbada285da609ceb260e444b..15a7cd6648acf3b524aa22e278cb35014db4fac3 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/context/InteractiveQuestionProvider.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/context/InteractiveQuestionProvider.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useEffect, useMemo } from "react"; import { useLoadQuestion } from "embedding-sdk/hooks/private/use-load-question"; import { useSdkSelector } from "embedding-sdk/store"; import { getPlugins } from "embedding-sdk/store/selectors"; +import type { DataPickerValue } from "metabase/common/components/DataPicker"; import { useValidatedEntityId } from "metabase/lib/entity-id/hooks/use-validated-entity-id"; import { useCreateQuestion } from "metabase/query_builder/containers/use-create-question"; import { useSaveQuestion } from "metabase/query_builder/containers/use-save-question"; @@ -10,6 +11,7 @@ import { getEmbeddingMode } from "metabase/visualizations/click-actions/lib/mode import type Question from "metabase-lib/v1/Question"; import type { + EntityTypeFilterKeys, InteractiveQuestionContextType, InteractiveQuestionProviderProps, } from "./types"; @@ -26,6 +28,19 @@ export const InteractiveQuestionContext = createContext< const DEFAULT_OPTIONS = {}; +const FILTER_MODEL_MAP: Record<EntityTypeFilterKeys, DataPickerValue["model"]> = + { + table: "table", + question: "card", + model: "dataset", + metric: "metric", + }; +const mapEntityTypeFilterToDataPickerModels = ( + entityTypeFilter: InteractiveQuestionProviderProps["entityTypeFilter"], +): InteractiveQuestionContextType["modelsFilterList"] => { + return entityTypeFilter?.map(entityType => FILTER_MODEL_MAP[entityType]); +}; + export const InteractiveQuestionProvider = ({ cardId: initId, options = DEFAULT_OPTIONS, @@ -36,6 +51,7 @@ export const InteractiveQuestionProvider = ({ onBeforeSave, onSave, isSaveEnabled = true, + entityTypeFilter, }: InteractiveQuestionProviderProps) => { const { id: cardId, isLoading: isLoadingValidatedId } = useValidatedEntityId({ type: "card", @@ -105,6 +121,7 @@ export const InteractiveQuestionProvider = ({ onSave: handleSave, onCreate: handleCreate, isSaveEnabled, + modelsFilterList: mapEntityTypeFilterToDataPickerModels(entityTypeFilter), }; useEffect(() => { diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/context/types.ts b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/context/types.ts index 9dcbffbac7e587b26c679859ec2bec35e99d9063..7aff1073e9f7c84e51178e0b5d7638febe986297 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/context/types.ts +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/context/types.ts @@ -3,16 +3,19 @@ import type { PropsWithChildren } from "react"; import type { SdkPluginsConfig } from "embedding-sdk"; import type { LoadQuestionHookResult } from "embedding-sdk/hooks/private/use-load-question"; import type { LoadSdkQuestionParams } from "embedding-sdk/types/question"; +import type { NotebookProps as QBNotebookProps } from "metabase/querying/notebook/components/Notebook"; import type { Mode } from "metabase/visualizations/click-actions/Mode"; import type Question from "metabase-lib/v1/Question"; import type { CardEntityId, CardId } from "metabase-types/api"; +export type EntityTypeFilterKeys = "table" | "question" | "model" | "metric"; export type InteractiveQuestionConfig = { componentPlugins?: SdkPluginsConfig; onNavigateBack?: () => void; onBeforeSave?: (question?: Question) => Promise<void>; onSave?: (question?: Question) => void; isSaveEnabled?: boolean; + entityTypeFilter?: EntityTypeFilterKeys[]; }; export type QuestionMockLocationParameters = { @@ -33,7 +36,8 @@ export type InteractiveQuestionContextType = Omit< LoadQuestionHookResult, "loadQuestion" > & - Pick<InteractiveQuestionConfig, "onNavigateBack" | "isSaveEnabled"> & { + Pick<InteractiveQuestionConfig, "onNavigateBack" | "isSaveEnabled"> & + Pick<QBNotebookProps, "modelsFilterList"> & { plugins: InteractiveQuestionConfig["componentPlugins"] | null; mode: Mode | null | undefined; resetQuestion: () => void; diff --git a/enterprise/frontend/src/embedding-sdk/components/private/PublicComponentStylesWrapper.tsx b/enterprise/frontend/src/embedding-sdk/components/private/PublicComponentStylesWrapper.tsx index 2cc306f7c1bfb3ce32b413ed1db25eb9a36a1358..51077535c42aea1d470ff05e1764c17434ecc923 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/PublicComponentStylesWrapper.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/PublicComponentStylesWrapper.tsx @@ -1,3 +1,4 @@ +import { css } from "@emotion/react"; import styled from "@emotion/styled"; import { aceEditorStyles } from "metabase/query_builder/components/NativeQueryEditor/NativeQueryEditor.styled"; @@ -13,18 +14,6 @@ export const PublicComponentStylesWrapper = styled.div` all: initial; text-decoration: none; - // # Basic css reset - // We can't apply a global css reset as it would leak into the host app - // but we can't also apply our entire css reset scoped to this container, - // as it would be of higher specificity than some of our styles. - // We'll have to hand pick the css resets that we neeed - - button { - border: 0; - background-color: transparent; - } - // end of RESET - font-style: normal; width: 100%; @@ -48,3 +37,19 @@ export const PublicComponentStylesWrapper = styled.div` display: inline; } `; +/** + * We can't apply a global css reset as it would leak into the host app but we + * can't also apply our entire css reset scoped to this container, as it would + * be of higher specificity than some of our styles. + * + * The reason why this works is two things combined: + * - `*:where(button)` doesn't increase specificity, so the resulting specificity is (0,1,0) + * - this global css is loaded in the provider, before our other styles + * - -> our other code with specificity (0,1,0) will override this as they're loaded after + */ +export const SCOPED_CSS_RESET = css` + ${PublicComponentStylesWrapper} *:where(button) { + border: 0; + background-color: transparent; + } +`; diff --git a/enterprise/frontend/src/embedding-sdk/components/private/QuestionEditor/QuestionEditor.tsx b/enterprise/frontend/src/embedding-sdk/components/private/QuestionEditor/QuestionEditor.tsx index 7c7b8430774dd031680089cac260f3cf1cf86510..1ba08fc28808712195ac2456b300147acece5e17 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/QuestionEditor/QuestionEditor.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/QuestionEditor/QuestionEditor.tsx @@ -94,6 +94,7 @@ export const QuestionEditor = ({ onBeforeSave, onSave, plugins, + entityTypeFilter, }: InteractiveQuestionProps) => ( <InteractiveQuestion questionId={questionId} @@ -101,6 +102,7 @@ export const QuestionEditor = ({ onSave={onSave} onBeforeSave={onBeforeSave} isSaveEnabled={isSaveEnabled} + entityTypeFilter={entityTypeFilter} > <QuestionEditorInner /> </InteractiveQuestion> diff --git a/enterprise/frontend/src/embedding-sdk/components/private/SdkThemeProvider.tsx b/enterprise/frontend/src/embedding-sdk/components/private/SdkThemeProvider.tsx index 3e6c9d78b19b1f6c30752cdb7deeb846943e21dd..f29721404d49632a0ea52c8d4c352c70d37a9ac0 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/SdkThemeProvider.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/SdkThemeProvider.tsx @@ -2,6 +2,7 @@ import { Global } from "@emotion/react"; import { useMemo } from "react"; import type { MetabaseTheme } from "embedding-sdk"; +import { DEFAULT_FONT } from "embedding-sdk/config"; import { getEmbeddingThemeOverride, setGlobalEmbeddingColors, @@ -42,7 +43,9 @@ export const SdkThemeProvider = ({ theme, children }: Props) => { function GlobalSdkCssVariables() { const theme = useMantineTheme(); - const font = useSelector(getFont); + + // the default is needed for when the sdk can't connect to the instance and get the default from there + const font = useSelector(getFont) ?? DEFAULT_FONT; return <Global styles={getMetabaseSdkCssVariables(theme, font)} />; } diff --git a/enterprise/frontend/src/embedding-sdk/components/private/SdkUsageProblem/SdkUsageProblemBanner.stories.tsx b/enterprise/frontend/src/embedding-sdk/components/private/SdkUsageProblem/SdkUsageProblemBanner.stories.tsx index d9f31fad6ee1aba88e1eb92cc50bcb28c453a580..3a72563e6b4004fc6dcb5ab1bf1e35bb5f9a84a6 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/SdkUsageProblem/SdkUsageProblemBanner.stories.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/SdkUsageProblem/SdkUsageProblemBanner.stories.tsx @@ -1,15 +1,18 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Box } from "metabase/ui"; -import { SdkUsageProblemBanner } from "./SdkUsageProblemBanner"; +import { + SdkUsageProblemBanner, + type SdkUsageProblemBannerProps, +} from "./SdkUsageProblemBanner"; export default { title: "EmbeddingSDK/SdkUsageProblemBanner", component: SdkUsageProblemBanner, }; -const Template: ComponentStory<typeof SdkUsageProblemBanner> = args => { +const Template: StoryFn<SdkUsageProblemBannerProps> = args => { return ( <Box pos="absolute" bottom="15px" left="15px"> <SdkUsageProblemBanner {...args} /> @@ -20,14 +23,18 @@ const Template: ComponentStory<typeof SdkUsageProblemBanner> = args => { const MESSAGE = "The embedding SDK is using API keys. This is intended for evaluation purposes and works only on localhost. To use on other sites, implement SSO."; -export const Warning = Template.bind({}); +export const Warning = { + render: Template, -Warning.args = { - problem: { severity: "warning", message: MESSAGE }, + args: { + problem: { severity: "warning", message: MESSAGE }, + }, }; -export const Error = Template.bind({}); +export const Error = { + render: Template, -Error.args = { - problem: { severity: "error", message: MESSAGE }, + args: { + problem: { severity: "error", message: MESSAGE }, + }, }; diff --git a/enterprise/frontend/src/embedding-sdk/components/private/SdkUsageProblem/SdkUsageProblemBanner.tsx b/enterprise/frontend/src/embedding-sdk/components/private/SdkUsageProblem/SdkUsageProblemBanner.tsx index 6a4137627e10babd1c6569a1a84ab4430b7ad938..17a8df46e4b68426a055a35077e955a299bdec06 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/SdkUsageProblem/SdkUsageProblemBanner.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/SdkUsageProblem/SdkUsageProblemBanner.tsx @@ -20,7 +20,7 @@ import { import S from "./SdkUsageProblemBanner.module.css"; -interface Props { +export interface SdkUsageProblemBannerProps { problem: SdkUsageProblem | null; } @@ -30,7 +30,9 @@ const unthemedBrand = originalColors["brand"]; const unthemedTextDark = originalColors["text-dark"]; const unthemedTextMedium = originalColors["text-medium"]; -export const SdkUsageProblemBanner = ({ problem }: Props) => { +export const SdkUsageProblemBanner = ({ + problem, +}: SdkUsageProblemBannerProps) => { const theme = useMantineTheme(); const [expanded, setExpanded] = useState(false); diff --git a/enterprise/frontend/src/embedding-sdk/components/public/CreateDashboardModal/CreateDashboardModal.stories.tsx b/enterprise/frontend/src/embedding-sdk/components/public/CreateDashboardModal/CreateDashboardModal.stories.tsx index c34718cbe6a31f496129e98782ed6d2181f64801..06ba7b5b442dc003843a338ba1e5df7146de9c7a 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/CreateDashboardModal/CreateDashboardModal.stories.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/CreateDashboardModal/CreateDashboardModal.stories.tsx @@ -1,5 +1,5 @@ import { action } from "@storybook/addon-actions"; -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { type JSXElementConstructor, useState } from "react"; import { EditableDashboard } from "embedding-sdk/components/public"; @@ -19,16 +19,18 @@ export default { decorators: [CommonSdkStoryWrapper], }; -const Template: ComponentStory<typeof CreateDashboardModal> = () => ( +const Template: StoryFn<typeof CreateDashboardModal> = () => ( <CreateDashboardModal onClose={action("onClose")} onCreate={action("onCreate")} /> ); -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; -const HookTemplate: ComponentStory< +const HookTemplate: StoryFn< JSXElementConstructor<Record<string, never>> > = () => { const [dashboard, setDashboard] = useState<Dashboard | null>(null); @@ -57,9 +59,11 @@ const HookTemplate: ComponentStory< ); }; -export const useCreateDashboardApiHook = HookTemplate.bind({}); +export const useCreateDashboardApiHook = { + render: HookTemplate, +}; -const FullWorkflowExampleTemplate: ComponentStory< +const FullWorkflowExampleTemplate: StoryFn< JSXElementConstructor<Record<string, never>> > = () => { const [dashboard, setDashboard] = useState<Dashboard | null>(null); @@ -73,4 +77,6 @@ const FullWorkflowExampleTemplate: ComponentStory< ); }; -export const FullWorkflowExample = FullWorkflowExampleTemplate.bind({}); +export const FullWorkflowExample = { + render: FullWorkflowExampleTemplate, +}; diff --git a/enterprise/frontend/src/embedding-sdk/components/public/CreateQuestion/CreateQuestion.tsx b/enterprise/frontend/src/embedding-sdk/components/public/CreateQuestion/CreateQuestion.tsx index 35e6b1da1f75076eb1edaaf1de002a962ea4e66f..a4409a84aa191854644f75feaa50adfa9629ac43 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/CreateQuestion/CreateQuestion.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/CreateQuestion/CreateQuestion.tsx @@ -9,11 +9,13 @@ export const CreateQuestion = ({ isSaveEnabled, onSave, onBeforeSave, + entityTypeFilter, }: CreateQuestionProps = {}) => ( <QuestionEditor plugins={plugins} isSaveEnabled={isSaveEnabled} onBeforeSave={onBeforeSave} onSave={onSave} + entityTypeFilter={entityTypeFilter} /> ); diff --git a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/EditableDashboard.stories.tsx b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/EditableDashboard.stories.tsx index 61877c1707edc1833f0c9892c72ebfe7619e637a..37d41d94363896eadbf13cd6031cae84779b7047 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/EditableDashboard.stories.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/EditableDashboard.stories.tsx @@ -1,8 +1,11 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { CommonSdkStoryWrapper } from "embedding-sdk/test/CommonSdkStoryWrapper"; -import { EditableDashboard } from "./EditableDashboard"; +import { + EditableDashboard, + type EditableDashboardProps, +} from "./EditableDashboard"; const DASHBOARD_ID = (window as any).DASHBOARD_ID || 1; @@ -15,11 +18,14 @@ export default { decorators: [CommonSdkStoryWrapper], }; -const Template: ComponentStory<typeof EditableDashboard> = args => { +const Template: StoryFn<EditableDashboardProps> = args => { return <EditableDashboard {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - dashboardId: DASHBOARD_ID, +export const Default = { + render: Template, + + args: { + dashboardId: DASHBOARD_ID, + }, }; diff --git a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/InteractiveDashboard.stories.tsx b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/InteractiveDashboard.stories.tsx index 020a3033c138c720090d63e41544bc7ac470747f..13889910356f4fda1dd322b4932c443954046641 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/InteractiveDashboard.stories.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/InteractiveDashboard.stories.tsx @@ -1,8 +1,11 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { CommonSdkStoryWrapper } from "embedding-sdk/test/CommonSdkStoryWrapper"; -import { InteractiveDashboard } from "./InteractiveDashboard"; +import { + InteractiveDashboard, + type InteractiveDashboardProps, +} from "./InteractiveDashboard"; const DASHBOARD_ID = (window as any).DASHBOARD_ID || 1; @@ -15,11 +18,14 @@ export default { decorators: [CommonSdkStoryWrapper], }; -const Template: ComponentStory<typeof InteractiveDashboard> = args => { +const Template: StoryFn<InteractiveDashboardProps> = args => { return <InteractiveDashboard {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - dashboardId: DASHBOARD_ID, +export const Default = { + render: Template, + + args: { + dashboardId: DASHBOARD_ID, + }, }; diff --git a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.tsx b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.tsx index fb25a039ae067c42769df260d78f277bec511cb5..3c1feffac7f9ae326093f9d32b2462e440e30cc6 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.tsx @@ -31,7 +31,7 @@ export type InteractiveQuestionProps = PropsWithChildren<{ }> & Pick< InteractiveQuestionProviderProps, - "onBeforeSave" | "onSave" | "isSaveEnabled" + "onBeforeSave" | "onSave" | "isSaveEnabled" | "entityTypeFilter" >; export const _InteractiveQuestion = ({ @@ -45,6 +45,7 @@ export const _InteractiveQuestion = ({ onBeforeSave, onSave, isSaveEnabled, + entityTypeFilter, }: InteractiveQuestionProps & InteractiveQuestionResultProps): JSX.Element | null => ( <InteractiveQuestionProvider @@ -53,6 +54,7 @@ export const _InteractiveQuestion = ({ onBeforeSave={onBeforeSave} onSave={onSave} isSaveEnabled={isSaveEnabled} + entityTypeFilter={entityTypeFilter} > {children ?? ( <InteractiveQuestionResult diff --git a/enterprise/frontend/src/embedding-sdk/components/public/MetabaseProvider.tsx b/enterprise/frontend/src/embedding-sdk/components/public/MetabaseProvider.tsx index 4fbbf6bf3cc7315dc67620c5890d839c1adac22c..87f100b15a930d782353c58306eff8e60af83676 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/MetabaseProvider.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/MetabaseProvider.tsx @@ -1,3 +1,4 @@ +import { Global } from "@emotion/react"; import type { Action, Store } from "@reduxjs/toolkit"; import { type JSX, type ReactNode, memo, useEffect } from "react"; import { Provider } from "react-redux"; @@ -10,7 +11,7 @@ import { import { useInitData } from "embedding-sdk/hooks"; import type { SdkEventHandlersConfig } from "embedding-sdk/lib/events"; import type { SdkPluginsConfig } from "embedding-sdk/lib/plugins"; -import { store } from "embedding-sdk/store"; +import { getSdkStore } from "embedding-sdk/store"; import { setErrorComponent, setEventHandlers, @@ -24,10 +25,14 @@ import type { MetabaseTheme } from "embedding-sdk/types/theme"; import { setOptions } from "metabase/redux/embed"; import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider"; -import { PublicComponentStylesWrapper } from "../private/PublicComponentStylesWrapper"; +import { + PublicComponentStylesWrapper, + SCOPED_CSS_RESET, +} from "../private/PublicComponentStylesWrapper"; import { SdkFontsGlobalStyles } from "../private/SdkGlobalFontsStyles"; import "metabase/css/index.module.css"; import { SdkUsageProblemDisplay } from "../private/SdkUsageProblem"; + import "metabase/css/vendor.css"; export interface MetabaseProviderProps { @@ -83,6 +88,7 @@ export const MetabaseProviderInternal = ({ return ( <EmotionCacheProvider> + <Global styles={SCOPED_CSS_RESET} /> <SdkThemeProvider theme={theme}> <SdkFontsGlobalStyles baseUrl={config.metabaseInstanceUrl} /> <div className={className} id={EMBEDDING_SDK_ROOT_ELEMENT_ID}> @@ -95,9 +101,12 @@ export const MetabaseProviderInternal = ({ ); }; -export const MetabaseProvider = memo(function MetabaseProvider( - props: MetabaseProviderProps, -) { +export const MetabaseProvider = memo(function MetabaseProvider({ + // @ts-expect-error -- we don't want to expose the store prop + // eslint-disable-next-line react/prop-types + store = getSdkStore(), + ...props +}: MetabaseProviderProps) { return ( <Provider store={store}> <MetabaseProviderInternal store={store} {...props} /> diff --git a/enterprise/frontend/src/embedding-sdk/components/public/ModifyQuestion/ModifyQuestion.tsx b/enterprise/frontend/src/embedding-sdk/components/public/ModifyQuestion/ModifyQuestion.tsx index 46b5992bd3057242006dc2e07718fba9b06f3fb0..ee2cb7ac13c673546b10edeb50cf312d55922804 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/ModifyQuestion/ModifyQuestion.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/ModifyQuestion/ModifyQuestion.tsx @@ -10,6 +10,7 @@ export const ModifyQuestion = ({ isSaveEnabled, onSave, onBeforeSave, + entityTypeFilter, }: ModifyQuestionProps = {}) => ( <QuestionEditor questionId={questionId} @@ -17,5 +18,6 @@ export const ModifyQuestion = ({ isSaveEnabled={isSaveEnabled} onSave={onSave} onBeforeSave={onBeforeSave} + entityTypeFilter={entityTypeFilter} /> ); diff --git a/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/StaticDashboard.stories.tsx b/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/StaticDashboard.stories.tsx index 2ef2c7f5be4457fce1dca423deaa904b7369ea41..b3cb34bdb712a5f9df119545e8426e15e21a17ed 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/StaticDashboard.stories.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/StaticDashboard.stories.tsx @@ -1,8 +1,10 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { StaticDashboard } from "embedding-sdk"; import { CommonSdkStoryWrapper } from "embedding-sdk/test/CommonSdkStoryWrapper"; +import type { StaticDashboardProps } from "./StaticDashboard"; + const DASHBOARD_ID = (window as any).DASHBOARD_ID || "1"; export default { @@ -14,11 +16,14 @@ export default { decorators: [CommonSdkStoryWrapper], }; -const Template: ComponentStory<typeof StaticDashboard> = args => { +const Template: StoryFn<StaticDashboardProps> = args => { return <StaticDashboard {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - dashboardId: DASHBOARD_ID, +export const Default = { + render: Template, + + args: { + dashboardId: DASHBOARD_ID, + }, }; diff --git a/enterprise/frontend/src/embedding-sdk/store/index.ts b/enterprise/frontend/src/embedding-sdk/store/index.ts index 5be403a894f6f35f4d149f3256bec7ed2b7400e5..9571b6fc0082b51eac3c976455942298ecc04b1d 100644 --- a/enterprise/frontend/src/embedding-sdk/store/index.ts +++ b/enterprise/frontend/src/embedding-sdk/store/index.ts @@ -18,15 +18,16 @@ export const sdkReducers = { sdk, } as unknown as Record<string, Reducer>; -export const store = getStore(sdkReducers, null, { - embed: { - options: {}, - isEmbeddingSdk: true, - }, - app: { - isDndAvailable: false, - }, -}) as unknown as Store<SdkStoreState, AnyAction>; +export const getSdkStore = () => + getStore(sdkReducers, null, { + embed: { + options: {}, + isEmbeddingSdk: true, + }, + app: { + isDndAvailable: false, + }, + }) as unknown as Store<SdkStoreState, AnyAction>; export const useSdkDispatch: () => ThunkDispatch< SdkStoreState, diff --git a/enterprise/frontend/src/embedding-sdk/test/CommonSdkStoryWrapper.tsx b/enterprise/frontend/src/embedding-sdk/test/CommonSdkStoryWrapper.tsx index fb1c056d2464307215e09191dad7dd6e6905ffc4..13c9448b29d7c61f17317ed6e5543fbbb8ca24ea 100644 --- a/enterprise/frontend/src/embedding-sdk/test/CommonSdkStoryWrapper.tsx +++ b/enterprise/frontend/src/embedding-sdk/test/CommonSdkStoryWrapper.tsx @@ -11,7 +11,10 @@ const METABASE_JWT_SHARED_SECRET = const secret = new TextEncoder().encode(METABASE_JWT_SHARED_SECRET); -const DEFAULT_CONFIG: SDKConfig = { +/** + * SDK config that signs the jwt on the FE + */ +export const storybookSdkDefaultConfig: SDKConfig = { metabaseInstanceUrl: METABASE_INSTANCE_URL, jwtProviderUri: `${METABASE_INSTANCE_URL}/sso/metabase`, fetchRequestToken: async () => { @@ -39,7 +42,7 @@ const DEFAULT_CONFIG: SDKConfig = { }; export const CommonSdkStoryWrapper = (Story: Story) => ( - <MetabaseProvider config={DEFAULT_CONFIG}> + <MetabaseProvider config={storybookSdkDefaultConfig}> <Story /> </MetabaseProvider> ); diff --git a/enterprise/frontend/src/embedding-sdk/tests/styling-sdk-tests.stories.tsx b/enterprise/frontend/src/embedding-sdk/tests/styling-sdk-tests.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..163ce7954ea2469e5657f52b9783916d1d00b559 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/tests/styling-sdk-tests.stories.tsx @@ -0,0 +1,71 @@ +import { + MetabaseProvider, + StaticQuestion, +} from "embedding-sdk/components/public"; +import { storybookSdkDefaultConfig } from "embedding-sdk/test/CommonSdkStoryWrapper"; +import type { SDKConfig } from "embedding-sdk/types"; + +export default { + title: "EmbeddingSDK/styles tests", +}; + +const configThatWillError: SDKConfig = { + apiKey: "TEST", + metabaseInstanceUrl: "http://fake-host:1234", +}; + +/** + * This simulates an empty project with just the provider, we should not mess + * with the styles either inside or outside of the provider + */ +export const NoStylesError = () => ( + <div> + <h1>No styles applied anywhere, should use browser default</h1> + <div style={{ border: "1px solid black" }}> + <h1>This is outside of the provider</h1> + </div> + + <MetabaseProvider config={configThatWillError}> + <div style={{ border: "1px solid black" }}> + <h1>This is inside of the provider</h1> + </div> + + <StaticQuestion questionId={(window as any).QUESTION_ID || 1} /> + </MetabaseProvider> + </div> +); + +export const NoStylesSuccess = () => ( + <div> + <h1>No styles applied anywhere, should use browser default</h1> + <div style={{ border: "1px solid black" }}> + <h1>This is outside of the provider</h1> + </div> + + <MetabaseProvider config={storybookSdkDefaultConfig}> + <div style={{ border: "1px solid black" }}> + <h1>This is inside of the provider</h1> + </div> + + <StaticQuestion questionId={(window as any).QUESTION_ID || 1} /> + </MetabaseProvider> + </div> +); + +export const FontFromConfig = () => ( + <div> + <MetabaseProvider + config={storybookSdkDefaultConfig} + theme={{ fontFamily: "Impact" }} + > + <StaticQuestion questionId={(window as any).QUESTION_ID || 1} /> + </MetabaseProvider> + </div> +); + +/** + * This story is only needed to get the default font of the browser + */ +export const GetBrowserDefaultFont = () => ( + <p>paragraph with default browser font</p> +); diff --git a/enterprise/frontend/src/embedding-sdk/types/theme/index.ts b/enterprise/frontend/src/embedding-sdk/types/theme/index.ts index 0247365876375389ed7806a5ecc8c96c53079b98..26e84d4435743b5e5f77a88d9666d74258b49e97 100644 --- a/enterprise/frontend/src/embedding-sdk/types/theme/index.ts +++ b/enterprise/frontend/src/embedding-sdk/types/theme/index.ts @@ -17,10 +17,10 @@ export interface MetabaseTheme { fontSize?: string; /** - * Base font family supported by Metabase, defaults to `Lato`. - * Custom fonts are not yet supported in this version. + * Font family that will be used for all text, it defaults to the instance's default font. **/ - fontFamily?: MetabaseFontFamily; + // eslint-disable-next-line @typescript-eslint/ban-types -- this is needed to allow any string but keep autocomplete for the built-in ones + fontFamily?: MetabaseFontFamily | (string & {}); /** Base line height */ lineHeight?: string | number; diff --git a/enterprise/frontend/src/metabase-enterprise/browse/components/BrowseModels.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/browse/models/BrowseModels.unit.spec.tsx similarity index 98% rename from enterprise/frontend/src/metabase-enterprise/browse/components/BrowseModels.unit.spec.tsx rename to enterprise/frontend/src/metabase-enterprise/browse/models/BrowseModels.unit.spec.tsx index 3dc80a5392d18f8c77f1c3a5f5e9507a642df229..f615978b30fb809551ea29fc07dfc07ca6c96160 100644 --- a/enterprise/frontend/src/metabase-enterprise/browse/components/BrowseModels.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/browse/models/BrowseModels.unit.spec.tsx @@ -6,11 +6,11 @@ import { } from "__support__/server-mocks"; import { mockSettings } from "__support__/settings"; import { renderWithProviders, screen, within } from "__support__/ui"; -import { BrowseModels } from "metabase/browse/components/BrowseModels"; +import { BrowseModels } from "metabase/browse"; import { createMockModelResult, createMockRecentModel, -} from "metabase/browse/test-utils"; +} from "metabase/browse/models/test-utils"; import type { RecentCollectionItem } from "metabase-types/api"; import { createMockCollection, diff --git a/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionInstanceAnalyticsIcon.tsx b/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionInstanceAnalyticsIcon.tsx index 860e6e7777a958322074cf499195d77ce08b8ffe..8e51b9b2a93bf5e062d9472bee1071782fe7ad24 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionInstanceAnalyticsIcon.tsx +++ b/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionInstanceAnalyticsIcon.tsx @@ -34,8 +34,7 @@ export function CollectionInstanceAnalyticsIcon({ <Icon {...iconProps} name={collectionType.icon} - // eslint-disable-next-line no-literal-metabase-strings -- Metabase analytics - tooltip={t`This is a read-only Metabase Analytics ${collectionIconTooltipNameMap[entity]}.`} + tooltip={t`This is a read-only Usage Analytics ${collectionIconTooltipNameMap[entity]}.`} data-testid="instance-analytics-collection-marker" /> ); diff --git a/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionInstanceAnalyticsIcon.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionInstanceAnalyticsIcon.unit.spec.tsx index 69a386d00b5e1d2d1331feb1aa17f5a727bfea16..be7c0910595276b3de3071b37a580f84c80c9c28 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionInstanceAnalyticsIcon.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/collections/components/CollectionInstanceAnalyticsIcon.unit.spec.tsx @@ -59,7 +59,7 @@ describe("CollectionInstanceAnalyticsIcon", () => { expect(queryOfficialIcon()).toBeInTheDocument(); await userEvent.hover(queryOfficialIcon()); expect(screen.getByRole("tooltip")).toHaveTextContent( - `This is a read-only Metabase Analytics ${entity}`, + `This is a read-only Usage Analytics ${entity}`, ); }); }); diff --git a/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.tsx index 5f3b35e7ee8c793134cecfbd4137eb7096a907b3..0570b2f2cca5eb2b52e2f276e2e69dd7f991affa 100644 --- a/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/collections/utils.unit.spec.tsx @@ -1,7 +1,7 @@ import { setupEnterprisePlugins } from "__support__/enterprise"; import { mockSettings } from "__support__/settings"; import { renderWithProviders } from "__support__/ui"; -import { createMockModelResult } from "metabase/browse/test-utils"; +import { createMockModelResult } from "metabase/browse/models/test-utils"; import { createMockCollection, createMockTokenFeatures, diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.styled.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.styled.tsx deleted file mode 100644 index 88e51c28cb81dad0fd95e15d49bdeb0d8e40a1f9..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.styled.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import styled from "@emotion/styled"; - -import { Text } from "metabase/ui"; - -export const ModelFilterControlSwitchLabel = styled(Text)` - text-align: right; - font-weight: bold; - line-height: 1rem; - padding: 0 0.75rem; -`; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.tsx deleted file mode 100644 index 1acb1250272fd255b8abbd856adef2a6b10684dc..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/ModelFilterControls.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback } from "react"; -import { t } from "ttag"; -import _ from "underscore"; - -import type { - ActualModelFilters, - ModelFilterControlsProps, -} from "metabase/browse/utils"; -import { useUserSetting } from "metabase/common/hooks"; -import { Button, Icon, Paper, Popover, Switch, Text } from "metabase/ui"; - -export const ModelFilterControls = ({ - actualModelFilters, - setActualModelFilters, -}: ModelFilterControlsProps) => { - const [__, setVerifiedFilterStatus] = useUserSetting( - "browse-filter-only-verified-models", - { shouldRefresh: false }, - ); - const setVerifiedFilterStatusDebounced = _.debounce( - setVerifiedFilterStatus, - 200, - ); - - const handleModelFilterChange = useCallback( - (modelFilterName: string, active: boolean) => { - // For now, only one filter is supported - setVerifiedFilterStatusDebounced(active); - setActualModelFilters((prev: ActualModelFilters) => { - return { ...prev, [modelFilterName]: active }; - }); - }, - [setActualModelFilters, setVerifiedFilterStatusDebounced], - ); - - // There's only one filter for now - const filters = [actualModelFilters.onlyShowVerifiedModels]; - - const areAnyFiltersActive = filters.some(filter => filter); - - return ( - <Popover position="bottom-end"> - <Popover.Target> - <Button p="sm" lh={0} variant="subtle" color="text-dark" pos="relative"> - {areAnyFiltersActive && <Dot />} - <Icon name="filter" /> - </Button> - </Popover.Target> - <Popover.Dropdown p="lg"> - <Switch - label={ - <Text - align="end" - weight="bold" - >{t`Show verified models only`}</Text> - } - role="switch" - checked={actualModelFilters.onlyShowVerifiedModels} - onChange={e => { - handleModelFilterChange("onlyShowVerifiedModels", e.target.checked); - }} - labelPosition="left" - /> - </Popover.Dropdown> - </Popover> - ); -}; - -const Dot = () => { - return ( - <Paper - pos="absolute" - right="0px" - top="7px" - radius="50%" - bg={"var(--mb-color-brand)"} - w="sm" - h="sm" - data-testid="filter-dot" - /> - ); -}; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts index a5c206fcb98b94d49974a472837f0f93083e80eb..1e318383b25edbd3e7e67f1088909db8c362d53e 100644 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts @@ -1,19 +1,18 @@ import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins"; import { hasPremiumFeature } from "metabase-enterprise/settings"; -import { ModelFilterControls } from "./ModelFilterControls"; import { VerifiedFilter } from "./VerifiedFilter"; import { MetricFilterControls, getDefaultMetricFilters } from "./metrics"; -import { availableModelFilters, useModelFilterSettings } from "./utils"; +import { ModelFilterControls, getDefaultModelFilters } from "./models"; if (hasPremiumFeature("content_verification")) { Object.assign(PLUGIN_CONTENT_VERIFICATION, { + contentVerificationEnabled: true, VerifiedFilter, + ModelFilterControls, - availableModelFilters, - useModelFilterSettings, + getDefaultModelFilters, - contentVerificationEnabled: true, getDefaultMetricFilters, MetricFilterControls, }); diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx index 7a78ad6f1511bbedc8c06bdb0818aaa265318ea6..2522465ddb1fbb0c8b6e6d2392223d4279f291ed 100644 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx @@ -4,7 +4,7 @@ import { t } from "ttag"; import type { MetricFilterControlsProps, MetricFilterSettings, -} from "metabase/browse/utils"; +} from "metabase/browse/metrics"; import { useUserSetting } from "metabase/common/hooks"; import { getSetting } from "metabase/selectors/settings"; import { Button, Icon, Paper, Popover, Switch, Text } from "metabase/ui"; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/models.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/models.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d9f473960d0bfd978de96bb8cae090ba3d609cc --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/models.tsx @@ -0,0 +1,85 @@ +import { type ChangeEvent, useCallback } from "react"; +import { t } from "ttag"; + +import type { + ModelFilterControlsProps, + ModelFilterSettings, +} from "metabase/browse/models"; +import { useUserSetting } from "metabase/common/hooks"; +import { getSetting } from "metabase/selectors/settings"; +import { Button, Icon, Paper, Popover, Switch, Text } from "metabase/ui"; +import type { State } from "metabase-types/store"; + +const USER_SETTING_KEY = "browse-filter-only-verified-models"; + +export function getDefaultModelFilters(state: State): ModelFilterSettings { + return { + verified: getSetting(state, USER_SETTING_KEY) ?? false, + }; +} + +// This component is similar to the MetricFilterControls component from ./MetricFilterControls.tsx +// merging them might be a good idea in the future. +export const ModelFilterControls = ({ + modelFilters, + setModelFilters, +}: ModelFilterControlsProps) => { + const areAnyFiltersActive = Object.values(modelFilters).some(Boolean); + + const [_, setUserSetting] = useUserSetting(USER_SETTING_KEY); + + const handleVerifiedFilterChange = useCallback( + function (evt: ChangeEvent<HTMLInputElement>) { + setModelFilters({ ...modelFilters, verified: evt.target.checked }); + setUserSetting(evt.target.checked); + }, + [modelFilters, setModelFilters, setUserSetting], + ); + + return ( + <Popover position="bottom-end"> + <Popover.Target> + <Button + p="sm" + lh={0} + variant="subtle" + color="var(--mb-color-text-dark)" + pos="relative" + aria-label={t`Filters`} + > + {areAnyFiltersActive && <Dot />} + <Icon name="filter" /> + </Button> + </Popover.Target> + <Popover.Dropdown p="lg"> + <Switch + label={ + <Text + align="end" + weight="bold" + >{t`Show verified models only`}</Text> + } + role="switch" + checked={Boolean(modelFilters.verified)} + onChange={handleVerifiedFilterChange} + labelPosition="left" + /> + </Popover.Dropdown> + </Popover> + ); +}; + +const Dot = () => { + return ( + <Paper + pos="absolute" + right="0px" + top="7px" + radius="50%" + bg={"var(--mb-color-brand)"} + w="sm" + h="sm" + data-testid="filter-dot" + /> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/utils.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/utils.ts deleted file mode 100644 index 5e7f5968c03495f41beda64a4cb139fcab7fe8cd..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; -import { useEffect, useMemo, useState } from "react"; - -import type { - ActualModelFilters, - AvailableModelFilters, -} from "metabase/browse/utils"; -import { useUserSetting } from "metabase/common/hooks"; - -export const availableModelFilters: AvailableModelFilters = { - onlyShowVerifiedModels: { - predicate: model => model.moderated_status === "verified", - activeByDefault: true, - }, -}; - -export const useModelFilterSettings = (): [ - ActualModelFilters, - Dispatch<SetStateAction<ActualModelFilters>>, -] => { - const [initialVerifiedFilterStatus] = useUserSetting( - "browse-filter-only-verified-models", - { shouldRefresh: false }, - ); - const initialModelFilters = useMemo( - () => ({ - onlyShowVerifiedModels: initialVerifiedFilterStatus ?? false, - }), - [initialVerifiedFilterStatus], - ); - - const [actualModelFilters, setActualModelFilters] = - useState<ActualModelFilters>(initialModelFilters); - - useEffect(() => { - setActualModelFilters(initialModelFilters); - }, [initialModelFilters, setActualModelFilters]); - - return [actualModelFilters, setActualModelFilters]; -}; diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/utils.unit.spec.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/utils.unit.spec.ts deleted file mode 100644 index 0a65bb1485c62ec6b45c510f93f1c752a8a2cf46..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/utils.unit.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createMockModelResult } from "metabase/browse/test-utils"; - -import { availableModelFilters } from "./utils"; - -describe("Utilities related to content verification", () => { - it("include a constant that defines a filter for only showing verified models", () => { - const models = [ - createMockModelResult({ - name: "A verified model", - moderated_status: "verified", - }), - createMockModelResult({ - name: "An unverified model", - moderated_status: null, - }), - ]; - const filteredModels = models.filter( - availableModelFilters.onlyShowVerifiedModels.predicate, - ); - expect(filteredModels.length).toBe(1); - expect(filteredModels[0].name).toBe("A verified model"); - }); -}); diff --git a/enterprise/frontend/src/metabase-enterprise/troubleshooting/components/QueryValidator.tsx b/enterprise/frontend/src/metabase-enterprise/troubleshooting/components/QueryValidator.tsx index e3ace57c8b72891ccba17acbdb912ad891fe49fd..6c32c652d2c2a0fd9e18edc2f34ac155ed89cc40 100644 --- a/enterprise/frontend/src/metabase-enterprise/troubleshooting/components/QueryValidator.tsx +++ b/enterprise/frontend/src/metabase-enterprise/troubleshooting/components/QueryValidator.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { Link } from "react-router"; -import { t } from "ttag"; +import { jt, t } from "ttag"; import _ from "underscore"; import { useGetCollectionQuery } from "metabase/api"; @@ -14,6 +14,7 @@ import { } from "metabase/common/components/CollectionPicker"; import { EllipsifiedPath } from "metabase/common/components/EllipsifiedPath"; import { Table } from "metabase/common/components/Table"; +import { useSetting } from "metabase/common/hooks"; import { Ellipsified } from "metabase/core/components/Ellipsified"; import CS from "metabase/css/core/index.css"; import { usePagination } from "metabase/hooks/use-pagination"; @@ -62,6 +63,7 @@ type TableRow = { }; export const QueryValidator = () => { + const queryAnalysisEnabled = useSetting("query-analysis-enabled"); const [sortColumn, setSortColumn] = useState<string>("name"); const [sortDirection, setSortDirection] = useState<SortDirection>( SortDirection.Asc, @@ -78,7 +80,7 @@ export const QueryValidator = () => { id: collectionId, }, { - skip: isRootCollection, + skip: isRootCollection || !queryAnalysisEnabled, }, ); @@ -92,6 +94,7 @@ export const QueryValidator = () => { }, { refetchOnMountOrArgChange: true, + skip: !queryAnalysisEnabled, }, ); @@ -120,7 +123,7 @@ export const QueryValidator = () => { [invalidCards], ); - return ( + return queryAnalysisEnabled ? ( <> <Box> <Flex mb="2rem" justify="space-between" align="center"> @@ -180,6 +183,21 @@ export const QueryValidator = () => { /> )} </> + ) : ( + <Flex justify="center" p="1rem"> + <Text fz="1rem" color="var(--mb-color-text-light)"> + {jt`Query Validation is currently disabled. ${( + <Text + fz="inherit" + color="var(--mb-color-brand)" + component={Link} + to="/admin/settings/general#query-analysis-enabled" + > + {t`Please enable query analysis here.`} + </Text> + )}`} + </Text> + </Flex> ); }; diff --git a/frontend/src/metabase-lib/v1/expressions/index.ts b/frontend/src/metabase-lib/v1/expressions/index.ts index f2842901a24256f2cae336f612b1b4d9cdfff421..a17b354b92c22546cbcb71ee51ab983ba05682a8 100644 --- a/frontend/src/metabase-lib/v1/expressions/index.ts +++ b/frontend/src/metabase-lib/v1/expressions/index.ts @@ -127,31 +127,60 @@ export function formatSegmentName( */ export function parseDimension( name: string, - { - query, - stageIndex, - expressionIndex, - }: { + options: { query: Lib.Query; stageIndex: number; - source: string; expressionIndex: number | undefined; + startRule: string; }, ) { - const columns = Lib.expressionableColumns(query, stageIndex, expressionIndex); - - return columns.find(column => { - const displayInfo = Lib.displayInfo(query, stageIndex, column); - + return getAvailableDimensions(options).find(({ info }) => { return EDITOR_FK_SYMBOLS.symbols.some(separator => { const displayName = getDisplayNameWithSeparator( - displayInfo.longDisplayName, + info.longDisplayName, separator, ); return displayName === name; }); + })?.dimension; +} + +function getAvailableDimensions({ + query, + stageIndex, + expressionIndex, + startRule, +}: { + query: Lib.Query; + stageIndex: number; + expressionIndex: number | undefined; + startRule: string; +}) { + const results = Lib.expressionableColumns( + query, + stageIndex, + expressionIndex, + ).map(dimension => { + return { + dimension, + info: Lib.displayInfo(query, stageIndex, dimension), + }; }); + + if (startRule === "aggregation") { + return [ + ...results, + ...Lib.availableMetrics(query, stageIndex).map(dimension => { + return { + dimension, + info: Lib.displayInfo(query, stageIndex, dimension), + }; + }), + ]; + } + + return results; } export function formatLegacyDimensionName( diff --git a/frontend/src/metabase-types/api/activity.ts b/frontend/src/metabase-types/api/activity.ts index 5d22c61d3136a53ff43209e4e53137e29b66b6ad..45eb0b02f4339bf4eec17f6326fe4f47842a95c4 100644 --- a/frontend/src/metabase-types/api/activity.ts +++ b/frontend/src/metabase-types/api/activity.ts @@ -1,6 +1,8 @@ import type { DatabaseId, InitialSyncStatus } from "./database"; import type { CardDisplayType } from "./visualization"; +import type { Collection } from "."; + export const ACTIVITY_MODELS = [ "table", "card", @@ -22,15 +24,15 @@ export const isLoggableActivityModel = (item: { return typeof item.id === "number" && isActivityModel(item.model); }; -export interface BaseRecentItem { +export type BaseRecentItem = { id: number; name: string; model: ActivityModel; description?: string | null; timestamp: string; -} +}; -export interface RecentTableItem extends BaseRecentItem { +export type RecentTableItem = BaseRecentItem & { model: "table"; display_name: string; table_schema: string; @@ -39,21 +41,17 @@ export interface RecentTableItem extends BaseRecentItem { name: string; initial_sync_status: InitialSyncStatus; }; -} +}; -export interface RecentCollectionItem extends BaseRecentItem { +export type RecentCollectionItem = BaseRecentItem & { model: "collection" | "dashboard" | "card" | "dataset" | "metric"; can_write: boolean; database_id?: DatabaseId; // for models and questions - parent_collection: { - id: number | null; - name: string; - authority_level?: "official" | null; - }; + parent_collection: Pick<Collection, "id" | "name" | "authority_level">; authority_level?: "official" | null; // for collections moderated_status?: "verified" | null; // for models display?: CardDisplayType; // for questions -} +}; export type RecentItem = RecentTableItem | RecentCollectionItem; diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index 43fe5ca3cba9ce1a0847572b41cce796e88c2cd4..072c2d3c744557c4cf1fe1c09101457ce8de7187 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -255,5 +255,6 @@ export const createMockSettings = ( "setup-license-active-at-setup": false, "notebook-native-preview-shown": false, "notebook-native-preview-sidebar-width": null, + "query-analysis-enabled": false, ...opts, }); diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index f2acb57b3e664758225133b0d9837f5bfbdaa7e3..894e6242a023a3c511202f2d7b597f6dfcd275bb 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -223,6 +223,7 @@ interface InstanceSettings { "subscription-allowed-domains": string | null; "uploads-settings": UploadsSettings; "user-visibility": string | null; + "query-analysis-enabled": boolean; } export type EmbeddingHomepageDismissReason = diff --git a/frontend/src/metabase/admin/app/components/DeprecationNotice/DeprecationNotice.stories.tsx b/frontend/src/metabase/admin/app/components/DeprecationNotice/DeprecationNotice.stories.tsx index ab72dd791822694f3d9fed1c90ad1282ee96ff97..a0d425024ead1c8f3468afe75145f79d9f7381a1 100644 --- a/frontend/src/metabase/admin/app/components/DeprecationNotice/DeprecationNotice.stories.tsx +++ b/frontend/src/metabase/admin/app/components/DeprecationNotice/DeprecationNotice.stories.tsx @@ -1,6 +1,8 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryObj } from "@storybook/react"; -import DeprecationNotice from "./DeprecationNotice"; +import DeprecationNotice, { + type DeprecationNoticeProps, +} from "./DeprecationNotice"; export default { title: "Admin/App/DeprecationNotice", @@ -10,12 +12,14 @@ export default { }, }; -export const Default: ComponentStory<typeof DeprecationNotice> = args => { - return <DeprecationNotice {...args} />; -}; +export const Default: StoryObj<DeprecationNoticeProps> = { + render: args => { + return <DeprecationNotice {...args} />; + }, -Default.args = { - hasSlackBot: true, - hasDeprecatedDatabase: true, - isEnabled: true, + args: { + hasSlackBot: true, + hasDeprecatedDatabase: true, + isEnabled: true, + }, }; diff --git a/frontend/src/metabase/admin/permissions/selectors/confirmations.tsx b/frontend/src/metabase/admin/permissions/selectors/confirmations.tsx index 44bf8539d7043ec20d3c3ae89f4fae909b947447..668f39900a7626f7420828e3da9d14b020506ba3 100644 --- a/frontend/src/metabase/admin/permissions/selectors/confirmations.tsx +++ b/frontend/src/metabase/admin/permissions/selectors/confirmations.tsx @@ -90,7 +90,7 @@ export function getPermissionWarning( return null; } -export function getTableBlockWarning( +export function getBlockWarning( dbValue: DataPermissionValue, schemaValue: DataPermissionValue, tableValue?: DataPermissionValue, @@ -99,12 +99,11 @@ export function getTableBlockWarning( return; } - if (schemaValue === DataPermissionValue.BLOCKED) { - return t`Users in groups with Blocked on a schema can't view native queries on this database.`; - } - - if (tableValue === DataPermissionValue.BLOCKED) { - return t`Users in groups with Blocked on a table can't view native queries on this database.`; + if ( + schemaValue === DataPermissionValue.BLOCKED || + tableValue === DataPermissionValue.BLOCKED + ) { + return t`Groups with a database, schema, or table set to Blocked can't view native queries on this database.`; } } diff --git a/frontend/src/metabase/admin/permissions/selectors/data-permissions/fields.ts b/frontend/src/metabase/admin/permissions/selectors/data-permissions/fields.ts index d3f535aee1cf35aa448986c78f576d0d07d604e2..ce583048a80730e8fc9e704bea5a4413d890cbee 100644 --- a/frontend/src/metabase/admin/permissions/selectors/data-permissions/fields.ts +++ b/frontend/src/metabase/admin/permissions/selectors/data-permissions/fields.ts @@ -26,10 +26,10 @@ import { DataPermissionValue, } from "../../types"; import { + getBlockWarning, getPermissionWarning, getPermissionWarningModal, getRevokingAccessToAllTablesWarningModal, - getTableBlockWarning, getWillRevokeNativeAccessWarningModal, } from "../confirmations"; @@ -64,14 +64,14 @@ const buildAccessPermission = ( ); const dbValue = getSchemasPermission( - originalPermissions, + permissions, groupId, entityId, DataPermission.VIEW_DATA, ); const schemaValue = getTablesPermission( - originalPermissions, + permissions, groupId, entityId, DataPermission.VIEW_DATA, @@ -85,8 +85,9 @@ const buildAccessPermission = ( groupId, ); - const blockWarning = getTableBlockWarning(dbValue, schemaValue, value); + const blockWarning = getBlockWarning(dbValue, schemaValue, value); + // permissionWarning should always trump a blockWarning const warning = permissionWarning || blockWarning; const confirmations = (newValue: DataPermissionValue) => [ diff --git a/frontend/src/metabase/admin/permissions/selectors/data-permissions/tables.ts b/frontend/src/metabase/admin/permissions/selectors/data-permissions/tables.ts index 421601303873a3653a8ceecac67d3da0c2714edb..e6b279da93212ab454fd2d2f027bf6ad838e7de0 100644 --- a/frontend/src/metabase/admin/permissions/selectors/data-permissions/tables.ts +++ b/frontend/src/metabase/admin/permissions/selectors/data-permissions/tables.ts @@ -23,9 +23,9 @@ import { DataPermissionValue, } from "../../types"; import { + getBlockWarning, getPermissionWarning, getPermissionWarningModal, - getTableBlockWarning, getViewDataPermissionsTooRestrictiveWarningModal, getWillRevokeNativeAccessWarningModal, } from "../confirmations"; @@ -60,7 +60,7 @@ const buildAccessPermission = ( ); const dbValue = getSchemasPermission( - originalPermissions, + permissions, groupId, entityId, DataPermission.VIEW_DATA, @@ -74,8 +74,9 @@ const buildAccessPermission = ( groupId, ); - const blockWarning = getTableBlockWarning(dbValue, value); + const blockWarning = getBlockWarning(dbValue, value); + // permissionWarning should always trump a blockWarning const warning = permissionWarning || blockWarning; const confirmations = (newValue: DataPermissionValue) => [ diff --git a/frontend/src/metabase/admin/settings/slack/components/SlackSetup/SlackSetup.stories.tsx b/frontend/src/metabase/admin/settings/slack/components/SlackSetup/SlackSetup.stories.tsx index a8cc5e93444a5170ae368666193f5f61bfde6a97..49c6d3428177e859e75d18519bd10fa1128f5bed 100644 --- a/frontend/src/metabase/admin/settings/slack/components/SlackSetup/SlackSetup.stories.tsx +++ b/frontend/src/metabase/admin/settings/slack/components/SlackSetup/SlackSetup.stories.tsx @@ -1,6 +1,6 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryObj } from "@storybook/react"; -import SlackSetup from "./SlackSetup"; +import SlackSetup, { type SlackSetupProps } from "./SlackSetup"; export default { title: "Admin/Settings/Slack/SlackSetup", @@ -11,13 +11,15 @@ export default { }, }; -export const Default: ComponentStory<typeof SlackSetup> = args => { - return <SlackSetup {...args} />; -}; +export const Default: StoryObj<SlackSetupProps> = { + render: args => { + return <SlackSetup {...args} />; + }, -Default.args = { - Form: () => <div />, - manifest: "app: token", - isBot: false, - isValid: true, + args: { + Form: () => <div />, + manifest: "app: token", + isBot: false, + isValid: true, + }, }; diff --git a/frontend/src/metabase/admin/settings/slack/components/SlackStatus/SlackStatus.stories.tsx b/frontend/src/metabase/admin/settings/slack/components/SlackStatus/SlackStatus.stories.tsx index c2f0b23cd4c5f698ff9964d51eb30357b8e56d76..a30fa6e14a553d53b0633282bb280d4a4d596668 100644 --- a/frontend/src/metabase/admin/settings/slack/components/SlackStatus/SlackStatus.stories.tsx +++ b/frontend/src/metabase/admin/settings/slack/components/SlackStatus/SlackStatus.stories.tsx @@ -1,6 +1,6 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryObj } from "@storybook/react"; -import SlackStatus from "./SlackStatus"; +import SlackStatus, { type SlackStatusProps } from "./SlackStatus"; export default { title: "Admin/Settings/Slack/SlackStatus", @@ -11,11 +11,13 @@ export default { }, }; -export const Default: ComponentStory<typeof SlackStatus> = args => { - return <SlackStatus {...args} />; -}; +export const Default: StoryObj<SlackStatusProps> = { + render: args => { + return <SlackStatus {...args} />; + }, -Default.args = { - Form: () => <div />, - isValid: true, + args: { + Form: () => <div />, + isValid: true, + }, }; diff --git a/frontend/src/metabase/admin/upsells/components/UpsellCard.mdx b/frontend/src/metabase/admin/upsells/components/UpsellCard.mdx new file mode 100644 index 0000000000000000000000000000000000000000..947adc51a199dc73197cdc92d587fcf1b9a48c42 --- /dev/null +++ b/frontend/src/metabase/admin/upsells/components/UpsellCard.mdx @@ -0,0 +1,20 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { ReduxProvider } from "__support__/storybook"; +import { _UpsellCard } from "./UpsellCard"; +import * as UpsellCardStories from "./UpsellCard.stories"; + +<Meta of={UpsellCardStories} /> + +# Upsell Card + +- Use as a small, visible upsell, with or without an image + +## Examples + +<Canvas> + <Story of={UpsellCardStories.WithImage} /> +</Canvas> + +<Canvas> + <Story of={UpsellCardStories.WithoutImage} /> +</Canvas> diff --git a/frontend/src/metabase/admin/upsells/components/UpsellCard.stories.mdx b/frontend/src/metabase/admin/upsells/components/UpsellCard.stories.tsx similarity index 50% rename from frontend/src/metabase/admin/upsells/components/UpsellCard.stories.mdx rename to frontend/src/metabase/admin/upsells/components/UpsellCard.stories.tsx index 12d7f22cf7b5293f56bf3c21967bbf2b07917ecd..56b8f6e6520da61d1eaadcbe6950b9793499f5ce 100644 --- a/frontend/src/metabase/admin/upsells/components/UpsellCard.stories.mdx +++ b/frontend/src/metabase/admin/upsells/components/UpsellCard.stories.tsx @@ -1,8 +1,9 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; import { ReduxProvider } from "__support__/storybook"; -import { _UpsellCard } from "./UpsellCard"; +import { Flex } from "metabase/ui"; -export const args = { +import { type UpsellCardProps, _UpsellCard } from "./UpsellCard"; + +const args = { title: "Ice Cream", buttonText: "Get Some", buttonLink: "https://www.metabase.com", @@ -12,7 +13,7 @@ export const args = { children: "You wouldn't believe how great this stuff is.", }; -export const argTypes = { +const argTypes = { children: { control: { type: "text" }, }, @@ -31,25 +32,9 @@ export const argTypes = { source: { control: { type: "text" }, }, - children: { - control: { type: "text" }, - } }; -<Meta - title="Upsells/Card" - component={_UpsellCard} - args={args} - argTypes={argTypes} -/> - -# Upsell Card - -- Use as a small, visible upsell, with or without an image - -## Examples - -export const DefaultTemplate = (args) => ( +const DefaultTemplate = (args: UpsellCardProps) => ( <ReduxProvider> <Flex justify="center"> <_UpsellCard {...args} /> @@ -57,15 +42,20 @@ export const DefaultTemplate = (args) => ( </ReduxProvider> ); -export const WithImage = DefaultTemplate.bind({}); - -export const WithoutImage = DefaultTemplate.bind({}); -WithoutImage.args = { ...args, illustrationSrc: null}; +export default { + title: "Upsells/Card", + component: _UpsellCard, + args, + argTypes, +}; -<Canvas> - <Story name="With Image">{WithImage}</Story> -</Canvas> +export const WithImage = { + render: DefaultTemplate, + name: "With Image", +}; -<Canvas> - <Story name="Without Image">{WithoutImage}</Story> -</Canvas> +export const WithoutImage = { + render: DefaultTemplate, + name: "Without Image", + args: { ...args, illustrationSrc: null }, +}; diff --git a/frontend/src/metabase/admin/upsells/components/UpsellCard.tsx b/frontend/src/metabase/admin/upsells/components/UpsellCard.tsx index 4717eb93cb15aca5a175dcd9c6ce084b9b0fa77f..4e97369f9d9e701c93e580bb5b145058881b4f77 100644 --- a/frontend/src/metabase/admin/upsells/components/UpsellCard.tsx +++ b/frontend/src/metabase/admin/upsells/components/UpsellCard.tsx @@ -31,7 +31,7 @@ interface FixedWidthVariant { type Variants = FullWidthVariant | FixedWidthVariant; -type UpsellCardProps = OwnProps & Variants; +export type UpsellCardProps = OwnProps & Variants; export const _UpsellCard: React.FC<UpsellCardProps> = ({ title, diff --git a/frontend/src/metabase/admin/upsells/components/UpsellPill.mdx b/frontend/src/metabase/admin/upsells/components/UpsellPill.mdx new file mode 100644 index 0000000000000000000000000000000000000000..fdaa3b8f814a1f17d4ac240ce15f3bfc19309cf5 --- /dev/null +++ b/frontend/src/metabase/admin/upsells/components/UpsellPill.mdx @@ -0,0 +1,19 @@ +import { Box } from "metabase/ui"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { ReduxProvider } from "__support__/storybook"; +import { _UpsellPill } from "./UpsellPill"; +import * as UpsellPillStories from "./UpsellPill.stories"; + +<Meta of={UpsellPillStories} /> + +# Upsell Pill + +## Examples + +<Canvas> + <Story of={UpsellPillStories.Default} /> +</Canvas> + +<Canvas> + <Story of={UpsellPillStories.Multiline} /> +</Canvas> diff --git a/frontend/src/metabase/admin/upsells/components/UpsellPill.stories.mdx b/frontend/src/metabase/admin/upsells/components/UpsellPill.stories.tsx similarity index 53% rename from frontend/src/metabase/admin/upsells/components/UpsellPill.stories.mdx rename to frontend/src/metabase/admin/upsells/components/UpsellPill.stories.tsx index 79d4cb9e888f5f277ed8e41275e33eb26d93f33e..efde7919e2c6f267f0f33962623fc71d64bf9631 100644 --- a/frontend/src/metabase/admin/upsells/components/UpsellPill.stories.mdx +++ b/frontend/src/metabase/admin/upsells/components/UpsellPill.stories.tsx @@ -1,16 +1,18 @@ -import { Box } from "metabase/ui"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; +import type { ComponentProps } from "react"; + import { ReduxProvider } from "__support__/storybook"; +import { Box } from "metabase/ui"; + import { _UpsellPill } from "./UpsellPill"; -export const args = { +const args = { children: "Metabase Enterprise is so great", link: "https://www.metabase.com", campaign: "enterprise", source: "enterprise-page-footer", }; -export const argTypes = { +const argTypes = { children: { control: { type: "text" }, }, @@ -25,19 +27,9 @@ export const argTypes = { }, }; -<Meta - title="Upsells/Pill" - component={_UpsellPill} - args={args} - argTypes={argTypes} -/> +type UpsellPillProps = ComponentProps<typeof _UpsellPill>; -# Upsell Pill - - -## Examples - -export const DefaultTemplate = (args) => ( +const DefaultTemplate = (args: UpsellPillProps) => ( <ReduxProvider> <Box> <_UpsellPill {...args} /> @@ -45,26 +37,27 @@ export const DefaultTemplate = (args) => ( </ReduxProvider> ); -export const Default = DefaultTemplate.bind({}); - - -export const NarrowTemplate = (args) => ( +const NarrowTemplate = (args: UpsellPillProps) => ( <ReduxProvider> - <Box style={{ maxWidth: "10rem"}}> + <Box style={{ maxWidth: "10rem" }}> <_UpsellPill {...args} /> </Box> </ReduxProvider> ); -export const Narrow = NarrowTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - - -<Canvas> - <Story name="Multiline">{Narrow}</Story> -</Canvas> +export default { + title: "Upsells/Pill", + component: _UpsellPill, + args, + argTypes, +}; +export const Default = { + render: DefaultTemplate, + name: "Default", +}; +export const Multiline = { + render: NarrowTemplate, + name: "Multiline", +}; diff --git a/frontend/src/metabase/browse/components/BrowseModels.tsx b/frontend/src/metabase/browse/components/BrowseModels.tsx deleted file mode 100644 index 94e62e4c21eec54549dce340d37c35b8d9d65563..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/components/BrowseModels.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useMemo } from "react"; -import { t } from "ttag"; - -import NoResults from "assets/img/no_results.svg"; -import { useListRecentsQuery } from "metabase/api"; -import { useFetchModels } from "metabase/common/hooks/use-fetch-models"; -import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; -import { - PLUGIN_COLLECTIONS, - PLUGIN_CONTENT_VERIFICATION, -} from "metabase/plugins"; -import { Box, Flex, Group, Icon, Stack, Title } from "metabase/ui"; - -import type { ModelResult } from "../types"; -import { isRecentModel } from "../types"; -import { filterModels } from "../utils"; - -import { - BrowseContainer, - BrowseHeader, - BrowseMain, - BrowseSection, - CenteredEmptyState, -} from "./BrowseContainer.styled"; -import { ModelExplanationBanner } from "./ModelExplanationBanner"; -import { ModelsTable } from "./ModelsTable"; -import { RecentModels } from "./RecentModels"; -import { getMaxRecentModelCount } from "./utils"; - -const { availableModelFilters, useModelFilterSettings, ModelFilterControls } = - PLUGIN_CONTENT_VERIFICATION; - -export const BrowseModels = () => { - /** Mapping of filter names to true if the filter is active or false if it is inactive */ - const [actualModelFilters, setActualModelFilters] = useModelFilterSettings(); - - const modelsResult = useFetchModels({ model_ancestors: true }); - - const { models, doVerifiedModelsExist } = useMemo(() => { - const unfilteredModels = - (modelsResult.data?.data as ModelResult[] | undefined) ?? []; - const doVerifiedModelsExist = unfilteredModels.some( - model => model.moderated_status === "verified", - ); - const models = - PLUGIN_COLLECTIONS.filterOutItemsFromInstanceAnalytics(unfilteredModels); - return { models, doVerifiedModelsExist }; - }, [modelsResult]); - - const { filteredModels } = useMemo(() => { - const filteredModels = filterModels( - models, - // If no models are verified, don't filter them - doVerifiedModelsExist ? actualModelFilters : {}, - availableModelFilters, - ); - return { filteredModels }; - }, [actualModelFilters, models, doVerifiedModelsExist]); - - const recentModelsResult = useListRecentsQuery(undefined, { - refetchOnMountOrArgChange: true, - }); - - const filteredRecentModels = useMemo( - () => - filterModels( - recentModelsResult.data?.filter(isRecentModel), - // If no models are verified, don't filter them - doVerifiedModelsExist ? actualModelFilters : {}, - availableModelFilters, - ), - [recentModelsResult.data, actualModelFilters, doVerifiedModelsExist], - ); - - const recentModels = useMemo(() => { - const cap = getMaxRecentModelCount(models.length); - return filteredRecentModels.slice(0, cap); - }, [filteredRecentModels, models.length]); - - const isEmpty = - !recentModelsResult.isLoading && - !modelsResult.isLoading && - !filteredModels.length; - - return ( - <BrowseContainer> - <BrowseHeader role="heading" data-testid="browse-models-header"> - <BrowseSection> - <Flex - w="100%" - h="2.25rem" - direction="row" - justify="space-between" - align="center" - > - <Title order={1} color="text-dark"> - <Group spacing="sm"> - <Icon - size={24} - color="var(--mb-color-icon-primary)" - name="model" - /> - {t`Models`} - </Group> - </Title> - {doVerifiedModelsExist && ( - <ModelFilterControls - actualModelFilters={actualModelFilters} - setActualModelFilters={setActualModelFilters} - /> - )} - </Flex> - </BrowseSection> - </BrowseHeader> - <BrowseMain> - <BrowseSection> - <Stack mb="lg" spacing="md" w="100%"> - {isEmpty ? ( - <CenteredEmptyState - title={<Box mb=".5rem">{t`No models here yet`}</Box>} - message={ - <Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box> - } - illustrationElement={ - <Box mb=".5rem"> - <img src={NoResults} /> - </Box> - } - /> - ) : ( - <> - <ModelExplanationBanner /> - <DelayedLoadingAndErrorWrapper - error={recentModelsResult.error} - loading={ - // If the main models result is still pending, the list of recently viewed - // models isn't ready yet, since the number of recently viewed models is - // capped according to the size of the main models result - recentModelsResult.isLoading || modelsResult.isLoading - } - style={{ flex: 1 }} - loader={<RecentModels skeleton />} - > - <RecentModels models={recentModels} /> - </DelayedLoadingAndErrorWrapper> - <DelayedLoadingAndErrorWrapper - error={modelsResult.error} - loading={modelsResult.isLoading} - style={{ flex: 1 }} - loader={<ModelsTable skeleton />} - > - <ModelsTable models={filteredModels} /> - </DelayedLoadingAndErrorWrapper> - </> - )} - </Stack> - </BrowseSection> - </BrowseMain> - </BrowseContainer> - ); -}; diff --git a/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx b/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx index f762df7e2a1105c5328731b08b6bbbeaa85c025a..37ea6879b89cb6a413819e9be23b698c78556c31 100644 --- a/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx +++ b/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx @@ -9,8 +9,8 @@ import { getSetting } from "metabase/selectors/settings"; import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "metabase-lib/v1/metadata/utils/saved-questions"; import * as ML_Urls from "metabase-lib/v1/urls"; -import TableBrowser from "../../components/TableBrowser"; import { RELOAD_INTERVAL } from "../../constants"; +import TableBrowser from "../../tables/TableBrowser"; const getDatabaseId = (props, { includeVirtual } = {}) => { const { params } = props; diff --git a/frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx b/frontend/src/metabase/browse/databases/BrowseDatabases.styled.tsx similarity index 86% rename from frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx rename to frontend/src/metabase/browse/databases/BrowseDatabases.styled.tsx index 7ed6b06559958d44142eb0c882afae2022f5bc09..87a3679a3471e7b885d6e53b34519df14384ee81 100644 --- a/frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx +++ b/frontend/src/metabase/browse/databases/BrowseDatabases.styled.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router"; import Card from "metabase/components/Card"; -import { BrowseGrid } from "./BrowseContainer.styled"; +import { BrowseGrid } from "../components/BrowseContainer.styled"; export const DatabaseGrid = styled(BrowseGrid)``; diff --git a/frontend/src/metabase/browse/components/BrowseDatabases.tsx b/frontend/src/metabase/browse/databases/BrowseDatabases.tsx similarity index 94% rename from frontend/src/metabase/browse/components/BrowseDatabases.tsx rename to frontend/src/metabase/browse/databases/BrowseDatabases.tsx index 1690c1cb420cd5104553b3aaa6fe750d6dca4a4d..93d61a1bd22bbfcfd0fa58b93e6b364ba441e2a2 100644 --- a/frontend/src/metabase/browse/components/BrowseDatabases.tsx +++ b/frontend/src/metabase/browse/databases/BrowseDatabases.tsx @@ -13,8 +13,9 @@ import { BrowseMain, BrowseSection, CenteredEmptyState, -} from "./BrowseContainer.styled"; -import { BrowseDataHeader } from "./BrowseDataHeader"; +} from "../components/BrowseContainer.styled"; +import { BrowseDataHeader } from "../components/BrowseDataHeader"; + import { DatabaseCard, DatabaseCardLink, diff --git a/frontend/src/metabase/browse/components/BrowseDatabases.unit.spec.tsx b/frontend/src/metabase/browse/databases/BrowseDatabases.unit.spec.tsx similarity index 100% rename from frontend/src/metabase/browse/components/BrowseDatabases.unit.spec.tsx rename to frontend/src/metabase/browse/databases/BrowseDatabases.unit.spec.tsx diff --git a/frontend/src/metabase/browse/databases/index.tsx b/frontend/src/metabase/browse/databases/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7529e040d02997291f914c3dd88ca41e776c4bc6 --- /dev/null +++ b/frontend/src/metabase/browse/databases/index.tsx @@ -0,0 +1 @@ +export { BrowseDatabases } from "./BrowseDatabases"; diff --git a/frontend/src/metabase/browse/index.tsx b/frontend/src/metabase/browse/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a9e26c2d9a22fcbfd2f96bf5a6b1e1d132610ae7 --- /dev/null +++ b/frontend/src/metabase/browse/index.tsx @@ -0,0 +1,5 @@ +export { BrowseMetrics } from "./metrics"; +export { BrowseModels } from "./models"; +export { BrowseDatabases } from "./databases"; +export { BrowseTables } from "./tables"; +export { BrowseSchemas } from "./schemas"; diff --git a/frontend/src/metabase/browse/components/BrowseMetrics.tsx b/frontend/src/metabase/browse/metrics/BrowseMetrics.tsx similarity index 97% rename from frontend/src/metabase/browse/components/BrowseMetrics.tsx rename to frontend/src/metabase/browse/metrics/BrowseMetrics.tsx index 4a8c48f0421aaece1d309bbac7e3ab9534e61122..66264f2a439198088f8539b597ec2fbaf6f7cd31 100644 --- a/frontend/src/metabase/browse/components/BrowseMetrics.tsx +++ b/frontend/src/metabase/browse/metrics/BrowseMetrics.tsx @@ -10,16 +10,15 @@ import { useSelector } from "metabase/lib/redux"; import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins"; import { Box, Flex, Group, Icon, Stack, Text, Title } from "metabase/ui"; -import type { MetricResult } from "../types"; -import type { MetricFilterSettings } from "../utils"; - import { BrowseContainer, BrowseHeader, BrowseMain, BrowseSection, -} from "./BrowseContainer.styled"; +} from "../components/BrowseContainer.styled"; + import { MetricsTable } from "./MetricsTable"; +import type { MetricFilterSettings, MetricResult } from "./types"; const { contentVerificationEnabled, diff --git a/frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx b/frontend/src/metabase/browse/metrics/BrowseMetrics.unit.spec.tsx similarity index 98% rename from frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx rename to frontend/src/metabase/browse/metrics/BrowseMetrics.unit.spec.tsx index b5335e1e03d1c362304dd7113509e0289cacf2f7..d7a5c4d28f1e53af51f2a695e322fda18e8f8d37 100644 --- a/frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx +++ b/frontend/src/metabase/browse/metrics/BrowseMetrics.unit.spec.tsx @@ -14,10 +14,9 @@ import { } from "metabase-types/api/mocks"; import { createMockSetupState } from "metabase-types/store/mocks"; -import { createMockMetricResult, createMockRecentMetric } from "../test-utils"; -import type { MetricResult, RecentMetric } from "../types"; - import { BrowseMetrics } from "./BrowseMetrics"; +import { createMockMetricResult, createMockRecentMetric } from "./test-utils"; +import type { MetricResult, RecentMetric } from "./types"; type SetupOpts = { metricCount?: number; diff --git a/frontend/src/metabase/browse/components/MetricsTable.tsx b/frontend/src/metabase/browse/metrics/MetricsTable.tsx similarity index 98% rename from frontend/src/metabase/browse/components/MetricsTable.tsx rename to frontend/src/metabase/browse/metrics/MetricsTable.tsx index 7721eadb578fc46b49e779bc2d6312c219e3df9c..07971547644a244af7abb4d90cc17248c922582f 100644 --- a/frontend/src/metabase/browse/components/MetricsTable.tsx +++ b/frontend/src/metabase/browse/metrics/MetricsTable.tsx @@ -42,8 +42,6 @@ import { import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; -import type { MetricResult } from "../types"; - import { Cell, CollectionLink, @@ -53,11 +51,13 @@ import { Value, ValueTableCell, ValueWrapper, -} from "./BrowseTable.styled"; +} from "../components/BrowseTable.styled"; + +import type { MetricResult } from "./types"; import { getDatasetValueForMetric, getMetricDescription, - sortModelOrMetric, + sortMetrics, } from "./utils"; type MetricsTableProps = { @@ -109,7 +109,7 @@ export function MetricsTable({ ); const locale = useLocale(); - const sortedMetrics = sortModelOrMetric(metrics, sortingOptions, locale); + const sortedMetrics = sortMetrics(metrics, sortingOptions, locale); const handleSortingOptionsChange = skeleton ? undefined : setSortingOptions; diff --git a/frontend/src/metabase/browse/metrics/index.tsx b/frontend/src/metabase/browse/metrics/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9eefac1ac955fed05b847ef5bac3663021ce8034 --- /dev/null +++ b/frontend/src/metabase/browse/metrics/index.tsx @@ -0,0 +1,6 @@ +export { BrowseMetrics } from "./BrowseMetrics"; +export type { + MetricFilterControlsProps, + MetricFilterSettings, + RecentMetric, +} from "./types"; diff --git a/frontend/src/metabase/browse/metrics/test-utils.ts b/frontend/src/metabase/browse/metrics/test-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..a420b268588a0fa164193c15e15783e9353d370d --- /dev/null +++ b/frontend/src/metabase/browse/metrics/test-utils.ts @@ -0,0 +1,19 @@ +import { + createMockRecentCollectionItem, + createMockSearchResult, +} from "metabase-types/api/mocks"; + +import type { MetricResult, RecentMetric } from "./types"; + +export const createMockMetricResult = ( + metric: Partial<MetricResult> = {}, +): MetricResult => + createMockSearchResult({ ...metric, model: "metric" }) as MetricResult; + +export const createMockRecentMetric = ( + metric: Partial<RecentMetric>, +): RecentMetric => + createMockRecentCollectionItem({ + ...metric, + model: "metric", + }) as RecentMetric; diff --git a/frontend/src/metabase/browse/metrics/types.tsx b/frontend/src/metabase/browse/metrics/types.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f3ba40104fcaced1a0e98079a8487908fea0f9ff --- /dev/null +++ b/frontend/src/metabase/browse/metrics/types.tsx @@ -0,0 +1,19 @@ +import type { RecentCollectionItem, SearchResult } from "metabase-types/api"; + +/** + * Metric retrieved through the search endpoint + */ +export type MetricResult = SearchResult<number, "metric">; + +export interface RecentMetric extends RecentCollectionItem { + model: "metric"; +} + +export type MetricFilterSettings = { + verified?: boolean; +}; + +export type MetricFilterControlsProps = { + metricFilters: MetricFilterSettings; + setMetricFilters: (settings: MetricFilterSettings) => void; +}; diff --git a/frontend/src/metabase/browse/components/utils.tsx b/frontend/src/metabase/browse/metrics/utils.tsx similarity index 68% rename from frontend/src/metabase/browse/components/utils.tsx rename to frontend/src/metabase/browse/metrics/utils.tsx index c12fb60054eca2c12576fa2023f61a580929e99c..919e223535fdb02e2bd8aea017b7714fc0c8b1d5 100644 --- a/frontend/src/metabase/browse/components/utils.tsx +++ b/frontend/src/metabase/browse/metrics/utils.tsx @@ -3,24 +3,10 @@ import { t } from "ttag"; import { getCollectionPathAsString } from "metabase/collections/utils"; import { formatValue } from "metabase/lib/formatting"; import { isDate } from "metabase-lib/v1/types/utils/isa"; -import type { Dataset, SearchResult } from "metabase-types/api"; +import type { Dataset } from "metabase-types/api"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; -import type { MetricResult, ModelResult } from "../types"; - -export type ModelOrMetricResult = ModelResult | MetricResult; - -export const isModel = (item: SearchResult) => item.model === "dataset"; - -export const getModelDescription = (item: ModelResult) => { - if (item.collection && !item.description?.trim()) { - return t`A model`; - } else { - return item.description; - } -}; - -export const isMetric = (item: SearchResult) => item.model === "metric"; +import type { MetricResult } from "./types"; export const getMetricDescription = (item: MetricResult) => { if (item.collection && !item.description?.trim()) { @@ -31,30 +17,30 @@ export const getMetricDescription = (item: MetricResult) => { }; const getValueForSorting = ( - model: ModelResult | MetricResult, - sort_column: keyof ModelResult, + metric: MetricResult, + sort_column: keyof MetricResult, ): string => { if (sort_column === "collection") { - return getCollectionPathAsString(model.collection) ?? ""; + return getCollectionPathAsString(metric.collection) ?? ""; } else { - return model[sort_column] ?? ""; + return metric[sort_column] ?? ""; } }; export const isValidSortColumn = ( sort_column: string, -): sort_column is keyof ModelResult => { +): sort_column is keyof MetricResult => { return ["name", "collection", "description"].includes(sort_column); }; export const getSecondarySortColumn = ( sort_column: string, -): keyof ModelResult => { +): keyof MetricResult => { return sort_column === "name" ? "collection" : "name"; }; -export function sortModelOrMetric<T extends ModelOrMetricResult>( - modelsOrMetrics: T[], +export function sortMetrics( + metrics: MetricResult[], sortingOptions: SortingOptions, localeCode: string = "en", ) { @@ -62,21 +48,21 @@ export function sortModelOrMetric<T extends ModelOrMetricResult>( if (!isValidSortColumn(sort_column)) { console.error("Invalid sort column", sort_column); - return modelsOrMetrics; + return metrics; } const compare = (a: string, b: string) => a.localeCompare(b, localeCode, { sensitivity: "base" }); - return [...modelsOrMetrics].sort((modelOrMetricA, modelOrMetricB) => { - const a = getValueForSorting(modelOrMetricA, sort_column); - const b = getValueForSorting(modelOrMetricB, sort_column); + return [...metrics].sort((metricA, metricB) => { + const a = getValueForSorting(metricA, sort_column); + const b = getValueForSorting(metricB, sort_column); let result = compare(a, b); if (result === 0) { const sort_column2 = getSecondarySortColumn(sort_column); - const a2 = getValueForSorting(modelOrMetricA, sort_column2); - const b2 = getValueForSorting(modelOrMetricB, sort_column2); + const a2 = getValueForSorting(metricA, sort_column2); + const b2 = getValueForSorting(metricB, sort_column2); result = compare(a2, b2); } @@ -84,22 +70,6 @@ export function sortModelOrMetric<T extends ModelOrMetricResult>( }); } -/** Find the maximum number of recently viewed models to show. - * This is roughly proportional to the number of models the user - * has permission to see */ -export const getMaxRecentModelCount = ( - /** How many models the user has permission to see */ - modelCount: number, -) => { - if (modelCount > 20) { - return 8; - } - if (modelCount > 9) { - return 4; - } - return 0; -}; - export function isDatasetScalar(dataset: Dataset) { if (dataset.error) { return false; diff --git a/frontend/src/metabase/browse/components/utils.unit.spec.tsx b/frontend/src/metabase/browse/metrics/utils.unit.spec.tsx similarity index 72% rename from frontend/src/metabase/browse/components/utils.unit.spec.tsx rename to frontend/src/metabase/browse/metrics/utils.unit.spec.tsx index 1c22219bead8eb4dd058f3473df03c36b7e34a72..325dd859a520f93924d1d38936430055fd685cd2 100644 --- a/frontend/src/metabase/browse/components/utils.unit.spec.tsx +++ b/frontend/src/metabase/browse/metrics/utils.unit.spec.tsx @@ -6,20 +6,18 @@ import { } from "metabase-types/api/mocks"; import { SortDirection } from "metabase-types/api/sorting"; -import { createMockModelResult } from "../test-utils"; -import type { ModelResult } from "../types"; - +import { createMockMetricResult } from "./test-utils"; +import type { MetricResult } from "./types"; import { getDatasetValueForMetric, - getMaxRecentModelCount, isDatasetScalar, - sortModelOrMetric, + sortMetrics, } from "./utils"; -describe("sortModels", () => { +describe("sortMetrics", () => { let id = 0; - const modelMap: Record<string, ModelResult> = { - "model named A, with collection path X / Y / Z": createMockModelResult({ + const metricMap: Record<string, MetricResult> = { + "model named A, with collection path X / Y / Z": createMockMetricResult({ id: id++, name: "A", collection: createMockCollection({ @@ -30,12 +28,12 @@ describe("sortModels", () => { ], }), }), - "model named C, with collection path Y": createMockModelResult({ + "model named C, with collection path Y": createMockMetricResult({ id: id++, name: "C", collection: createMockCollection({ name: "Y" }), }), - "model named B, with collection path D / E / F": createMockModelResult({ + "model named B, with collection path D / E / F": createMockMetricResult({ id: id++, name: "B", collection: createMockCollection({ @@ -47,14 +45,14 @@ describe("sortModels", () => { }), }), }; - const mockSearchResults = Object.values(modelMap); + const mockSearchResults = Object.values(metricMap); it("can sort by name in ascending order", () => { const sortingOptions = { sort_column: "name", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["A", "B", "C"]); }); @@ -63,7 +61,7 @@ describe("sortModels", () => { sort_column: "name", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["C", "B", "A"]); }); @@ -72,7 +70,7 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["B", "A", "C"]); }); @@ -81,17 +79,19 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["C", "A", "B"]); }); describe("secondary sort", () => { - modelMap["model named C, with collection path Z"] = createMockModelResult({ - name: "C", - collection: createMockCollection({ name: "Z" }), - }); - modelMap["model named Bz, with collection path D / E / F"] = - createMockModelResult({ + metricMap["model named C, with collection path Z"] = createMockMetricResult( + { + name: "C", + collection: createMockCollection({ name: "Z" }), + }, + ); + metricMap["model named Bz, with collection path D / E / F"] = + createMockMetricResult({ name: "Bz", collection: createMockCollection({ name: "F", @@ -101,20 +101,20 @@ describe("sortModels", () => { ], }), }); - const mockSearchResults = Object.values(modelMap); + const mockSearchResults = Object.values(metricMap); it("can sort by collection path, ascending, and then does a secondary sort by name", () => { const sortingOptions = { sort_column: "collection", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted).toEqual([ - modelMap["model named B, with collection path D / E / F"], - modelMap["model named Bz, with collection path D / E / F"], - modelMap["model named A, with collection path X / Y / Z"], - modelMap["model named C, with collection path Y"], - modelMap["model named C, with collection path Z"], + metricMap["model named B, with collection path D / E / F"], + metricMap["model named Bz, with collection path D / E / F"], + metricMap["model named A, with collection path X / Y / Z"], + metricMap["model named C, with collection path Y"], + metricMap["model named C, with collection path Z"], ]); }); @@ -123,13 +123,13 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); + const sorted = sortMetrics(mockSearchResults, sortingOptions); expect(sorted).toEqual([ - modelMap["model named C, with collection path Z"], - modelMap["model named C, with collection path Y"], - modelMap["model named A, with collection path X / Y / Z"], - modelMap["model named Bz, with collection path D / E / F"], - modelMap["model named B, with collection path D / E / F"], + metricMap["model named C, with collection path Z"], + metricMap["model named C, with collection path Y"], + metricMap["model named A, with collection path X / Y / Z"], + metricMap["model named Bz, with collection path D / E / F"], + metricMap["model named B, with collection path D / E / F"], ]); }); @@ -139,7 +139,7 @@ describe("sortModels", () => { sort_direction: SortDirection.Asc, } as const; - const addUmlauts = (model: ModelResult): ModelResult => ({ + const addUmlauts = (model: MetricResult): MetricResult => ({ ...model, name: model.name.replace(/^B$/g, "Bä"), collection: { @@ -153,63 +153,45 @@ describe("sortModels", () => { }, }); - const swedishModelMap = { + const swedishmetricMap = { "model named A, with collection path Ä / Y / Z": addUmlauts( - modelMap["model named A, with collection path X / Y / Z"], + metricMap["model named A, with collection path X / Y / Z"], ), "model named Bä, with collection path D / E / F": addUmlauts( - modelMap["model named B, with collection path D / E / F"], + metricMap["model named B, with collection path D / E / F"], ), "model named Bz, with collection path D / E / F": addUmlauts( - modelMap["model named Bz, with collection path D / E / F"], + metricMap["model named Bz, with collection path D / E / F"], ), "model named C, with collection path Y": addUmlauts( - modelMap["model named C, with collection path Y"], + metricMap["model named C, with collection path Y"], ), "model named C, with collection path Z": addUmlauts( - modelMap["model named C, with collection path Z"], + metricMap["model named C, with collection path Z"], ), }; - const swedishResults = Object.values(swedishModelMap); + const swedishResults = Object.values(swedishmetricMap); // When sorting in Swedish, z comes before ä const swedishLocaleCode = "sv"; - const sorted = sortModelOrMetric( + const sorted = sortMetrics( swedishResults, sortingOptions, swedishLocaleCode, ); expect("ä".localeCompare("z", "sv", { sensitivity: "base" })).toEqual(1); expect(sorted).toEqual([ - swedishModelMap["model named Bz, with collection path D / E / F"], // Model Bz sorts before Bä - swedishModelMap["model named Bä, with collection path D / E / F"], - swedishModelMap["model named C, with collection path Y"], - swedishModelMap["model named C, with collection path Z"], // Collection Z sorts before Ä - swedishModelMap["model named A, with collection path Ä / Y / Z"], + swedishmetricMap["model named Bz, with collection path D / E / F"], // Model Bz sorts before Bä + swedishmetricMap["model named Bä, with collection path D / E / F"], + swedishmetricMap["model named C, with collection path Y"], + swedishmetricMap["model named C, with collection path Z"], // Collection Z sorts before Ä + swedishmetricMap["model named A, with collection path Ä / Y / Z"], ]); }); }); }); -describe("getMaxRecentModelCount", () => { - it("returns 8 for modelCount greater than 20", () => { - expect(getMaxRecentModelCount(21)).toBe(8); - expect(getMaxRecentModelCount(100)).toBe(8); - }); - - it("returns 4 for modelCount greater than 9 and less than or equal to 20", () => { - expect(getMaxRecentModelCount(10)).toBe(4); - expect(getMaxRecentModelCount(20)).toBe(4); - }); - - it("returns 0 for modelCount of 9 or less", () => { - expect(getMaxRecentModelCount(0)).toBe(0); - expect(getMaxRecentModelCount(5)).toBe(0); - expect(getMaxRecentModelCount(9)).toBe(0); - }); -}); - describe("isDatasetScalar", () => { it("should return true for a dataset with a single column and a single row", () => { const dataset = createMockDataset({ diff --git a/frontend/src/metabase/browse/components/BrowseModels.styled.tsx b/frontend/src/metabase/browse/models/BrowseModels.styled.tsx similarity index 98% rename from frontend/src/metabase/browse/components/BrowseModels.styled.tsx rename to frontend/src/metabase/browse/models/BrowseModels.styled.tsx index dda35826be06393c48cb57e594f1bf7fb2c25ad0..720e82f301fcaf8149d2312c46df72603d443520 100644 --- a/frontend/src/metabase/browse/components/BrowseModels.styled.tsx +++ b/frontend/src/metabase/browse/models/BrowseModels.styled.tsx @@ -7,7 +7,7 @@ import { Ellipsified } from "metabase/core/components/Ellipsified"; import Link from "metabase/core/components/Link"; import { Box, type ButtonProps, Collapse, Icon } from "metabase/ui"; -import { BrowseGrid } from "./BrowseContainer.styled"; +import { BrowseGrid } from "../components/BrowseContainer.styled"; export const ModelCardLink = styled(Link)` margin: 0.5rem 0; diff --git a/frontend/src/metabase/browse/models/BrowseModels.tsx b/frontend/src/metabase/browse/models/BrowseModels.tsx new file mode 100644 index 0000000000000000000000000000000000000000..afc510f1e4e6d03b490106f0d6448be8bf819945 --- /dev/null +++ b/frontend/src/metabase/browse/models/BrowseModels.tsx @@ -0,0 +1,209 @@ +import { useState } from "react"; +import { t } from "ttag"; + +import NoResults from "assets/img/no_results.svg"; +import { skipToken, useListRecentsQuery } from "metabase/api"; +import { useFetchModels } from "metabase/common/hooks/use-fetch-models"; +import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; +import { useSelector } from "metabase/lib/redux"; +import { + PLUGIN_COLLECTIONS, + PLUGIN_CONTENT_VERIFICATION, +} from "metabase/plugins"; +import { Box, Flex, Group, Icon, Stack, Title } from "metabase/ui"; + +import { + BrowseContainer, + BrowseHeader, + BrowseMain, + BrowseSection, + CenteredEmptyState, +} from "../components/BrowseContainer.styled"; + +import { ModelExplanationBanner } from "./ModelExplanationBanner"; +import { ModelsTable } from "./ModelsTable"; +import { RecentModels } from "./RecentModels"; +import type { ModelFilterSettings, ModelResult } from "./types"; +import { getMaxRecentModelCount, isRecentModel } from "./utils"; + +const { + contentVerificationEnabled, + ModelFilterControls, + getDefaultModelFilters, +} = PLUGIN_CONTENT_VERIFICATION; + +export const BrowseModels = () => { + const [modelFilters, setModelFilters] = useModelFilterSettings(); + const { isLoading, error, models, recentModels, hasVerifiedModels } = + useFilteredModels(modelFilters); + + const isEmpty = !isLoading && models.length === 0; + + return ( + <BrowseContainer> + <BrowseHeader role="heading" data-testid="browse-models-header"> + <BrowseSection> + <Flex + w="100%" + h="2.25rem" + direction="row" + justify="space-between" + align="center" + > + <Title order={1} color="text-dark"> + <Group spacing="sm"> + <Icon + size={24} + color="var(--mb-color-icon-primary)" + name="model" + /> + {t`Models`} + </Group> + </Title> + {hasVerifiedModels && ( + <ModelFilterControls + modelFilters={modelFilters} + setModelFilters={setModelFilters} + /> + )} + </Flex> + </BrowseSection> + </BrowseHeader> + <BrowseMain> + <BrowseSection> + <Stack mb="lg" spacing="md" w="100%"> + {isEmpty ? ( + <CenteredEmptyState + title={<Box mb=".5rem">{t`No models here yet`}</Box>} + message={ + <Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box> + } + illustrationElement={ + <Box mb=".5rem"> + <img src={NoResults} /> + </Box> + } + /> + ) : ( + <> + <ModelExplanationBanner /> + <DelayedLoadingAndErrorWrapper + error={error} + loading={isLoading} + style={{ flex: 1 }} + loader={<RecentModels skeleton />} + > + <RecentModels models={recentModels} /> + </DelayedLoadingAndErrorWrapper> + <DelayedLoadingAndErrorWrapper + error={error} + loading={isLoading} + style={{ flex: 1 }} + loader={<ModelsTable skeleton />} + > + <ModelsTable models={models} /> + </DelayedLoadingAndErrorWrapper> + </> + )} + </Stack> + </BrowseSection> + </BrowseMain> + </BrowseContainer> + ); +}; + +function useModelFilterSettings() { + const defaultModelFilters = useSelector(getDefaultModelFilters); + return useState(defaultModelFilters); +} + +function useHasVerifiedModels() { + const result = useFetchModels( + contentVerificationEnabled + ? { + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + limit: 0, + verified: true, + } + : skipToken, + ); + + if (!contentVerificationEnabled) { + return { + isLoading: false, + error: null, + result: false, + }; + } + + const total = result.data?.total ?? 0; + + return { + isLoading: result.isLoading, + error: result.error, + result: total > 0, + }; +} + +function useFilteredModels(modelFilters: ModelFilterSettings) { + const hasVerifiedModels = useHasVerifiedModels(); + + const filters = cleanModelFilters(modelFilters, hasVerifiedModels.result); + + const modelsResult = useFetchModels( + hasVerifiedModels.isLoading || hasVerifiedModels.error + ? skipToken + : { + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + ...filters, + }, + ); + + const models = modelsResult.data?.data as ModelResult[] | undefined; + + const recentsCap = getMaxRecentModelCount(models?.length ?? 0); + + const recentModelsResult = useListRecentsQuery(undefined, { + refetchOnMountOrArgChange: true, + skip: recentsCap === 0, + }); + + const isLoading = + hasVerifiedModels.isLoading || + modelsResult.isLoading || + recentModelsResult.isLoading; + + const error = + hasVerifiedModels.error || modelsResult.error || recentModelsResult.error; + + return { + isLoading, + error, + hasVerifiedModels: hasVerifiedModels.result, + models: PLUGIN_COLLECTIONS.filterOutItemsFromInstanceAnalytics( + models ?? [], + ), + + recentModels: (recentModelsResult.data ?? []) + .filter(isRecentModel) + .filter( + model => !filters.verified || model.moderated_status === "verified", + ) + .slice(0, recentsCap), + }; +} + +function cleanModelFilters( + modelFilters: ModelFilterSettings, + hasVerifiedModels: boolean, +) { + const filters = { ...modelFilters }; + if (!hasVerifiedModels || !filters.verified) { + // we cannot pass false or undefined to the backend + // delete the key instead + delete filters.verified; + } + return filters; +} diff --git a/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx b/frontend/src/metabase/browse/models/BrowseModels.unit.spec.tsx similarity index 99% rename from frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx rename to frontend/src/metabase/browse/models/BrowseModels.unit.spec.tsx index e05f459ff2e5147c0f6b411fd317ef5667de8f08..d5d8969d015ed376d839d3a6d4b2d4201d613d91 100644 --- a/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx +++ b/frontend/src/metabase/browse/models/BrowseModels.unit.spec.tsx @@ -10,9 +10,8 @@ import { } from "metabase-types/api/mocks"; import { createMockSetupState } from "metabase-types/store/mocks"; -import { createMockModelResult, createMockRecentModel } from "../test-utils"; - import { BrowseModels } from "./BrowseModels"; +import { createMockModelResult, createMockRecentModel } from "./test-utils"; const defaultRootCollection = createMockCollection({ id: "root", diff --git a/frontend/src/metabase/browse/components/ModelExplanationBanner.tsx b/frontend/src/metabase/browse/models/ModelExplanationBanner.tsx similarity index 100% rename from frontend/src/metabase/browse/components/ModelExplanationBanner.tsx rename to frontend/src/metabase/browse/models/ModelExplanationBanner.tsx diff --git a/frontend/src/metabase/browse/components/ModelsTable.tsx b/frontend/src/metabase/browse/models/ModelsTable.tsx similarity index 96% rename from frontend/src/metabase/browse/components/ModelsTable.tsx rename to frontend/src/metabase/browse/models/ModelsTable.tsx index 815dd8619795274863ab907bac71b803fa17a9bf..8b4376eb5c7f2f4d92d1b211e72fc7726c06ffcb 100644 --- a/frontend/src/metabase/browse/components/ModelsTable.tsx +++ b/frontend/src/metabase/browse/models/ModelsTable.tsx @@ -24,18 +24,17 @@ import { FixedSizeIcon, Flex, Icon, Skeleton } from "metabase/ui"; import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; -import { trackModelClick } from "../analytics"; -import type { ModelResult } from "../types"; -import { getIcon } from "../utils"; - import { Cell, CollectionLink, CollectionTableCell, NameColumn, TableRow, -} from "./BrowseTable.styled"; -import { getModelDescription, sortModelOrMetric } from "./utils"; +} from "../components/BrowseTable.styled"; + +import { trackModelClick } from "./analytics"; +import type { ModelResult } from "./types"; +import { getIcon, getModelDescription, sortModels } from "./utils"; export interface ModelsTableProps { models?: ModelResult[]; @@ -69,7 +68,7 @@ export const ModelsTable = ({ ); const locale = useLocale(); - const sortedModels = sortModelOrMetric(models, sortingOptions, locale); + const sortedModels = sortModels(models, sortingOptions, locale); /** The name column has an explicitly set width. The remaining columns divide the remaining width. This is the percentage allocated to the collection column */ const collectionWidth = 38.5; diff --git a/frontend/src/metabase/browse/components/RecentModels.styled.tsx b/frontend/src/metabase/browse/models/RecentModels.styled.tsx similarity index 100% rename from frontend/src/metabase/browse/components/RecentModels.styled.tsx rename to frontend/src/metabase/browse/models/RecentModels.styled.tsx diff --git a/frontend/src/metabase/browse/components/RecentModels.tsx b/frontend/src/metabase/browse/models/RecentModels.tsx similarity index 96% rename from frontend/src/metabase/browse/components/RecentModels.tsx rename to frontend/src/metabase/browse/models/RecentModels.tsx index 66b750040c38d112f0bbb2856a4232e1954d98bf..c4aa34dd730b3218bb4e81c876a11ed914e8a78c 100644 --- a/frontend/src/metabase/browse/components/RecentModels.tsx +++ b/frontend/src/metabase/browse/models/RecentModels.tsx @@ -5,9 +5,8 @@ import { Box, Text } from "metabase/ui"; import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import type { RecentCollectionItem } from "metabase-types/api"; -import { trackModelClick } from "../analytics"; - import { RecentModelsGrid } from "./RecentModels.styled"; +import { trackModelClick } from "./analytics"; export function RecentModels({ models = [], diff --git a/frontend/src/metabase/browse/models/analytics.ts b/frontend/src/metabase/browse/models/analytics.ts new file mode 100644 index 0000000000000000000000000000000000000000..f176dabfda3f97369627d3ea09e8b4696fafd501 --- /dev/null +++ b/frontend/src/metabase/browse/models/analytics.ts @@ -0,0 +1,8 @@ +import { trackSchemaEvent } from "metabase/lib/analytics"; +import type { CardId } from "metabase-types/api"; + +export const trackModelClick = (modelId: CardId) => + trackSchemaEvent("browse_data", { + event: "browse_data_model_clicked", + model_id: modelId, + }); diff --git a/frontend/src/metabase/browse/models/index.tsx b/frontend/src/metabase/browse/models/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ac8edc8ec027f65f3c0bfcfe615ac6576c40437 --- /dev/null +++ b/frontend/src/metabase/browse/models/index.tsx @@ -0,0 +1,7 @@ +export { BrowseModels } from "./BrowseModels"; +export type { + ModelFilterSettings, + ModelFilterControlsProps, + ModelResult, + RecentModel, +} from "./types"; diff --git a/frontend/src/metabase/browse/test-utils.ts b/frontend/src/metabase/browse/models/test-utils.ts similarity index 53% rename from frontend/src/metabase/browse/test-utils.ts rename to frontend/src/metabase/browse/models/test-utils.ts index 38ee0947246e2cabf0ed92458fb8c0b0d4a5e1d3..c3b0e8c7635df326386a8be0673f193d902d16ee 100644 --- a/frontend/src/metabase/browse/test-utils.ts +++ b/frontend/src/metabase/browse/models/test-utils.ts @@ -4,12 +4,7 @@ import { createMockSearchResult, } from "metabase-types/api/mocks"; -import type { - MetricResult, - ModelResult, - RecentMetric, - RecentModel, -} from "./types"; +import type { ModelResult, RecentModel } from "./types"; export const createMockModelResult = ( model: Partial<ModelResult> = {}, @@ -20,16 +15,3 @@ export const createMockRecentModel = ( model: Partial<RecentCollectionItem>, ): RecentModel => createMockRecentCollectionItem({ ...model, model: "dataset" }) as RecentModel; - -export const createMockMetricResult = ( - metric: Partial<MetricResult> = {}, -): MetricResult => - createMockSearchResult({ ...metric, model: "metric" }) as MetricResult; - -export const createMockRecentMetric = ( - metric: Partial<RecentMetric>, -): RecentMetric => - createMockRecentCollectionItem({ - ...metric, - model: "metric", - }) as RecentMetric; diff --git a/frontend/src/metabase/browse/models/types.tsx b/frontend/src/metabase/browse/models/types.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ac0b4db850aaf148d3489fbea0e9dc0d6fed4f71 --- /dev/null +++ b/frontend/src/metabase/browse/models/types.tsx @@ -0,0 +1,22 @@ +import type { RecentCollectionItem, SearchResult } from "metabase-types/api"; + +/** + * Model retrieved through the search endpoint + */ +export type ModelResult = SearchResult<number, "dataset">; + +/** + * Model retrieved through the recent views endpoint + */ +export interface RecentModel extends RecentCollectionItem { + model: "dataset"; +} + +export type ModelFilterSettings = { + verified?: boolean; +}; + +export type ModelFilterControlsProps = { + modelFilters: ModelFilterSettings; + setModelFilters: (settings: ModelFilterSettings) => void; +}; diff --git a/frontend/src/metabase/browse/models/utils.ts b/frontend/src/metabase/browse/models/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..e35852bc23e26f318d053a612b4a168613463414 --- /dev/null +++ b/frontend/src/metabase/browse/models/utils.ts @@ -0,0 +1,98 @@ +import { t } from "ttag"; +import _ from "underscore"; + +import { getCollectionPathAsString } from "metabase/collections/utils"; +import { entityForObject } from "metabase/lib/schema"; +import type { IconName } from "metabase/ui"; +import type { RecentItem, SearchResult } from "metabase-types/api"; +import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; + +import type { ModelResult, RecentModel } from "./types"; + +export const isModel = (item: SearchResult) => item.model === "dataset"; + +export const isRecentModel = (item: RecentItem): item is RecentModel => + item.model === "dataset"; + +export const getModelDescription = (item: ModelResult) => { + if (item.collection && !item.description?.trim()) { + return t`A model`; + } else { + return item.description; + } +}; + +const getValueForSorting = ( + model: ModelResult, + sort_column: keyof ModelResult, +): string => { + if (sort_column === "collection") { + return getCollectionPathAsString(model.collection) ?? ""; + } else { + return model[sort_column] ?? ""; + } +}; + +export const isValidSortColumn = ( + sort_column: string, +): sort_column is keyof ModelResult => { + return ["name", "collection", "description"].includes(sort_column); +}; + +export const getSecondarySortColumn = ( + sort_column: string, +): keyof ModelResult => { + return sort_column === "name" ? "collection" : "name"; +}; + +export function sortModels( + models: ModelResult[], + sortingOptions: SortingOptions, + localeCode: string = "en", +) { + const { sort_column, sort_direction } = sortingOptions; + + if (!isValidSortColumn(sort_column)) { + console.error("Invalid sort column", sort_column); + return models; + } + + const compare = (a: string, b: string) => + a.localeCompare(b, localeCode, { sensitivity: "base" }); + + return [...models].sort((modelA, modelB) => { + const a = getValueForSorting(modelA, sort_column); + const b = getValueForSorting(modelB, sort_column); + + let result = compare(a, b); + if (result === 0) { + const sort_column2 = getSecondarySortColumn(sort_column); + const a2 = getValueForSorting(modelA, sort_column2); + const b2 = getValueForSorting(modelB, sort_column2); + result = compare(a2, b2); + } + + return sort_direction === SortDirection.Asc ? result : -result; + }); +} + +/** Find the maximum number of recently viewed models to show. + * This is roughly proportional to the number of models the user + * has permission to see */ +export const getMaxRecentModelCount = ( + /** How many models the user has permission to see */ + modelCount: number, +) => { + if (modelCount > 20) { + return 8; + } + if (modelCount > 9) { + return 4; + } + return 0; +}; + +export const getIcon = (item: unknown): { name: IconName; color: string } => { + const entity = entityForObject(item); + return entity?.objectSelectors?.getIcon?.(item) || { name: "folder" }; +}; diff --git a/frontend/src/metabase/browse/models/utils.unit.spec.tsx b/frontend/src/metabase/browse/models/utils.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a33bd9014f67475091d0c78a21a38e1184eed25f --- /dev/null +++ b/frontend/src/metabase/browse/models/utils.unit.spec.tsx @@ -0,0 +1,200 @@ +import { createMockCollection } from "metabase-types/api/mocks"; +import { SortDirection } from "metabase-types/api/sorting"; + +import { createMockModelResult } from "./test-utils"; +import type { ModelResult } from "./types"; +import { getMaxRecentModelCount, sortModels } from "./utils"; + +describe("sortModels", () => { + let id = 0; + const modelMap: Record<string, ModelResult> = { + "model named A, with collection path X / Y / Z": createMockModelResult({ + id: id++, + name: "A", + collection: createMockCollection({ + name: "Z", + effective_ancestors: [ + createMockCollection({ name: "X" }), + createMockCollection({ name: "Y" }), + ], + }), + }), + "model named C, with collection path Y": createMockModelResult({ + id: id++, + name: "C", + collection: createMockCollection({ name: "Y" }), + }), + "model named B, with collection path D / E / F": createMockModelResult({ + id: id++, + name: "B", + collection: createMockCollection({ + name: "F", + effective_ancestors: [ + createMockCollection({ name: "D" }), + createMockCollection({ name: "E" }), + ], + }), + }), + }; + const mockSearchResults = Object.values(modelMap); + + it("can sort by name in ascending order", () => { + const sortingOptions = { + sort_column: "name", + sort_direction: SortDirection.Asc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted?.map(model => model.name)).toEqual(["A", "B", "C"]); + }); + + it("can sort by name in descending order", () => { + const sortingOptions = { + sort_column: "name", + sort_direction: SortDirection.Desc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted?.map(model => model.name)).toEqual(["C", "B", "A"]); + }); + + it("can sort by collection path in ascending order", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Asc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted?.map(model => model.name)).toEqual(["B", "A", "C"]); + }); + + it("can sort by collection path in descending order", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Desc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted?.map(model => model.name)).toEqual(["C", "A", "B"]); + }); + + describe("secondary sort", () => { + modelMap["model named C, with collection path Z"] = createMockModelResult({ + name: "C", + collection: createMockCollection({ name: "Z" }), + }); + modelMap["model named Bz, with collection path D / E / F"] = + createMockModelResult({ + name: "Bz", + collection: createMockCollection({ + name: "F", + effective_ancestors: [ + createMockCollection({ name: "D" }), + createMockCollection({ name: "E" }), + ], + }), + }); + const mockSearchResults = Object.values(modelMap); + + it("can sort by collection path, ascending, and then does a secondary sort by name", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Asc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted).toEqual([ + modelMap["model named B, with collection path D / E / F"], + modelMap["model named Bz, with collection path D / E / F"], + modelMap["model named A, with collection path X / Y / Z"], + modelMap["model named C, with collection path Y"], + modelMap["model named C, with collection path Z"], + ]); + }); + + it("can sort by collection path, descending, and then does a secondary sort by name", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Desc, + } as const; + const sorted = sortModels(mockSearchResults, sortingOptions); + expect(sorted).toEqual([ + modelMap["model named C, with collection path Z"], + modelMap["model named C, with collection path Y"], + modelMap["model named A, with collection path X / Y / Z"], + modelMap["model named Bz, with collection path D / E / F"], + modelMap["model named B, with collection path D / E / F"], + ]); + }); + + it("can sort by collection path, ascending, and then does a secondary sort by name - with a localized sort order", () => { + const sortingOptions = { + sort_column: "collection", + sort_direction: SortDirection.Asc, + } as const; + + const addUmlauts = (model: ModelResult): ModelResult => ({ + ...model, + name: model.name.replace(/^B$/g, "Bä"), + collection: { + ...model.collection, + effective_ancestors: model.collection?.effective_ancestors?.map( + ancestor => ({ + ...ancestor, + name: ancestor.name.replace("X", "Ä"), + }), + ), + }, + }); + + const swedishModelMap = { + "model named A, with collection path Ä / Y / Z": addUmlauts( + modelMap["model named A, with collection path X / Y / Z"], + ), + "model named Bä, with collection path D / E / F": addUmlauts( + modelMap["model named B, with collection path D / E / F"], + ), + "model named Bz, with collection path D / E / F": addUmlauts( + modelMap["model named Bz, with collection path D / E / F"], + ), + "model named C, with collection path Y": addUmlauts( + modelMap["model named C, with collection path Y"], + ), + "model named C, with collection path Z": addUmlauts( + modelMap["model named C, with collection path Z"], + ), + }; + + const swedishResults = Object.values(swedishModelMap); + + // When sorting in Swedish, z comes before ä + const swedishLocaleCode = "sv"; + const sorted = sortModels( + swedishResults, + sortingOptions, + swedishLocaleCode, + ); + expect("ä".localeCompare("z", "sv", { sensitivity: "base" })).toEqual(1); + expect(sorted).toEqual([ + swedishModelMap["model named Bz, with collection path D / E / F"], // Model Bz sorts before Bä + swedishModelMap["model named Bä, with collection path D / E / F"], + swedishModelMap["model named C, with collection path Y"], + swedishModelMap["model named C, with collection path Z"], // Collection Z sorts before Ä + swedishModelMap["model named A, with collection path Ä / Y / Z"], + ]); + }); + }); +}); + +describe("getMaxRecentModelCount", () => { + it("returns 8 for modelCount greater than 20", () => { + expect(getMaxRecentModelCount(21)).toBe(8); + expect(getMaxRecentModelCount(100)).toBe(8); + }); + + it("returns 4 for modelCount greater than 9 and less than or equal to 20", () => { + expect(getMaxRecentModelCount(10)).toBe(4); + expect(getMaxRecentModelCount(20)).toBe(4); + }); + + it("returns 0 for modelCount of 9 or less", () => { + expect(getMaxRecentModelCount(0)).toBe(0); + expect(getMaxRecentModelCount(5)).toBe(0); + expect(getMaxRecentModelCount(9)).toBe(0); + }); +}); diff --git a/frontend/src/metabase/browse/components/BrowseSchemas.styled.tsx b/frontend/src/metabase/browse/schemas/BrowseSchemas.styled.tsx similarity index 100% rename from frontend/src/metabase/browse/components/BrowseSchemas.styled.tsx rename to frontend/src/metabase/browse/schemas/BrowseSchemas.styled.tsx diff --git a/frontend/src/metabase/browse/components/BrowseSchemas.tsx b/frontend/src/metabase/browse/schemas/BrowseSchemas.tsx similarity index 90% rename from frontend/src/metabase/browse/components/BrowseSchemas.tsx rename to frontend/src/metabase/browse/schemas/BrowseSchemas.tsx index b59c8cc706c87387f5c66df7918f85b557060bd6..1c9922ffec7042c180047c964516cc0d547302e4 100644 --- a/frontend/src/metabase/browse/components/BrowseSchemas.tsx +++ b/frontend/src/metabase/browse/schemas/BrowseSchemas.tsx @@ -16,12 +16,13 @@ import { BrowseContainer, BrowseMain, BrowseSection, -} from "./BrowseContainer.styled"; -import { BrowseDataHeader } from "./BrowseDataHeader"; -import { BrowseHeaderContent } from "./BrowseHeader.styled"; +} from "../components/BrowseContainer.styled"; +import { BrowseDataHeader } from "../components/BrowseDataHeader"; +import { BrowseHeaderContent } from "../components/BrowseHeader.styled"; + import { SchemaGridItem, SchemaLink } from "./BrowseSchemas.styled"; -const BrowseSchemas = ({ +const BrowseSchemasContainer = ({ schemas, params, }: { @@ -90,9 +91,8 @@ const BrowseSchemas = ({ ); }; -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default Schema.loadList({ +export const BrowseSchemas = Schema.loadList({ query: (state: any, { params: { slug } }: { params: { slug: string } }) => ({ dbId: Urls.extractEntityId(slug), }), -})(BrowseSchemas); +})(BrowseSchemasContainer); diff --git a/frontend/src/metabase/browse/schemas/index.tsx b/frontend/src/metabase/browse/schemas/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36acba3f3707bad8613c4dc9beaed176e8d76db8 --- /dev/null +++ b/frontend/src/metabase/browse/schemas/index.tsx @@ -0,0 +1 @@ +export { BrowseSchemas } from "./BrowseSchemas"; diff --git a/frontend/src/metabase/browse/components/BrowseTables.tsx b/frontend/src/metabase/browse/tables/BrowseTables.tsx similarity index 81% rename from frontend/src/metabase/browse/components/BrowseTables.tsx rename to frontend/src/metabase/browse/tables/BrowseTables.tsx index 9007610cfe9664b7a824117684d4a0bfbddc6c10..409fe3104f0748c4cae9a091fd969e539441be04 100644 --- a/frontend/src/metabase/browse/components/BrowseTables.tsx +++ b/frontend/src/metabase/browse/tables/BrowseTables.tsx @@ -1,11 +1,10 @@ -import TableBrowser from "../containers/TableBrowser"; - import { BrowseContainer, BrowseMain, BrowseSection, -} from "./BrowseContainer.styled"; -import { BrowseDataHeader } from "./BrowseDataHeader"; +} from "../components/BrowseContainer.styled"; +import { BrowseDataHeader } from "../components/BrowseDataHeader"; +import TableBrowser from "../containers/TableBrowser"; export const BrowseTables = ({ params: { dbId, schemaName }, diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx b/frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.jsx similarity index 97% rename from frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx rename to frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.jsx index 468c9515960df0a248db5ef7f92e2ed7facb1103..60358f0368585130c36d0a1df5b33fe76d85f13b 100644 --- a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx +++ b/frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.jsx @@ -14,8 +14,8 @@ import { isVirtualCardId, } from "metabase-lib/v1/metadata/utils/saved-questions"; -import { trackTableClick } from "../../analytics"; -import { BrowseHeaderContent } from "../BrowseHeader.styled"; +import { BrowseHeaderContent } from "../../components/BrowseHeader.styled"; +import { trackTableClick } from "../analytics"; import { TableActionLink, diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.styled.tsx b/frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.styled.tsx similarity index 100% rename from frontend/src/metabase/browse/components/TableBrowser/TableBrowser.styled.tsx rename to frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.styled.tsx diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.unit.spec.js b/frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.unit.spec.js similarity index 100% rename from frontend/src/metabase/browse/components/TableBrowser/TableBrowser.unit.spec.js rename to frontend/src/metabase/browse/tables/TableBrowser/TableBrowser.unit.spec.js diff --git a/frontend/src/metabase/browse/components/TableBrowser/index.js b/frontend/src/metabase/browse/tables/TableBrowser/index.js similarity index 100% rename from frontend/src/metabase/browse/components/TableBrowser/index.js rename to frontend/src/metabase/browse/tables/TableBrowser/index.js diff --git a/frontend/src/metabase/browse/analytics.ts b/frontend/src/metabase/browse/tables/analytics.ts similarity index 50% rename from frontend/src/metabase/browse/analytics.ts rename to frontend/src/metabase/browse/tables/analytics.ts index ffa3dcd1c64a1f2733a8588d998f23ace01ca83d..0e2fb444b7f90fa81a36dd04303f550c8af6a836 100644 --- a/frontend/src/metabase/browse/analytics.ts +++ b/frontend/src/metabase/browse/tables/analytics.ts @@ -1,11 +1,5 @@ import { trackSchemaEvent } from "metabase/lib/analytics"; -import type { CardId, ConcreteTableId } from "metabase-types/api"; - -export const trackModelClick = (modelId: CardId) => - trackSchemaEvent("browse_data", { - event: "browse_data_model_clicked", - model_id: modelId, - }); +import type { ConcreteTableId } from "metabase-types/api"; export const trackTableClick = (tableId: ConcreteTableId) => trackSchemaEvent("browse_data", { diff --git a/frontend/src/metabase/browse/tables/index.tsx b/frontend/src/metabase/browse/tables/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d1921cbd3703f5c7f40a1e02c008b3f4bd7b389 --- /dev/null +++ b/frontend/src/metabase/browse/tables/index.tsx @@ -0,0 +1 @@ +export { BrowseTables } from "./BrowseTables"; diff --git a/frontend/src/metabase/browse/types.tsx b/frontend/src/metabase/browse/types.tsx deleted file mode 100644 index 7ecc3a879b6ab0dc61742e997a7a8746788412ee..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/types.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { - RecentCollectionItem, - RecentItem, - SearchResult, -} from "metabase-types/api"; - -/** Model retrieved through the search endpoint */ -export type ModelResult = SearchResult<number, "dataset">; - -/** Model retrieved through the recent views endpoint */ -export interface RecentModel extends RecentCollectionItem { - model: "dataset"; -} - -export const isRecentModel = (item: RecentItem): item is RecentModel => - item.model === "dataset"; - -/** A model retrieved through either endpoint. - * This type is needed so that our filtering functions can - * filter arrays of models retrieved from either endpoint. */ -export type FilterableModel = ModelResult | RecentModel; - -/** Metric retrieved through the search endpoint */ -export type MetricResult = SearchResult<number, "metric">; - -export interface RecentMetric extends RecentCollectionItem { - model: "metric"; -} diff --git a/frontend/src/metabase/browse/utils.ts b/frontend/src/metabase/browse/utils.ts deleted file mode 100644 index 4d0f22bf4100ba3d52fd3061a4e50a5e8e81e2d2..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; -import { t } from "ttag"; -import _ from "underscore"; - -import { - canonicalCollectionId, - coerceCollectionId, - isRootCollection, -} from "metabase/collections/utils"; -import { entityForObject } from "metabase/lib/schema"; -import type { IconName } from "metabase/ui"; -import type { CollectionEssentials } from "metabase-types/api"; - -import type { FilterableModel } from "./types"; - -export const getCollectionName = (collection: CollectionEssentials) => { - if (isRootCollection(collection)) { - return t`Our analytics`; - } - return collection?.name || t`Untitled collection`; -}; - -/** The root collection's id might be null or 'root' in different contexts. - * Use 'root' instead of null, for the sake of sorting */ -export const getCollectionIdForSorting = (collection: CollectionEssentials) => { - return coerceCollectionId(canonicalCollectionId(collection.id)); -}; - -export type AvailableModelFilters = Record< - string, - { - predicate: (value: FilterableModel) => boolean; - activeByDefault: boolean; - } ->; - -export type ModelFilterControlsProps = { - actualModelFilters: ActualModelFilters; - setActualModelFilters: Dispatch<SetStateAction<ActualModelFilters>>; -}; - -export type MetricFilterSettings = { - verified?: boolean; -}; - -export type MetricFilterControlsProps = { - metricFilters: MetricFilterSettings; - setMetricFilters: (settings: MetricFilterSettings) => void; -}; - -/** Mapping of filter names to true if the filter is active - * or false if it is inactive */ -export type ActualModelFilters = Record<string, boolean>; - -export const filterModels = <T extends FilterableModel>( - unfilteredModels: T[] | undefined, - actualModelFilters: ActualModelFilters, - availableModelFilters: AvailableModelFilters, -): T[] => { - return _.reduce( - actualModelFilters, - (acc, shouldFilterBeActive, filterName) => - shouldFilterBeActive - ? acc.filter(availableModelFilters[filterName].predicate) - : acc, - unfilteredModels || [], - ); -}; - -export const getIcon = (item: unknown): { name: IconName; color: string } => { - const entity = entityForObject(item); - return entity?.objectSelectors?.getIcon?.(item) || { name: "folder" }; -}; diff --git a/frontend/src/metabase/browse/utils.unit.spec.ts b/frontend/src/metabase/browse/utils.unit.spec.ts deleted file mode 100644 index 97e058c0684dc1680411f939ed3318bedd471e3d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/utils.unit.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup"; -import type { SearchResult } from "metabase-types/api"; -import { createMockCollection } from "metabase-types/api/mocks"; - -import { createMockModelResult } from "./test-utils"; -import type { ModelResult } from "./types"; -import type { ActualModelFilters, AvailableModelFilters } from "./utils"; -import { filterModels } from "./utils"; - -const collectionAlpha = createMockCollection({ id: 0, name: "Alpha" }); -const collectionBeta = createMockCollection({ id: 1, name: "Beta" }); -const collectionCharlie = createMockCollection({ id: 2, name: "Charlie" }); -const collectionDelta = createMockCollection({ id: 3, name: "Delta" }); -const collectionZulu = createMockCollection({ id: 4, name: "Zulu" }); -const collectionAngstrom = createMockCollection({ id: 5, name: "Ångström" }); -const collectionOzgur = createMockCollection({ id: 6, name: "Özgür" }); - -const mockModels: ModelResult[] = [ - { - id: 0, - name: "Model 0", - collection: collectionAlpha, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:59:59.000Z", - }, - { - id: 1, - name: "Model 1", - collection: collectionAlpha, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:59:30.000Z", - }, - { - id: 2, - name: "Model 2", - collection: collectionAlpha, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:59:00.000Z", - }, - { - id: 3, - name: "Model 3", - collection: collectionBeta, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:50:00.000Z", - }, - { - id: 4, - name: "Model 4", - collection: collectionBeta, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-15T11:00:00.000Z", - }, - { - id: 5, - name: "Model 5", - collection: collectionBeta, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-14T22:00:00.000Z", - }, - { - id: 6, - name: "Model 6", - collection: collectionCharlie, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-14T12:00:00.000Z", - }, - { - id: 7, - name: "Model 7", - collection: collectionCharlie, - last_editor_common_name: "Bobby", - last_edited_at: "2024-12-10T12:00:00.000Z", - }, - { - id: 8, - name: "Model 8", - collection: collectionCharlie, - last_editor_common_name: "Bobby", - last_edited_at: "2024-11-15T12:00:00.000Z", - }, - { - id: 9, - name: "Model 9", - collection: collectionDelta, - last_editor_common_name: "Bobby", - last_edited_at: "2024-02-15T12:00:00.000Z", - }, - { - id: 10, - name: "Model 10", - collection: collectionDelta, - last_editor_common_name: "Bobby", - last_edited_at: "2023-12-15T12:00:00.000Z", - }, - { - id: 11, - name: "Model 11", - collection: collectionDelta, - last_editor_common_name: "Bobby", - last_edited_at: "2020-01-01T00:00:00.000Z", - }, - { - id: 12, - name: "Model 12", - collection: collectionZulu, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 13, - name: "Model 13", - collection: collectionZulu, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 14, - name: "Model 14", - collection: collectionZulu, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 15, - name: "Model 15", - collection: collectionAngstrom, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 16, - name: "Model 16", - collection: collectionAngstrom, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 17, - name: "Model 17", - collection: collectionAngstrom, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 18, - name: "Model 18", - collection: collectionOzgur, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 19, - name: "Model 19", - collection: collectionOzgur, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 20, - name: "Model 20", - collection: collectionOzgur, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 21, - name: "Model 20", - collection: defaultRootCollection, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, - { - id: 22, - name: "Model 21", - collection: defaultRootCollection, - last_editor_common_name: "Bobby", - last_edited_at: "2000-01-01T00:00:00.000Z", - }, -].map(model => createMockModelResult(model)); - -describe("Browse utils", () => { - const diverseModels = mockModels.map((model, index) => ({ - ...model, - name: index % 2 === 0 ? `red ${index}` : `blue ${index}`, - moderated_status: index % 3 === 0 ? `good ${index}` : `bad ${index}`, - })); - const availableModelFilters: AvailableModelFilters = { - onlyRed: { - predicate: model => model.name.startsWith("red"), - activeByDefault: false, - }, - onlyGood: { - predicate: model => Boolean(model.moderated_status?.startsWith("good")), - activeByDefault: false, - }, - onlyBig: { - predicate: model => Boolean(model.description?.startsWith("big")), - activeByDefault: true, - }, - }; - - it("include a function that filters models, based on the object provided", () => { - const onlyRedAndGood: ActualModelFilters = { - onlyRed: true, - onlyGood: true, - onlyBig: false, - }; - const onlyRedAndGoodModels = filterModels( - diverseModels, - onlyRedAndGood, - availableModelFilters, - ); - const everySixthModel = diverseModels.reduce<SearchResult[]>( - (acc, model, index) => { - return index % 6 === 0 ? [...acc, model] : acc; - }, - [], - ); - // Since every other model is red and every third model is good, - // we expect every sixth model to be both red and good - expect(onlyRedAndGoodModels).toEqual(everySixthModel); - }); - - it("filterModels does not filter out models if no filters are active", () => { - const noActiveFilters: ActualModelFilters = { - onlyRed: false, - onlyGood: false, - onlyBig: false, - }; - const filteredModels = filterModels( - diverseModels, - noActiveFilters, - availableModelFilters, - ); - expect(filteredModels).toEqual(diverseModels); - }); -}); diff --git a/frontend/src/metabase/collections/components/CollectionHeader/tests/enterprise.unit.spec.tsx b/frontend/src/metabase/collections/components/CollectionHeader/tests/enterprise.unit.spec.tsx index a5f900ba6b0f1b0ad26cb9deb760f37a0eb53e16..0293bdaa1fc7265b08c7fedfd0fccd458e7eb877 100644 --- a/frontend/src/metabase/collections/components/CollectionHeader/tests/enterprise.unit.spec.tsx +++ b/frontend/src/metabase/collections/components/CollectionHeader/tests/enterprise.unit.spec.tsx @@ -8,7 +8,7 @@ import { setup } from "./setup"; describe("Instance Analytics Collection Header", () => { const defaultOptions = { collection: { - name: "Metabase Analytics", + name: "Usage Analytics", type: "instance-analytics" as CollectionType, can_write: false, }, diff --git a/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.stories.tsx b/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.stories.tsx index c8cb966b6a9d5bf425195f08f616dffe576d74f8..81563327e643f496980f2b1f3afba30b4c42bdce 100644 --- a/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.stories.tsx +++ b/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.stories.tsx @@ -1,7 +1,7 @@ import { action } from "@storybook/addon-actions"; -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import PinnedItemCard from "./PinnedItemCard"; +import PinnedItemCard, { type PinnedItemCardProps } from "./PinnedItemCard"; export default { title: "Collections/PinnedItemCard", @@ -22,70 +22,79 @@ const collection = { const onCopy = action("onCopy"); const onMove = action("onMove"); -const Template: ComponentStory<typeof PinnedItemCard> = args => { +const Template: StoryFn<PinnedItemCardProps> = args => { return <PinnedItemCard {...args} />; }; -export const Question = Template.bind({}); -Question.args = { - collection, - item: { - id: 1, - collection_position: 1, - collection_id: null, - model: "card", - name: "Question", - description: "This is a description of the question", - getIcon: () => ({ name: "question" }), - getUrl: () => "/question/1", - setArchived: action("setArchived"), - setPinned: action("setPinned"), - copy: true, - setCollection: action("setCollection"), - archived: false, +export const Question = { + render: Template, + + args: { + collection, + item: { + id: 1, + collection_position: 1, + collection_id: null, + model: "card", + name: "Question", + description: "This is a description of the question", + getIcon: () => ({ name: "question" }), + getUrl: () => "/question/1", + setArchived: action("setArchived"), + setPinned: action("setPinned"), + copy: true, + setCollection: action("setCollection"), + archived: false, + }, + onCopy, + onMove, }, - onCopy, - onMove, }; -export const Dashboard = Template.bind({}); -Dashboard.args = { - collection, - item: { - id: 1, - model: "dashboard", - collection_position: 1, - collection_id: null, - name: "Dashboard", - description: Array(20) - .fill("This is a description of the dashboard.") - .join(" "), - getIcon: () => ({ name: "dashboard" }), - getUrl: () => "/dashboard/1", - setArchived: action("setArchived"), - setPinned: action("setPinned"), - archived: false, +export const Dashboard = { + render: Template, + + args: { + collection, + item: { + id: 1, + model: "dashboard", + collection_position: 1, + collection_id: null, + name: "Dashboard", + description: Array(20) + .fill("This is a description of the dashboard.") + .join(" "), + getIcon: () => ({ name: "dashboard" }), + getUrl: () => "/dashboard/1", + setArchived: action("setArchived"), + setPinned: action("setPinned"), + archived: false, + }, + onCopy, + onMove, }, - onCopy, - onMove, }; -export const Model = Template.bind({}); -Model.args = { - collection, - item: { - id: 1, - model: "dataset", - collection_position: 1, - collection_id: null, - name: "Model", - description: "This is a description of the model", - getIcon: () => ({ name: "model" }), - getUrl: () => "/question/1", - setArchived: action("setArchived"), - setPinned: action("setPinned"), - archived: false, +export const Model = { + render: Template, + + args: { + collection, + item: { + id: 1, + model: "dataset", + collection_position: 1, + collection_id: null, + name: "Model", + description: "This is a description of the model", + getIcon: () => ({ name: "model" }), + getUrl: () => "/question/1", + setArchived: action("setArchived"), + setPinned: action("setPinned"), + archived: false, + }, + onCopy, + onMove, }, - onCopy, - onMove, }; diff --git a/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.tsx b/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.tsx index 66e6885e9a647ba8e6aed4b88ba8e9ac0d70b401..eca0615b06b28c4e0e931e32fe466b618945a211 100644 --- a/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.tsx +++ b/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.tsx @@ -45,7 +45,7 @@ type ItemOrSkeleton = iconForSkeleton: IconName; }; -type Props = { +export type PinnedItemCardProps = { databases?: Database[]; bookmarks?: Bookmark[]; createBookmark?: CreateBookmark; @@ -84,7 +84,7 @@ function PinnedItemCard({ onMove, onClick, iconForSkeleton, -}: Props) { +}: PinnedItemCardProps) { const [showTitleTooltip, setShowTitleTooltip] = useState(false); const icon = iconForSkeleton ?? diff --git a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx index 32d5cd1ec9b79629033b60dbdf34796e25ac6b0f..e3c5cd305f7a12f9ccfafff13e471244abf30038 100644 --- a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx +++ b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx @@ -414,7 +414,8 @@ describe("AggregationPicker", () => { }); }); - describe("column compare shortcut", () => { + // eslint-disable-next-line jest/no-disabled-tests + describe.skip("column compare shortcut", () => { it("does not display the shortcut if there are no aggregations", () => { setup({ allowCustomExpressions: true, allowTemporalComparisons: true }); expect(screen.queryByText(/compare/i)).not.toBeInTheDocument(); diff --git a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.tsx b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.tsx index 817936c4eb6db52991c3028eced53443d815ef72..dedce465e9945608795cf7c376574fb51e76c5d3 100644 --- a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.tsx +++ b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/EntityPickerModal.tsx @@ -233,7 +233,9 @@ export function EntityPickerModal< setShowActionButtons={setShowActionButtons} /> ) : ( - <SinglePickerView>{tabs[0].element}</SinglePickerView> + <SinglePickerView data-testid="single-picker-view"> + {tabs?.[0]?.element} + </SinglePickerView> )} {!!hydratedOptions.hasConfirmButtons && onConfirm && ( <ButtonBar diff --git a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/TabsView.tsx b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/TabsView.tsx index 043f7eb2d992148653b63a6447328ce0d4b0ae0c..140d3c0d4d984cbd4e23b1c1e8ffca7bfcf61179 100644 --- a/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/TabsView.tsx +++ b/frontend/src/metabase/common/components/EntityPicker/components/EntityPickerModal/TabsView.tsx @@ -107,6 +107,7 @@ export const TabsView = < display: "flex", flexDirection: "column", }} + data-testid="tabs-view" > <Tabs.List px="1rem"> {tabs.map(tab => { diff --git a/frontend/src/metabase/common/components/EntityPicker/components/ItemList/ItemList.tsx b/frontend/src/metabase/common/components/EntityPicker/components/ItemList/ItemList.tsx index 1eec02e1c6ca3af3af7aa5ccc8d228b28b075d83..353175f8a27812f8c2c312f5390f8f7f8a2236d8 100644 --- a/frontend/src/metabase/common/components/EntityPicker/components/ItemList/ItemList.tsx +++ b/frontend/src/metabase/common/components/EntityPicker/components/ItemList/ItemList.tsx @@ -78,7 +78,7 @@ export const ItemList = < const icon = getEntityPickerIcon(item, isSelected && isCurrentLevel); return ( - <div key={`${item.model}-${item.id}`}> + <div data-testid="picker-item" key={`${item.model}-${item.id}`}> <NavLink disabled={shouldDisableItem?.(item)} rightSection={ diff --git a/frontend/src/metabase/common/components/Sidesheet/Sidesheet.mdx b/frontend/src/metabase/common/components/Sidesheet/Sidesheet.mdx new file mode 100644 index 0000000000000000000000000000000000000000..ac08230a5b236faf14946fedcb729f5e974eb61f --- /dev/null +++ b/frontend/src/metabase/common/components/Sidesheet/Sidesheet.mdx @@ -0,0 +1,60 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Flex } from "metabase/ui"; +import * as SidesheetStories from "./Sidesheet.stories"; + +import { Sidesheet } from "./Sidesheet"; +import { SidesheetCard } from "./SidesheetCard"; +import { SidesheetCardSection } from "./SidesheetCardSection"; +import { SidesheetButton, SidesheetButtonWithChevron } from "./SidesheetButton"; + +import { TestTabbedSidesheet, TestPagedSidesheet } from "./Sidesheet.samples"; + +<Meta of={SidesheetStories} /> + +# Sidesheet + +## When to use a Sidesheet + +## Docs + +## Caveats + +## Usage guidelines + +## Examples + +// TODO: figure out how to get CSS modules working with storybook 🔥 + +<Canvas> + <Story of={SidesheetStories.Default} /> +</Canvas> + +### With cards + +<Canvas> + <Story of={SidesheetStories.WithCards} /> +</Canvas> + +### With sectioned cards + +<Canvas> + <Story of={SidesheetStories.WithSectionedCards} /> +</Canvas> + +### With pages + +<Canvas> + <Story of={SidesheetStories.WithSubPages} /> +</Canvas> + +### With tabs + +<Canvas> + <Story of={SidesheetStories.WithTabs} /> +</Canvas> + +### Sidesheet Buttons + +<Canvas> + <Story of={SidesheetStories.SidesheetButtons} /> +</Canvas> diff --git a/frontend/src/metabase/common/components/Sidesheet/Sidesheet.stories.mdx b/frontend/src/metabase/common/components/Sidesheet/Sidesheet.stories.mdx deleted file mode 100644 index 1a9348b3393e4db256094c27bbcdd07b58258088..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/common/components/Sidesheet/Sidesheet.stories.mdx +++ /dev/null @@ -1,165 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Flex } from "metabase/ui"; - -import { Sidesheet } from "./Sidesheet"; -import { SidesheetCard } from "./SidesheetCard"; -import { SidesheetCardSection } from "./SidesheetCardSection"; -import { SidesheetButton, SidesheetButtonWithChevron } from "./SidesheetButton"; - -import { TestTabbedSidesheet, TestPagedSidesheet} from "./Sidesheet.samples"; - -export const args = { - size: "md", - title: "My Awesome Sidesheet", - onClose: () => {}, - isOpen: true, -}; - -export const argTypes = { - size: { - options: ["xs", "sm", "md", "lg", "xl", "auto"], - control: { type: "inline-radio" }, - }, - title: { - control: { type: "text" }, - }, - isOpen: { - control: { type: "boolean" }, - } -}; - -<Meta title="Components/Sidesheet" component={Sidesheet} args={args} argTypes={argTypes} /> - -# Sidesheet - - -## When to use a Sidesheet - - - -## Docs - - -## Caveats - - - -## Usage guidelines - - - -## Examples - -// TODO: figure out how to get CSS modules working with storybook 🔥 - -export const DefaultTemplate = args => ( - <Sidesheet {...args}> - Call me Ishmael ... - </Sidesheet> -); - -export const WithCardsTemplate = args => ( - <Sidesheet {...args}> - <SidesheetCard> - Here is even more cool information - </SidesheetCard> - <SidesheetCard title="Some information has a title"> - titles are neat - </SidesheetCard> - </Sidesheet> -); - -export const WithSectionedCardsTemplate = args => ( - <Sidesheet {...args}> - <SidesheetCard> - <SidesheetCardSection title="lots"> - Some cards have so much information - </SidesheetCardSection> - <SidesheetCardSection title="of information"> - that you need a bunch - </SidesheetCardSection> - <SidesheetCardSection title="to display"> - of sections to display it all - </SidesheetCardSection> - </SidesheetCard> - </Sidesheet> -); - -export const PagedSidesheetTemplate = () => ( - <TestPagedSidesheet /> -); - -export const TabbedSidesheetTemplate = () => ( - <TestTabbedSidesheet /> -); - - -export const SidesheetButtonTemplate = () => ( - <Flex maw="30rem" direction="column" gap="lg"> - <SidesheetCard title="normal"> - <SidesheetButton> - Do something fun - </SidesheetButton> - </SidesheetCard> - <SidesheetCard title="with chevron"> - <Flex justify="space-between"> - Favorite Pokemon - <SidesheetButtonWithChevron> - Naclstack - </SidesheetButtonWithChevron> - </Flex> - </SidesheetCard> - <SidesheetCard title="with chevron - fullWidth"> - <SidesheetButtonWithChevron fullWidth> - Configure favorite pokemon - </SidesheetButtonWithChevron> - </SidesheetCard> - </Flex> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### With cards - -export const WithCards = WithCardsTemplate.bind({}); - -<Canvas> - <Story name="With cards">{WithCards}</Story> -</Canvas> - - -### With sectioned cards - -export const WithSectionedCards = WithSectionedCardsTemplate.bind({}); - -<Canvas> - <Story name="With sectioned cards">{WithSectionedCards}</Story> -</Canvas> - -### With pages - -export const WithPages = PagedSidesheetTemplate.bind({}); - -<Canvas> - <Story name="With sub pages">{WithPages}</Story> -</Canvas> - -### With tabs - -export const WithTabs = TabbedSidesheetTemplate.bind({}); - -<Canvas> - <Story name="With tabs">{WithTabs}</Story> -</Canvas> - -### Sidesheet Buttons - -export const SidesheetButtonStory = SidesheetButtonTemplate.bind({}); - -<Canvas> - <Story name="Sidesheet buttons">{SidesheetButtonStory}</Story> -</Canvas> diff --git a/frontend/src/metabase/common/components/Sidesheet/Sidesheet.stories.tsx b/frontend/src/metabase/common/components/Sidesheet/Sidesheet.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4fe79f4d90dd37c2886624c1faba2d103ea4f802 --- /dev/null +++ b/frontend/src/metabase/common/components/Sidesheet/Sidesheet.stories.tsx @@ -0,0 +1,120 @@ +import type { ComponentProps } from "react"; + +import { Flex } from "metabase/ui"; + +import { Sidesheet } from "./Sidesheet"; +import { TestPagedSidesheet, TestTabbedSidesheet } from "./Sidesheet.samples"; +import { SidesheetButton, SidesheetButtonWithChevron } from "./SidesheetButton"; +import { SidesheetCard } from "./SidesheetCard"; +import { SidesheetCardSection } from "./SidesheetCardSection"; + +const args = { + size: "md", + title: "My Awesome Sidesheet", + onClose: () => {}, + isOpen: true, +}; + +const argTypes = { + size: { + options: ["xs", "sm", "md", "lg", "xl", "auto"], + control: { type: "inline-radio" }, + }, + title: { + control: { type: "text" }, + }, + isOpen: { + control: { type: "boolean" }, + }, +}; + +type SidesheetProps = ComponentProps<typeof Sidesheet>; + +const DefaultTemplate = (args: SidesheetProps) => ( + <Sidesheet {...args}>Call me Ishmael ...</Sidesheet> +); + +const WithCardsTemplate = (args: SidesheetProps) => ( + <Sidesheet {...args}> + <SidesheetCard>Here is even more cool information</SidesheetCard> + <SidesheetCard title="Some information has a title"> + titles are neat + </SidesheetCard> + </Sidesheet> +); + +const WithSectionedCardsTemplate = (args: SidesheetProps) => ( + <Sidesheet {...args}> + <SidesheetCard> + <SidesheetCardSection title="lots"> + Some cards have so much information + </SidesheetCardSection> + <SidesheetCardSection title="of information"> + that you need a bunch + </SidesheetCardSection> + <SidesheetCardSection title="to display"> + of sections to display it all + </SidesheetCardSection> + </SidesheetCard> + </Sidesheet> +); + +const PagedSidesheetTemplate = () => <TestPagedSidesheet />; + +const TabbedSidesheetTemplate = () => <TestTabbedSidesheet />; + +const SidesheetButtonTemplate = () => ( + <Flex maw="30rem" direction="column" gap="lg"> + <SidesheetCard title="normal"> + <SidesheetButton>Do something fun</SidesheetButton> + </SidesheetCard> + <SidesheetCard title="with chevron"> + <Flex justify="space-between"> + Favorite Pokemon + <SidesheetButtonWithChevron>Naclstack</SidesheetButtonWithChevron> + </Flex> + </SidesheetCard> + <SidesheetCard title="with chevron - fullWidth"> + <SidesheetButtonWithChevron fullWidth> + Configure favorite pokemon + </SidesheetButtonWithChevron> + </SidesheetCard> + </Flex> +); + +export default { + title: "Components/Sidesheet", + component: Sidesheet, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const WithCards = { + render: WithCardsTemplate, + name: "With cards", +}; + +export const WithSectionedCards = { + render: WithSectionedCardsTemplate, + name: "With sectioned cards", +}; + +export const WithSubPages = { + render: PagedSidesheetTemplate, + name: "With sub pages", +}; + +export const WithTabs = { + render: TabbedSidesheetTemplate, + name: "With tabs", +}; + +export const SidesheetButtons = { + render: SidesheetButtonTemplate, + name: "Sidesheet buttons", +}; diff --git a/frontend/src/metabase/common/hooks/use-fetch-models.tsx b/frontend/src/metabase/common/hooks/use-fetch-models.tsx index 3a52f8feeef1f0b8d6b778a82eb20178dc7bcd36..931e4d9ca83a0c70b20cb97249df9ecb968a5617 100644 --- a/frontend/src/metabase/common/hooks/use-fetch-models.tsx +++ b/frontend/src/metabase/common/hooks/use-fetch-models.tsx @@ -1,12 +1,18 @@ -import { useSearchQuery } from "metabase/api"; +import { skipToken, useSearchQuery } from "metabase/api"; import type { SearchRequest } from "metabase-types/api"; -export const useFetchModels = (req: Partial<SearchRequest> = {}) => { - const modelsResult = useSearchQuery({ - models: ["dataset"], // 'model' in the sense of 'type of thing' - filter_items_in_personal_collection: "exclude", - model_ancestors: false, - ...req, - }); +export const useFetchModels = ( + req: Partial<SearchRequest> | typeof skipToken = {}, +) => { + const modelsResult = useSearchQuery( + req === skipToken + ? req + : { + models: ["dataset"], // 'model' in the sense of 'type of thing' + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + ...req, + }, + ); return modelsResult; }; diff --git a/frontend/src/metabase/components/Calendar/Calendar.stories.tsx b/frontend/src/metabase/components/Calendar/Calendar.stories.tsx index 9ca7f37f9ace5955777612f567917e61ebb0f298..543f3cd28e7d17dbd0e88160eefb3538184d95e8 100644 --- a/frontend/src/metabase/components/Calendar/Calendar.stories.tsx +++ b/frontend/src/metabase/components/Calendar/Calendar.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import Calendar from "./Calendar"; @@ -7,9 +7,11 @@ export default { component: Calendar, }; -const Template: ComponentStory<typeof Calendar> = args => { +const Template: StoryFn<typeof Calendar> = args => { return <Calendar {...args} />; }; -export const Default = Template.bind({}); -Default.args = {}; +export const Default = { + render: Template, + args: {}, +}; diff --git a/frontend/src/metabase/components/DateMonthYearWidget/DateMonthYearWidget.stories.tsx b/frontend/src/metabase/components/DateMonthYearWidget/DateMonthYearWidget.stories.tsx index 3560f1a04c7788964e2e1e4786d6acc3fdb8765b..b373787865b7e958781dfe2c9de5b3636b5c3f68 100644 --- a/frontend/src/metabase/components/DateMonthYearWidget/DateMonthYearWidget.stories.tsx +++ b/frontend/src/metabase/components/DateMonthYearWidget/DateMonthYearWidget.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/client-api"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { DateMonthYearWidget } from "./DateMonthYearWidget"; @@ -8,7 +8,7 @@ export default { component: DateMonthYearWidget, }; -const Template: ComponentStory<typeof DateMonthYearWidget> = args => { +const Template: StoryFn<typeof DateMonthYearWidget> = args => { const [{ value }, updateArgs] = useArgs(); const handleSetValue = (v: string) => { @@ -29,17 +29,26 @@ const Template: ComponentStory<typeof DateMonthYearWidget> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - value: "", +export const Default = { + render: Template, + + args: { + value: "", + }, }; -export const ThisYear = Template.bind({}); -ThisYear.args = { - value: "2022", +export const ThisYear = { + render: Template, + + args: { + value: "2022", + }, }; -export const LastYear = Template.bind({}); -LastYear.args = { - value: "2021-07", +export const LastYear = { + render: Template, + + args: { + value: "2021-07", + }, }; diff --git a/frontend/src/metabase/components/DateQuarterYearWidget/DateQuarterYearWidget.stories.tsx b/frontend/src/metabase/components/DateQuarterYearWidget/DateQuarterYearWidget.stories.tsx index 035c0e1a622c382c5e2bbcd32c1113dc2192fdcc..b0270e56135bf29243fd9af62a02b5ac1ff157f8 100644 --- a/frontend/src/metabase/components/DateQuarterYearWidget/DateQuarterYearWidget.stories.tsx +++ b/frontend/src/metabase/components/DateQuarterYearWidget/DateQuarterYearWidget.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { DateQuarterYearWidget } from "./DateQuarterYearWidget"; @@ -8,7 +8,7 @@ export default { component: DateQuarterYearWidget, }; -const Template: ComponentStory<typeof DateQuarterYearWidget> = args => { +const Template: StoryFn<typeof DateQuarterYearWidget> = args => { const [{ value }, updateArgs] = useArgs(); const handleSetValue = (v: string) => { @@ -29,17 +29,26 @@ const Template: ComponentStory<typeof DateQuarterYearWidget> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - value: "", +export const Default = { + render: Template, + + args: { + value: "", + }, }; -export const SomeTimeLastYear = Template.bind({}); -SomeTimeLastYear.args = { - value: "4-2021", +export const SomeTimeLastYear = { + render: Template, + + args: { + value: "4-2021", + }, }; -export const SomeTimeAgo = Template.bind({}); -SomeTimeAgo.args = { - value: "2-1981", +export const SomeTimeAgo = { + render: Template, + + args: { + value: "2-1981", + }, }; diff --git a/frontend/src/metabase/components/DateRelativeWidget/DateRelativeWidget.stories.tsx b/frontend/src/metabase/components/DateRelativeWidget/DateRelativeWidget.stories.tsx index 6c7eadbc8097aac5c6c2fabc931d78663fc3088a..7d25bdd57455b84d1d33cd627e9da80b19606241 100644 --- a/frontend/src/metabase/components/DateRelativeWidget/DateRelativeWidget.stories.tsx +++ b/frontend/src/metabase/components/DateRelativeWidget/DateRelativeWidget.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { DateRelativeWidget } from "./DateRelativeWidget"; @@ -8,7 +8,7 @@ export default { component: DateRelativeWidget, }; -const Template: ComponentStory<typeof DateRelativeWidget> = args => { +const Template: StoryFn<typeof DateRelativeWidget> = args => { const [{ value }, updateArgs] = useArgs(); const handleSetValue = (v?: string) => { @@ -29,22 +29,34 @@ const Template: ComponentStory<typeof DateRelativeWidget> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - value: "", +export const Default = { + render: Template, + + args: { + value: "", + }, }; -export const Yesterday = Template.bind({}); -Yesterday.args = { - value: "yesterday", +export const Yesterday = { + render: Template, + + args: { + value: "yesterday", + }, }; -export const LastMonth = Template.bind({}); -LastMonth.args = { - value: "lastmonth", +export const LastMonth = { + render: Template, + + args: { + value: "lastmonth", + }, }; -export const ThisWeek = Template.bind({}); -ThisWeek.args = { - value: "thisweek", +export const ThisWeek = { + render: Template, + + args: { + value: "thisweek", + }, }; diff --git a/frontend/src/metabase/components/EntityMenu.stories.tsx b/frontend/src/metabase/components/EntityMenu.stories.tsx index db3bcef951d4592375bdbaeca0a46374f95c81e3..2e554217aa99e9c2cc30dc79d2183625c4971d70 100644 --- a/frontend/src/metabase/components/EntityMenu.stories.tsx +++ b/frontend/src/metabase/components/EntityMenu.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import EntityMenu from "./EntityMenu"; @@ -7,7 +7,7 @@ export default { component: EntityMenu, }; -const Template: ComponentStory<typeof EntityMenu> = args => { +const Template: StoryFn<typeof EntityMenu> = args => { return <EntityMenu {...args} />; }; @@ -30,8 +30,11 @@ const items = [ }, ]; -export const Default = Template.bind({}); -Default.args = { - items, - trigger: <span>Click Me</span>, +export const Default = { + render: Template, + + args: { + items, + trigger: <span>Click Me</span>, + }, }; diff --git a/frontend/src/metabase/components/HelpCard/HelpCard.stories.tsx b/frontend/src/metabase/components/HelpCard/HelpCard.stories.tsx index 9e014945d2c215ec7c73e6417b08f9e5cf036b39..25c02f9ffb3c006957272804f5eada731c824976 100644 --- a/frontend/src/metabase/components/HelpCard/HelpCard.stories.tsx +++ b/frontend/src/metabase/components/HelpCard/HelpCard.stories.tsx @@ -1,20 +1,23 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import HelpCard from "./HelpCard"; +import HelpCard, { type HelpCardProps } from "./HelpCard"; export default { title: "Components/HelpCard", component: HelpCard, }; -const Template: ComponentStory<typeof HelpCard> = args => { +const Template: StoryFn<HelpCardProps> = args => { return <HelpCard {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - title: "Need help with anything?", - helpUrl: "https://metabase.com", - children: - "See our docs for step-by-step directions on how to do what you need.", +export const Default = { + render: Template, + + args: { + title: "Need help with anything?", + helpUrl: "https://metabase.com", + children: + "See our docs for step-by-step directions on how to do what you need.", + }, }; diff --git a/frontend/src/metabase/components/ModalContent/ModalContent.stories.tsx b/frontend/src/metabase/components/ModalContent/ModalContent.stories.tsx index f03e257be8c2b864f2f7de60e5e46b0a9e7f589f..c2281e4c723b92fe8eb132e8d76feb39e44d46cf 100644 --- a/frontend/src/metabase/components/ModalContent/ModalContent.stories.tsx +++ b/frontend/src/metabase/components/ModalContent/ModalContent.stories.tsx @@ -1,9 +1,12 @@ import { action } from "@storybook/addon-actions"; -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; -import ModalContent, { ModalContentActionIcon } from "./index"; +import ModalContent, { + ModalContentActionIcon, + type ModalContentProps, +} from "./index"; export default { title: "Components/ModalContent", @@ -24,7 +27,7 @@ export default { }, }; -const Template: ComponentStory<typeof ModalContent> = args => { +const Template: StoryFn<ModalContentProps> = args => { return ( <div style={{ @@ -48,23 +51,32 @@ const args = { onBack: undefined, }; -export const Default = Template.bind({}); -Default.args = { - ...args, +export const Default = { + render: Template, + + args: { + ...args, + }, }; -export const WithHeaderActions = Template.bind({}); -WithHeaderActions.args = { - ...args, - headerActions: ( - <> - <ModalContentActionIcon name="pencil" onClick={action("Action1")} /> - </> - ), +export const WithHeaderActions = { + render: Template, + + args: { + ...args, + headerActions: ( + <> + <ModalContentActionIcon name="pencil" onClick={action("Action1")} /> + </> + ), + }, }; -export const WithBackButton = Template.bind({}); -WithBackButton.args = { - ...args, - onBack: action("onBack"), +export const WithBackButton = { + render: Template, + + args: { + ...args, + onBack: action("onBack"), + }, }; diff --git a/frontend/src/metabase/components/Schedule/Schedule.stories.tsx b/frontend/src/metabase/components/Schedule/Schedule.stories.tsx index fdce96762fc5ba63fbbfa37f48a38869bda1bcc3..e4b6a130926dde42fd2a32eed81991d8d0badc5c 100644 --- a/frontend/src/metabase/components/Schedule/Schedule.stories.tsx +++ b/frontend/src/metabase/components/Schedule/Schedule.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { Schedule } from "./Schedule"; @@ -8,7 +8,7 @@ export default { component: Schedule, }; -const Template: ComponentStory<typeof Schedule> = args => { +const Template: StoryFn<typeof Schedule> = args => { const [ { schedule, @@ -29,13 +29,16 @@ const Template: ComponentStory<typeof Schedule> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - schedule: { - schedule_day: "mon", - schedule_frame: null, - schedule_hour: 0, - schedule_type: "daily", +export const Default = { + render: Template, + + args: { + schedule: { + schedule_day: "mon", + schedule_frame: null, + schedule_hour: 0, + schedule_type: "daily", + }, + verb: "Deliver", }, - verb: "Deliver", }; diff --git a/frontend/src/metabase/components/SchedulePicker/SchedulePicker.stories.tsx b/frontend/src/metabase/components/SchedulePicker/SchedulePicker.stories.tsx index 57b89f1b8660f3ae1358ae68d791072a4074d7a0..0b85d9e0865fb0dec91cc7c44850e61c307d8ae6 100644 --- a/frontend/src/metabase/components/SchedulePicker/SchedulePicker.stories.tsx +++ b/frontend/src/metabase/components/SchedulePicker/SchedulePicker.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import SchedulePicker from "./SchedulePicker"; @@ -8,7 +8,7 @@ export default { component: SchedulePicker, }; -const Template: ComponentStory<typeof SchedulePicker> = args => { +const Template: StoryFn<typeof SchedulePicker> = args => { const [ { schedule, @@ -29,13 +29,16 @@ const Template: ComponentStory<typeof SchedulePicker> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - schedule: { - schedule_day: "mon", - schedule_frame: null, - schedule_hour: 0, - schedule_type: "daily", +export const Default = { + render: Template, + + args: { + schedule: { + schedule_day: "mon", + schedule_frame: null, + schedule_hour: 0, + schedule_type: "daily", + }, + textBeforeInterval: "Deliver", }, - textBeforeInterval: "Deliver", }; diff --git a/frontend/src/metabase/components/SegmentedControl/SegmentedControl.stories.tsx b/frontend/src/metabase/components/SegmentedControl/SegmentedControl.stories.tsx index 379b035ea14f874d28a821150d3c9f0297a08869..c791573561c6deb5c12e724a2eeb50d607b8965e 100644 --- a/frontend/src/metabase/components/SegmentedControl/SegmentedControl.stories.tsx +++ b/frontend/src/metabase/components/SegmentedControl/SegmentedControl.stories.tsx @@ -1,14 +1,17 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; -import { SegmentedControl } from "./SegmentedControl"; +import { + SegmentedControl, + type SegmentedControlProps, +} from "./SegmentedControl"; export default { title: "Components/SegmentedControl", component: SegmentedControl, }; -const Template: ComponentStory<typeof SegmentedControl> = args => { +const Template: StoryFn<SegmentedControlProps<number>> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: unknown) => updateArgs({ value }); @@ -19,42 +22,54 @@ Template.args = { value: 0, }; -export const Default = Template.bind({}); -Default.args = { - options: [ - { name: "Gadget", value: 0 }, - { name: "Gizmo", value: 1 }, - ], +export const Default = { + render: Template, + + args: { + options: [ + { name: "Gadget", value: 0 }, + { name: "Gizmo", value: 1 }, + ], + }, }; -export const WithIcons = Template.bind({}); -WithIcons.args = { - options: [ - { name: "Gadget", value: 0, icon: "lightbulb" }, - { name: "Gizmo", value: 1, icon: "folder" }, - { name: "Doohickey", value: 2, icon: "insight" }, - ], +export const WithIcons = { + render: Template, + + args: { + options: [ + { name: "Gadget", value: 0, icon: "lightbulb" }, + { name: "Gizmo", value: 1, icon: "folder" }, + { name: "Doohickey", value: 2, icon: "insight" }, + ], + }, }; -export const OnlyIcons = Template.bind({}); -OnlyIcons.args = { - options: [ - { value: 0, icon: "lightbulb" }, - { value: 1, icon: "folder" }, - { value: 2, icon: "insight" }, - ], +export const OnlyIcons = { + render: Template, + + args: { + options: [ + { value: 0, icon: "lightbulb" }, + { value: 1, icon: "folder" }, + { value: 2, icon: "insight" }, + ], + }, }; -export const WithColors = Template.bind({}); -WithColors.args = { - options: [ - { - name: "Gadget", - value: 0, - icon: "lightbulb", - selectedColor: "accent1", - }, - { name: "Gizmo", value: 1, icon: "folder", selectedColor: "accent2" }, - { name: "Doohickey", value: 2, icon: "insight" }, - ], +export const WithColors = { + render: Template, + + args: { + options: [ + { + name: "Gadget", + value: 0, + icon: "lightbulb", + selectedColor: "accent1", + }, + { name: "Gizmo", value: 1, icon: "folder", selectedColor: "accent2" }, + { name: "Doohickey", value: 2, icon: "insight" }, + ], + }, }; diff --git a/frontend/src/metabase/components/SegmentedControl/SegmentedControl.tsx b/frontend/src/metabase/components/SegmentedControl/SegmentedControl.tsx index c2864fc65946f851cdb668d0ccb9cccbd3f1299e..07777f5f3311bdc8d09f6c6c5ae9ecdeb8eea0d3 100644 --- a/frontend/src/metabase/components/SegmentedControl/SegmentedControl.tsx +++ b/frontend/src/metabase/components/SegmentedControl/SegmentedControl.tsx @@ -26,7 +26,7 @@ export type SegmentedControlOption<Value extends SegmentedControlValue> = { selectedColor?: string; }; -interface Props<Value extends SegmentedControlValue> { +export interface SegmentedControlProps<Value extends SegmentedControlValue> { name?: string; value?: Value; options: SegmentedControlOption<Value>[]; @@ -47,7 +47,7 @@ export function SegmentedControl<Value extends SegmentedControlValue = number>({ inactiveColor = "text-dark", variant = "fill-background", ...props -}: Props<Value>) { +}: SegmentedControlProps<Value>) { const id = useMemo(() => _.uniqueId("radio-"), []); const name = nameProp || id; const selectedOptionIndex = options.findIndex( diff --git a/frontend/src/metabase/components/SelectList/SelectListItem.stories.tsx b/frontend/src/metabase/components/SelectList/SelectListItem.stories.tsx index ee3c6766a144520d8bb616cee85fd11112c85612..fb3817e3714efa46f8d7f25a37d3db9b98836a08 100644 --- a/frontend/src/metabase/components/SelectList/SelectListItem.stories.tsx +++ b/frontend/src/metabase/components/SelectList/SelectListItem.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { useState } from "react"; import SelectList from "./SelectList"; @@ -10,7 +10,7 @@ export default { const items = ["alert", "all", "archive", "dyno", "history"]; -const Template: ComponentStory<any> = args => { +const Template: StoryFn<any> = args => { const [value, setValue] = useState("dyno"); return ( @@ -30,9 +30,11 @@ const Template: ComponentStory<any> = args => { ); }; -export const Default = Template.bind({}); +export const Default = { + render: Template, -Default.args = { - items: items, - rightIcon: "check", + args: { + items: items, + rightIcon: "check", + }, }; diff --git a/frontend/src/metabase/components/TextWidget/TextWidget.stories.tsx b/frontend/src/metabase/components/TextWidget/TextWidget.stories.tsx index 615b7fe16c2798e192cc29f22293907798c44e96..30e07da6a74657c814c0a96338945a94f9186461 100644 --- a/frontend/src/metabase/components/TextWidget/TextWidget.stories.tsx +++ b/frontend/src/metabase/components/TextWidget/TextWidget.stories.tsx @@ -1,14 +1,14 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; -import { TextWidget } from "./TextWidget"; +import { TextWidget, type TextWidgetProps } from "./TextWidget"; export default { title: "Parameters/TextWidget", component: TextWidget, }; -const Template: ComponentStory<typeof TextWidget> = args => { +const Template: StoryFn<TextWidgetProps> = args => { const [{ value }, updateArgs] = useArgs(); const setValue = (value: string | number | null) => { @@ -18,18 +18,27 @@ const Template: ComponentStory<typeof TextWidget> = args => { return <TextWidget {...args} value={value} setValue={setValue} />; }; -export const Default = Template.bind({}); -Default.args = { - value: "", +export const Default = { + render: Template, + + args: { + value: "", + }, }; -export const InitialValue = Template.bind({}); -InitialValue.args = { - value: "Toucan McBird", +export const InitialValue = { + render: Template, + + args: { + value: "Toucan McBird", + }, }; -export const Placeholder = Template.bind({}); -Placeholder.args = { - value: "", - placeholder: "What's your wish?", +export const Placeholder = { + render: Template, + + args: { + value: "", + placeholder: "What's your wish?", + }, }; diff --git a/frontend/src/metabase/components/TextWidget/TextWidget.tsx b/frontend/src/metabase/components/TextWidget/TextWidget.tsx index 51a71ccaa92771fc61c91ccd3abe31335a6319cb..17f71f3e62242e349fc2a83ff8aee1caa96c3d36 100644 --- a/frontend/src/metabase/components/TextWidget/TextWidget.tsx +++ b/frontend/src/metabase/components/TextWidget/TextWidget.tsx @@ -4,7 +4,7 @@ import { t } from "ttag"; import { forceRedraw } from "metabase/lib/dom"; -type Props = { +export type TextWidgetProps = { value: string | number; setValue: (v: string | number | null) => void; className?: string; @@ -20,14 +20,14 @@ type State = { isFocused: boolean; }; -export class TextWidget extends Component<Props, State> { +export class TextWidget extends Component<TextWidgetProps, State> { static defaultProps = { isEditing: false, commitImmediately: false, disabled: false, }; - constructor(props: Props) { + constructor(props: TextWidgetProps) { super(props); this.state = { @@ -40,7 +40,7 @@ export class TextWidget extends Component<Props, State> { this.UNSAFE_componentWillReceiveProps(this.props); } - UNSAFE_componentWillReceiveProps(nextProps: Props) { + UNSAFE_componentWillReceiveProps(nextProps: TextWidgetProps) { if (nextProps.value !== this.props.value) { this.setState({ value: nextProps.value }, () => { // HACK: Address Safari rendering bug which causes https://github.com/metabase/metabase/issues/5335 diff --git a/frontend/src/metabase/components/Toaster/Toaster.stories.tsx b/frontend/src/metabase/components/Toaster/Toaster.stories.tsx index 185d9f0996b2ef9cadfc25a6617b4309ddcba650..f2e25a9435e10aad8fe8dd1f05357750aceeee70 100644 --- a/frontend/src/metabase/components/Toaster/Toaster.stories.tsx +++ b/frontend/src/metabase/components/Toaster/Toaster.stories.tsx @@ -1,24 +1,28 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import Toaster from "./Toaster"; +import Toaster, { type ToasterProps } from "./Toaster"; export default { title: "Dashboard/Toaster", component: Toaster, }; -const Template: ComponentStory<typeof Toaster> = args => { +const Template: StoryFn<ToasterProps> = args => { return <Toaster {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - message: "Would you like to be notified when this dashboard is done loading?", - isShown: true, - onConfirm: () => { - alert("Confirmed"); - }, - onDismiss: () => { - alert("Dismissed"); +export const Default = { + render: Template, + + args: { + message: + "Would you like to be notified when this dashboard is done loading?", + isShown: true, + onConfirm: () => { + alert("Confirmed"); + }, + onDismiss: () => { + alert("Dismissed"); + }, }, }; diff --git a/frontend/src/metabase/components/TokenFieldItem/TokenFieldItem.stories.tsx b/frontend/src/metabase/components/TokenFieldItem/TokenFieldItem.stories.tsx index a142191978c70bb339058642bd03d7b87b7f4cab..d629ef9d466c12da3d0a5f78aafb7f1a26f426f1 100644 --- a/frontend/src/metabase/components/TokenFieldItem/TokenFieldItem.stories.tsx +++ b/frontend/src/metabase/components/TokenFieldItem/TokenFieldItem.stories.tsx @@ -1,5 +1,6 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import cx from "classnames"; +import type { ComponentProps } from "react"; import CS from "metabase/css/core/index.css"; import { Icon } from "metabase/ui"; @@ -17,7 +18,7 @@ const Wrapper = ({ children }: { children: JSX.Element | JSX.Element[] }) => ( </div> ); -const Template: ComponentStory<typeof TokenFieldItem> = args => { +const Template: StoryFn<ComponentProps<typeof TokenFieldItem>> = args => { return ( <Wrapper> <TokenFieldItem {...args} /> @@ -25,7 +26,7 @@ const Template: ComponentStory<typeof TokenFieldItem> = args => { ); }; -const ManyTemplate: ComponentStory<typeof TokenFieldItem> = args => { +const ManyTemplate: StoryFn<ComponentProps<typeof TokenFieldItem>> = args => { return ( <Wrapper> <TokenFieldItem {...args}> {`${args.children} 1`} </TokenFieldItem> @@ -37,7 +38,7 @@ const ManyTemplate: ComponentStory<typeof TokenFieldItem> = args => { ); }; -const AddonTemplate: ComponentStory<typeof TokenFieldItem> = args => { +const AddonTemplate: StoryFn<ComponentProps<typeof TokenFieldItem>> = args => { return ( <Wrapper> <TokenFieldItem isValid={args.isValid}> @@ -54,21 +55,29 @@ const AddonTemplate: ComponentStory<typeof TokenFieldItem> = args => { ); }; -export const Default = Template.bind({}); -export const Many = ManyTemplate.bind({}); -export const WithAddon = AddonTemplate.bind({}); +export const Default = { + render: Template, -Default.args = { - isValid: true, - children: "Token Item Value", + args: { + isValid: true, + children: "Token Item Value", + }, }; -Many.args = { - isValid: true, - children: "Token Item Value", +export const Many = { + render: ManyTemplate, + + args: { + isValid: true, + children: "Token Item Value", + }, }; -WithAddon.args = { - isValid: true, - children: "Token Item Value", +export const WithAddon = { + render: AddonTemplate, + + args: { + isValid: true, + children: "Token Item Value", + }, }; diff --git a/frontend/src/metabase/components/UserAvatar/UserAvartar.stories.tsx b/frontend/src/metabase/components/UserAvatar/UserAvartar.stories.tsx index dc775bda6de5a953d7690dcb48fdc4eed8b60fa6..f126f0e0122ea8b95cbbd08f43b6cc6de7fa078c 100644 --- a/frontend/src/metabase/components/UserAvatar/UserAvartar.stories.tsx +++ b/frontend/src/metabase/components/UserAvatar/UserAvartar.stories.tsx @@ -1,5 +1,3 @@ -import type { ComponentStory } from "@storybook/react"; - import UserAvatar from "./UserAvatar"; export default { @@ -7,46 +5,46 @@ export default { component: UserAvatar, }; -const Template: ComponentStory<typeof UserAvatar> = args => ( - <UserAvatar {...args} /> -); - -export const Default = Template.bind({}); -Default.args = { - user: { - first_name: "Testy", - last_name: "Tableton", - email: "user@metabase.test", - common_name: "Testy Tableton", +export const Default = { + args: { + user: { + first_name: "Testy", + last_name: "Tableton", + email: "user@metabase.test", + common_name: "Testy Tableton", + }, }, }; -export const SingleName = Template.bind({}); -SingleName.args = { - user: { - first_name: "Testy", - last_name: null, - email: "user@metabase.test", - common_name: "Testy", +export const SingleName = { + args: { + user: { + first_name: "Testy", + last_name: null, + email: "user@metabase.test", + common_name: "Testy", + }, }, }; -export const OnlyEmail = Template.bind({}); -OnlyEmail.args = { - user: { - first_name: null, - last_name: null, - email: "user@metabase.test", - common_name: "user@metabase.test", +export const OnlyEmail = { + args: { + user: { + first_name: null, + last_name: null, + email: "user@metabase.test", + common_name: "user@metabase.test", + }, }, }; -export const ShortEmail = Template.bind({}); -ShortEmail.args = { - user: { - first_name: null, - last_name: null, - email: "u@metabase.test", - common_name: "u@metabase.test", +export const ShortEmail = { + args: { + user: { + first_name: null, + last_name: null, + email: "u@metabase.test", + common_name: "u@metabase.test", + }, }, }; diff --git a/frontend/src/metabase/components/YearPicker/YearPicker.stories.tsx b/frontend/src/metabase/components/YearPicker/YearPicker.stories.tsx index 27c34981a5dae8d4aa4e8b8bd2a5c2145a8cd64a..52ab023e939e11cf6606bfdebc6549bfa9d93960 100644 --- a/frontend/src/metabase/components/YearPicker/YearPicker.stories.tsx +++ b/frontend/src/metabase/components/YearPicker/YearPicker.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import YearPicker from "./YearPicker"; @@ -8,7 +8,7 @@ export default { component: YearPicker, }; -const Template: ComponentStory<typeof YearPicker> = args => { +const Template: StoryFn<typeof YearPicker> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (year: number) => { @@ -18,7 +18,10 @@ const Template: ComponentStory<typeof YearPicker> = args => { return <YearPicker {...args} value={value} onChange={handleChange} />; }; -export const Default = Template.bind({}); -Default.args = { - value: 2022, +export const Default = { + render: Template, + + args: { + value: 2022, + }, }; diff --git a/frontend/src/metabase/core/components/AccordionList/AccordionList.stories.js b/frontend/src/metabase/core/components/AccordionList/AccordionList.stories.tsx similarity index 65% rename from frontend/src/metabase/core/components/AccordionList/AccordionList.stories.js rename to frontend/src/metabase/core/components/AccordionList/AccordionList.stories.tsx index e2af0a33600042ef4e934f7dbb19b9a946abca31..dc5f342a1baeeaa074785b8a65fc0d037995dd9c 100644 --- a/frontend/src/metabase/core/components/AccordionList/AccordionList.stories.js +++ b/frontend/src/metabase/core/components/AccordionList/AccordionList.stories.tsx @@ -16,12 +16,8 @@ export default { component: AccordionList, }; -const Template = args => { - return <AccordionList {...args} />; -}; - -export const Default = Template.bind({}); - -Default.args = { - sections: SECTIONS, +export const Default = { + args: { + sections: SECTIONS, + }, }; diff --git a/frontend/src/metabase/core/components/Alert/Alert.stories.tsx b/frontend/src/metabase/core/components/Alert/Alert.stories.tsx index f24b01906bdce2f48f5f82e7a245c216abfb2d23..3cbf1324cdce1d0f072458c9d3943f59b69f8dec 100644 --- a/frontend/src/metabase/core/components/Alert/Alert.stories.tsx +++ b/frontend/src/metabase/core/components/Alert/Alert.stories.tsx @@ -1,32 +1,41 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import Alert from "./Alert"; +import Alert, { type AlertProps } from "./Alert"; export default { title: "Core/Alert", component: Alert, }; -const Template: ComponentStory<typeof Alert> = args => { +const Template: StoryFn<AlertProps> = args => { return <Alert {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - children: "Info alert", - icon: "info", +export const Default = { + render: Template, + + args: { + children: "Info alert", + icon: "info", + }, }; -export const Warning = Template.bind({}); -Warning.args = { - children: "Warning alert", - variant: "warning", - icon: "warning", +export const Warning = { + render: Template, + + args: { + children: "Warning alert", + variant: "warning", + icon: "warning", + }, }; -export const Error = Template.bind({}); -Error.args = { - children: "Error alert", - variant: "error", - icon: "warning", +export const Error = { + render: Template, + + args: { + children: "Error alert", + variant: "error", + icon: "warning", + }, }; diff --git a/frontend/src/metabase/core/components/Alert/Alert.tsx b/frontend/src/metabase/core/components/Alert/Alert.tsx index d19f2c25eac0c1fabad94d1d9624b4b6ad28c9b8..b521c34d659de3f97a2d462e2f70110994dc1bf9 100644 --- a/frontend/src/metabase/core/components/Alert/Alert.tsx +++ b/frontend/src/metabase/core/components/Alert/Alert.tsx @@ -6,7 +6,7 @@ import { AlertIcon, AlertRoot } from "./Alert.styled"; export type AlertVariant = "info" | "warning" | "error"; -interface AlertProps { +export interface AlertProps { children: ReactNode; icon?: IconName; hasBorder?: boolean; diff --git a/frontend/src/metabase/core/components/AutocompleteInput/AutocompleteInput.stories.tsx b/frontend/src/metabase/core/components/AutocompleteInput/AutocompleteInput.stories.tsx index 93d7e8b311f8d1f850c8f57134870e72c3245ee5..034278d5ecf81d24c59cf0bc6b3fa03d3d89ee65 100644 --- a/frontend/src/metabase/core/components/AutocompleteInput/AutocompleteInput.stories.tsx +++ b/frontend/src/metabase/core/components/AutocompleteInput/AutocompleteInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { useState } from "react"; import AutocompleteInput from "./AutocompleteInput"; @@ -8,7 +8,7 @@ export default { component: AutocompleteInput, }; -const Template: ComponentStory<typeof AutocompleteInput> = args => { +const Template: StoryFn<typeof AutocompleteInput> = args => { const [value, setValue] = useState(""); return ( <AutocompleteInput @@ -31,22 +31,25 @@ const Template: ComponentStory<typeof AutocompleteInput> = args => { ); }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; + +export const CustomFilter = { + render: Template, -export const CustomFilter = Template.bind({}); -CustomFilter.args = { - filterOptions: (value: string | undefined, options: string[]) => { - if (!value) { - return []; - } else { - return options.filter(o => o.includes(value[0])); - } + args: { + filterOptions: (value: string | undefined, options: string[]) => { + if (!value) { + return []; + } else { + return options.filter(o => o.includes(value[0])); + } + }, }, }; -const CustomOptionClickTemplate: ComponentStory< - typeof AutocompleteInput -> = args => { +const CustomOptionClickTemplate: StoryFn<typeof AutocompleteInput> = args => { const [value, setValue] = useState(""); const handleOptionSelect = (option: string) => { @@ -72,4 +75,7 @@ const CustomOptionClickTemplate: ComponentStory< /> ); }; -export const CustomOptionClick = CustomOptionClickTemplate.bind({}); + +export const CustomOptionClick = { + render: CustomOptionClickTemplate, +}; diff --git a/frontend/src/metabase/core/components/BookmarkToggle/BookmarkToggle.stories.tsx b/frontend/src/metabase/core/components/BookmarkToggle/BookmarkToggle.stories.tsx index a630afd9e2a4086b9089c266b451e6b4267300aa..ba6d6007fd3580534e54c25fb1737b2c4dac9a76 100644 --- a/frontend/src/metabase/core/components/BookmarkToggle/BookmarkToggle.stories.tsx +++ b/frontend/src/metabase/core/components/BookmarkToggle/BookmarkToggle.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import BookmarkToggle from "./BookmarkToggle"; @@ -8,7 +8,7 @@ export default { component: BookmarkToggle, }; -const Template: ComponentStory<typeof BookmarkToggle> = args => { +const Template: StoryFn<typeof BookmarkToggle> = args => { const [{ isBookmarked }, updateArgs] = useArgs(); const handleCreateBookmark = () => updateArgs({ isBookmarked: true }); const handleDeleteBookmark = () => updateArgs({ isBookmarked: false }); @@ -23,7 +23,10 @@ const Template: ComponentStory<typeof BookmarkToggle> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - isBookmarked: false, +export const Default = { + render: Template, + + args: { + isBookmarked: false, + }, }; diff --git a/frontend/src/metabase/core/components/Button/Button.stories.tsx b/frontend/src/metabase/core/components/Button/Button.stories.tsx index 781c47237f001e643aa4d6a9f14e157a9a7a8d03..5037f2837761b8c6bd3040eab89e4c7c795c50d8 100644 --- a/frontend/src/metabase/core/components/Button/Button.stories.tsx +++ b/frontend/src/metabase/core/components/Button/Button.stories.tsx @@ -1,34 +1,46 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import Button from "./Button"; +import Button, { type ButtonProps } from "./Button"; export default { title: "Core/Button", component: Button, }; -const Template: ComponentStory<typeof Button> = args => { +const Template: StoryFn<ButtonProps> = args => { return <Button {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - children: "Default", +export const Default = { + render: Template, + + args: { + children: "Default", + }, }; -export const Primary = Template.bind({}); -Primary.args = { - primary: true, - children: "Primary", +export const Primary = { + render: Template, + + args: { + primary: true, + children: "Primary", + }, }; -export const WithIcon = Template.bind({}); -WithIcon.args = { - icon: "chevrondown", +export const WithIcon = { + render: Template, + + args: { + icon: "chevrondown", + }, }; -export const OnlyText = Template.bind({}); -OnlyText.args = { - onlyText: true, - children: "Click Me", +export const OnlyText = { + render: Template, + + args: { + onlyText: true, + children: "Click Me", + }, }; diff --git a/frontend/src/metabase/core/components/ButtonGroup/ButtonGroup.stories.tsx b/frontend/src/metabase/core/components/ButtonGroup/ButtonGroup.stories.tsx index 004ef44b58f2f8c2ae2de9fcb3ff9e4bc78c486d..6c83cc9c6d8edf742b7045954ebcc474520c3b41 100644 --- a/frontend/src/metabase/core/components/ButtonGroup/ButtonGroup.stories.tsx +++ b/frontend/src/metabase/core/components/ButtonGroup/ButtonGroup.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import Button from "../Button"; @@ -9,7 +9,7 @@ export default { component: ButtonGroup, }; -const Template: ComponentStory<typeof ButtonGroup> = args => { +const Template: StoryFn<typeof ButtonGroup> = args => { return ( <ButtonGroup {...args}> <Button>One</Button> @@ -19,4 +19,6 @@ const Template: ComponentStory<typeof ButtonGroup> = args => { ); }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; diff --git a/frontend/src/metabase/core/components/CheckBox/CheckBox.stories.tsx b/frontend/src/metabase/core/components/CheckBox/CheckBox.stories.tsx index 8b7565f882a2d3c76c837e2a89c33bc281e5a6fe..3009f29b54204382682c1b28f975410fb63c027e 100644 --- a/frontend/src/metabase/core/components/CheckBox/CheckBox.stories.tsx +++ b/frontend/src/metabase/core/components/CheckBox/CheckBox.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import type { ChangeEvent } from "react"; import CheckBox from "./CheckBox"; @@ -9,7 +9,7 @@ export default { component: CheckBox, }; -const Template: ComponentStory<typeof CheckBox> = args => { +const Template: StoryFn<typeof CheckBox> = args => { const [{ checked }, updateArgs] = useArgs(); const handleChange = (event: ChangeEvent<HTMLInputElement>) => { @@ -19,19 +19,28 @@ const Template: ComponentStory<typeof CheckBox> = args => { return <CheckBox {...args} checked={checked} onChange={handleChange} />; }; -export const Default = Template.bind({}); -Default.args = { - checked: false, +export const Default = { + render: Template, + + args: { + checked: false, + }, }; -export const WithLabel = Template.bind({}); -WithLabel.args = { - checked: false, - label: "Label", +export const WithLabel = { + render: Template, + + args: { + checked: false, + label: "Label", + }, }; -export const WithCustomLabel = Template.bind({}); -WithCustomLabel.args = { - checked: false, - label: <strong style={{ marginLeft: "8px" }}>Label</strong>, +export const WithCustomLabel = { + render: Template, + + args: { + checked: false, + label: <strong style={{ marginLeft: "8px" }}>Label</strong>, + }, }; diff --git a/frontend/src/metabase/core/components/ColorInput/ColorInput.stories.tsx b/frontend/src/metabase/core/components/ColorInput/ColorInput.stories.tsx index bcba2aed522342d365fcf8542825eb9abf55bba5..e8bf0f8c63ad6b671472064e9e7704978bf59388 100644 --- a/frontend/src/metabase/core/components/ColorInput/ColorInput.stories.tsx +++ b/frontend/src/metabase/core/components/ColorInput/ColorInput.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import ColorInput from "./ColorInput"; @@ -8,7 +8,7 @@ export default { component: ColorInput, }; -const Template: ComponentStory<typeof ColorInput> = args => { +const Template: StoryFn<typeof ColorInput> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value?: string) => { @@ -18,4 +18,6 @@ const Template: ComponentStory<typeof ColorInput> = args => { return <ColorInput {...args} value={value} onChange={handleChange} />; }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; diff --git a/frontend/src/metabase/core/components/ColorPicker/ColorPicker.stories.tsx b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.stories.tsx index ed9c9fe17fd964b323e3e11bb2ef617af2d92753..b563f8ca8ae2a3f83149b8484f08d6e388f27723 100644 --- a/frontend/src/metabase/core/components/ColorPicker/ColorPicker.stories.tsx +++ b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; @@ -10,7 +10,7 @@ export default { component: ColorPicker, }; -const Template: ComponentStory<typeof ColorPicker> = args => { +const Template: StoryFn<typeof ColorPicker> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value?: string) => { @@ -20,8 +20,11 @@ const Template: ComponentStory<typeof ColorPicker> = args => { return <ColorPicker {...args} value={value} onChange={handleChange} />; }; -export const Default = Template.bind({}); -Default.args = { - value: color("brand"), - placeholder: color("brand"), +export const Default = { + render: Template, + + args: { + value: color("brand"), + placeholder: color("brand"), + }, }; diff --git a/frontend/src/metabase/core/components/ColorPill/ColorPill.stories.tsx b/frontend/src/metabase/core/components/ColorPill/ColorPill.stories.tsx index 2977679b22d837735e6acda49eae5c9b20320d84..e10a7c405c316b6f8f82d6ed4c3516aa79c4d8fb 100644 --- a/frontend/src/metabase/core/components/ColorPill/ColorPill.stories.tsx +++ b/frontend/src/metabase/core/components/ColorPill/ColorPill.stories.tsx @@ -1,25 +1,31 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; -import ColorPill from "./ColorPill"; +import ColorPill, { type ColorPillProps } from "./ColorPill"; export default { title: "Core/ColorPill", component: ColorPill, }; -const Template: ComponentStory<typeof ColorPill> = args => { +const Template: StoryFn<ColorPillProps> = args => { return <ColorPill {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - color: color("brand"), +export const Default = { + render: Template, + + args: { + color: color("brand"), + }, }; -export const Auto = Template.bind({}); -Auto.args = { - color: color("brand"), - isAuto: true, +export const Auto = { + render: Template, + + args: { + color: color("brand"), + isAuto: true, + }, }; diff --git a/frontend/src/metabase/core/components/ColorRange/ColorRange.stories.tsx b/frontend/src/metabase/core/components/ColorRange/ColorRange.stories.tsx index ed1b097cb3584af8b2bdf122b4e01b5c08438297..c2c4b1c5489577005ab344b8a8970613c64f89de 100644 --- a/frontend/src/metabase/core/components/ColorRange/ColorRange.stories.tsx +++ b/frontend/src/metabase/core/components/ColorRange/ColorRange.stories.tsx @@ -1,29 +1,38 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; -import ColorRange from "./ColorRange"; +import ColorRange, { type ColorRangeProps } from "./ColorRange"; export default { title: "Core/ColorRange", component: ColorRange, }; -const Template: ComponentStory<typeof ColorRange> = args => { +const Template: StoryFn<ColorRangeProps> = args => { return <ColorRange {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - colors: [color("white"), color("brand")], +export const Default = { + render: Template, + + args: { + colors: [color("white"), color("brand")], + }, }; -export const Inverted = Template.bind({}); -Inverted.args = { - colors: [color("brand"), color("white")], +export const Inverted = { + render: Template, + + args: { + colors: [color("brand"), color("white")], + }, }; -export const ThreeColors = Template.bind({}); -ThreeColors.args = { - colors: [color("error"), color("white"), color("success")], +export const ThreeColors = { + render: Template, + + args: { + colors: [color("error"), color("white"), color("success")], + }, }; diff --git a/frontend/src/metabase/core/components/ColorRangeSelector/ColorRangeSelector.stories.tsx b/frontend/src/metabase/core/components/ColorRangeSelector/ColorRangeSelector.stories.tsx index bd5b4085409ef9fe4276f39395d6548727d81b14..5faa1b52d26b65980f220f8ab499d33c04c8999c 100644 --- a/frontend/src/metabase/core/components/ColorRangeSelector/ColorRangeSelector.stories.tsx +++ b/frontend/src/metabase/core/components/ColorRangeSelector/ColorRangeSelector.stories.tsx @@ -1,16 +1,18 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; -import ColorRangeSelector from "./ColorRangeSelector"; +import ColorRangeSelector, { + type ColorRangeSelectorProps, +} from "./ColorRangeSelector"; export default { title: "Core/ColorRangeSelector", component: ColorRangeSelector, }; -const Template: ComponentStory<typeof ColorRangeSelector> = args => { +const Template: StoryFn<ColorRangeSelectorProps> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: string[]) => { @@ -20,29 +22,42 @@ const Template: ComponentStory<typeof ColorRangeSelector> = args => { return <ColorRangeSelector {...args} value={value} onChange={handleChange} />; }; -export const Default = Template.bind({}); -Default.args = { - value: [color("white"), color("brand")], - colors: [color("brand"), color("summarize"), color("filter")], +export const Default = { + render: Template, + + args: { + value: [color("white"), color("brand")], + colors: [color("brand"), color("summarize"), color("filter")], + }, }; -export const WithColorRanges = Template.bind({}); -WithColorRanges.args = { - value: [color("white"), color("brand")], - colors: [color("brand"), color("summarize"), color("filter")], - colorRanges: [ - [color("error"), color("white"), color("success")], - [color("error"), color("warning"), color("success")], - ], +export const WithColorRanges = { + render: Template, + + args: { + value: [color("white"), color("brand")], + colors: [color("brand"), color("summarize"), color("filter")], + colorRanges: [ + [color("error"), color("white"), color("success")], + [color("error"), color("warning"), color("success")], + ], + }, }; -export const WithColorMapping = Template.bind({}); -WithColorMapping.args = { - value: [color("white"), color("brand")], - colors: [color("brand"), color("summarize"), color("filter")], - colorMapping: { - [color("brand")]: [color("brand"), color("white"), color("brand")], - [color("summarize")]: [color("summarize"), color("white"), color("error")], - [color("filter")]: [color("filter"), color("white"), color("filter")], +export const WithColorMapping = { + render: Template, + + args: { + value: [color("white"), color("brand")], + colors: [color("brand"), color("summarize"), color("filter")], + colorMapping: { + [color("brand")]: [color("brand"), color("white"), color("brand")], + [color("summarize")]: [ + color("summarize"), + color("white"), + color("error"), + ], + [color("filter")]: [color("filter"), color("white"), color("filter")], + }, }, }; diff --git a/frontend/src/metabase/core/components/ColorSelector/ColorSelector.stories.tsx b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.stories.tsx index 47368eb4f8323efa248ff18de2752da213412641..909ce20462c5214e7a1fbdac8727de716f6a39d5 100644 --- a/frontend/src/metabase/core/components/ColorSelector/ColorSelector.stories.tsx +++ b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.stories.tsx @@ -1,16 +1,16 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; -import ColorSelector from "./ColorSelector"; +import ColorSelector, { type ColorSelectorProps } from "./ColorSelector"; export default { title: "Core/ColorSelector", component: ColorSelector, }; -const Template: ComponentStory<typeof ColorSelector> = args => { +const Template: StoryFn<ColorSelectorProps> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: string) => { @@ -20,8 +20,11 @@ const Template: ComponentStory<typeof ColorSelector> = args => { return <ColorSelector {...args} value={value} onChange={handleChange} />; }; -export const Default = Template.bind({}); -Default.args = { - value: color("brand"), - colors: [color("brand"), color("summarize"), color("filter")], +export const Default = { + render: Template, + + args: { + value: color("brand"), + colors: [color("brand"), color("summarize"), color("filter")], + }, }; diff --git a/frontend/src/metabase/core/components/DateInput/DateInput.stories.tsx b/frontend/src/metabase/core/components/DateInput/DateInput.stories.tsx index 1ab46b2a47c42ace9596f45c8cada23dd1f9f735..3dcda8c2f8c5085a525c8330104e1245ffbb0151 100644 --- a/frontend/src/metabase/core/components/DateInput/DateInput.stories.tsx +++ b/frontend/src/metabase/core/components/DateInput/DateInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import type { Moment } from "moment-timezone"; import { useState } from "react"; @@ -9,14 +9,19 @@ export default { component: DateInput, }; -const Template: ComponentStory<typeof DateInput> = args => { +const Template: StoryFn<typeof DateInput> = args => { const [value, setValue] = useState<Moment>(); return <DateInput {...args} value={value} onChange={setValue} />; }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; + +export const WithTime = { + render: Template, -export const WithTime = Template.bind({}); -WithTime.args = { - hasTime: true, + args: { + hasTime: true, + }, }; diff --git a/frontend/src/metabase/core/components/DateSelector/DateSelector.stories.tsx b/frontend/src/metabase/core/components/DateSelector/DateSelector.stories.tsx index f235584e07bc634083353fc33be433d7ada9663b..697e64f5b8556ae3f8facd7f656e9b6c11ff8b38 100644 --- a/frontend/src/metabase/core/components/DateSelector/DateSelector.stories.tsx +++ b/frontend/src/metabase/core/components/DateSelector/DateSelector.stories.tsx @@ -1,23 +1,28 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import moment from "moment-timezone"; import { useState } from "react"; -import DateSelector from "./DateSelector"; +import DateSelector, { type DateSelectorProps } from "./DateSelector"; export default { title: "Core/DateSelector", component: DateSelector, }; -const Template: ComponentStory<typeof DateSelector> = args => { +const Template: StoryFn<DateSelectorProps> = args => { const [value, setValue] = useState(args.value); return <DateSelector {...args} value={value} onChange={setValue} />; }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; + +export const WithTime = { + render: Template, -export const WithTime = Template.bind({}); -WithTime.args = { - value: moment("2015-01-01"), - hasTime: true, + args: { + value: moment("2015-01-01"), + hasTime: true, + }, }; diff --git a/frontend/src/metabase/core/components/DateWidget/DateWidget.stories.tsx b/frontend/src/metabase/core/components/DateWidget/DateWidget.stories.tsx index 9701bff1aca5eab8e8d9f8a61747cdf26580b27a..7e7a92bb5966b6f654431ac0787c10b669bf5d61 100644 --- a/frontend/src/metabase/core/components/DateWidget/DateWidget.stories.tsx +++ b/frontend/src/metabase/core/components/DateWidget/DateWidget.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import type { Moment } from "moment-timezone"; import { useState } from "react"; @@ -9,14 +9,19 @@ export default { component: DateWidget, }; -const Template: ComponentStory<typeof DateWidget> = args => { +const Template: StoryFn<typeof DateWidget> = args => { const [value, setValue] = useState<Moment>(); return <DateWidget {...args} value={value} onChange={setValue} />; }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; + +export const WithTime = { + render: Template, -export const WithTime = Template.bind({}); -WithTime.args = { - hasTime: true, + args: { + hasTime: true, + }, }; diff --git a/frontend/src/metabase/core/components/EditableText/EditableText.stories.tsx b/frontend/src/metabase/core/components/EditableText/EditableText.stories.tsx index 7accb1a8f0d141efc5a775dc9d26d7bfa4952cd7..d9695da17564d6495956a04d9cb369d93b5a20ae 100644 --- a/frontend/src/metabase/core/components/EditableText/EditableText.stories.tsx +++ b/frontend/src/metabase/core/components/EditableText/EditableText.stories.tsx @@ -1,44 +1,56 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import EditableText from "./EditableText"; +import EditableText, { type EditableTextProps } from "./EditableText"; export default { title: "Core/EditableText", component: EditableText, }; -const Template: ComponentStory<typeof EditableText> = args => { +const Template: StoryFn<EditableTextProps> = args => { return <EditableText {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - initialValue: "Question", - placeholder: "Enter title", +export const Default = { + render: Template, + + args: { + initialValue: "Question", + placeholder: "Enter title", + }, }; -export const Multiline = Template.bind({}); -Multiline.args = { - initialValue: "Question", - placeholder: "Enter title", - isMultiline: true, +export const Multiline = { + render: Template, + + args: { + initialValue: "Question", + placeholder: "Enter title", + isMultiline: true, + }, }; -export const WithMaxWidth = Template.bind({}); -WithMaxWidth.args = { - initialValue: "Question", - placeholder: "Enter title", - style: { maxWidth: 500 }, +export const WithMaxWidth = { + render: Template, + + args: { + initialValue: "Question", + placeholder: "Enter title", + style: { maxWidth: 500 }, + }, }; -export const WithMarkdown = Template.bind({}); -WithMarkdown.args = { - initialValue: `**bold** text +export const WithMarkdown = { + render: Template, + + args: { + initialValue: `**bold** text - *multiline* + *multiline* - and [link](https://metabase.com)`, - placeholder: "Enter description", - isMultiline: true, - isMarkdown: true, + and [link](https://metabase.com)`, + placeholder: "Enter description", + isMultiline: true, + isMarkdown: true, + }, }; diff --git a/frontend/src/metabase/core/components/Ellipsified/Ellipsified.stories.tsx b/frontend/src/metabase/core/components/Ellipsified/Ellipsified.stories.tsx index 5d3f140691c7f43ed85def30da8bd52faaa66097..6455788de0497b4fc652ca58250b031ba0b5c5c6 100644 --- a/frontend/src/metabase/core/components/Ellipsified/Ellipsified.stories.tsx +++ b/frontend/src/metabase/core/components/Ellipsified/Ellipsified.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Ellipsified } from "./Ellipsified"; @@ -16,7 +16,7 @@ export default { component: Ellipsified, }; -const Template: ComponentStory<typeof Ellipsified> = args => ( +const Template: StoryFn<typeof Ellipsified> = args => ( <ul style={{ maxWidth: 100 }}> {testLabels.map((label: string) => ( <li style={{ marginTop: 10 }} key={label}> @@ -26,8 +26,12 @@ const Template: ComponentStory<typeof Ellipsified> = args => ( </ul> ); -export const SingleLineEllipsify = Template.bind({}); -SingleLineEllipsify.args = { lines: 1 }; +export const SingleLineEllipsify = { + render: Template, + args: { lines: 1 }, +}; -export const MultiLineClamp = Template.bind({}); -MultiLineClamp.args = { lines: 8 }; +export const MultiLineClamp = { + render: Template, + args: { lines: 8 }, +}; diff --git a/frontend/src/metabase/core/components/ExternalLink/ExternalLink.stories.tsx b/frontend/src/metabase/core/components/ExternalLink/ExternalLink.stories.tsx index 5d1820255add0c121cb472ef13f3e59ce5a5914d..73a9d106dc0f95489cefca119bb26bfcff57b8e7 100644 --- a/frontend/src/metabase/core/components/ExternalLink/ExternalLink.stories.tsx +++ b/frontend/src/metabase/core/components/ExternalLink/ExternalLink.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import ExternalLink from "./ExternalLink"; @@ -7,12 +7,15 @@ export default { component: ExternalLink, }; -const Template: ComponentStory<typeof ExternalLink> = args => { +const Template: StoryFn<typeof ExternalLink> = args => { return <ExternalLink {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - href: "/", - children: "Link", +export const Default = { + render: Template, + + args: { + href: "/", + children: "Link", + }, }; diff --git a/frontend/src/metabase/core/components/FileInput/FileInput.stories.tsx b/frontend/src/metabase/core/components/FileInput/FileInput.stories.tsx index 0efcf1592b1304fe83801f52faed9c0c778b2937..8cc4cd502f8921b325f64b3d3b6fb6c9df286343 100644 --- a/frontend/src/metabase/core/components/FileInput/FileInput.stories.tsx +++ b/frontend/src/metabase/core/components/FileInput/FileInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import FileInput from "./FileInput"; @@ -7,11 +7,14 @@ export default { component: FileInput, }; -const Template: ComponentStory<typeof FileInput> = args => { +const Template: StoryFn<typeof FileInput> = args => { return <FileInput {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - name: "file", +export const Default = { + render: Template, + + args: { + name: "file", + }, }; diff --git a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.stories.tsx b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.stories.tsx index 1f02415f000d3bf6098854a9d6f2aaedf8d2fad5..4da80c2e8480a37a6a6f075dd26d7f40900933fe 100644 --- a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.stories.tsx +++ b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Form, FormProvider } from "metabase/forms"; @@ -9,7 +9,7 @@ export default { component: FormCheckBox, }; -const Template: ComponentStory<typeof FormCheckBox> = args => { +const Template: StoryFn<typeof FormCheckBox> = args => { const initialValues = { value: false }; const handleSubmit = () => undefined; @@ -22,13 +22,19 @@ const Template: ComponentStory<typeof FormCheckBox> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", +export const Default = { + render: Template, + + args: { + title: "Title", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + description: "Description", + }, }; diff --git a/frontend/src/metabase/core/components/FormDateInput/FormDateInput.stories.tsx b/frontend/src/metabase/core/components/FormDateInput/FormDateInput.stories.tsx index 77ae6c0a6944deb9a40f75defd065b38d019f059..73d0167922d2042045e666b7ddb95018b109d839 100644 --- a/frontend/src/metabase/core/components/FormDateInput/FormDateInput.stories.tsx +++ b/frontend/src/metabase/core/components/FormDateInput/FormDateInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Form, FormProvider } from "metabase/forms"; @@ -9,7 +9,7 @@ export default { component: FormDateInput, }; -const Template: ComponentStory<typeof FormDateInput> = args => { +const Template: StoryFn<typeof FormDateInput> = args => { const initialValues = { value: undefined }; const handleSubmit = () => undefined; @@ -22,13 +22,19 @@ const Template: ComponentStory<typeof FormDateInput> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", +export const Default = { + render: Template, + + args: { + title: "Title", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + description: "Description", + }, }; diff --git a/frontend/src/metabase/core/components/FormField/FormField.stories.tsx b/frontend/src/metabase/core/components/FormField/FormField.stories.tsx index d9ca6952e1a3cfe9df21ec569deaf58628b00f68..fb2172d268a0d37403a544024ab83bf82b56a4c6 100644 --- a/frontend/src/metabase/core/components/FormField/FormField.stories.tsx +++ b/frontend/src/metabase/core/components/FormField/FormField.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import type { ComponentProps } from "react"; import { cloneElement, isValidElement } from "react"; @@ -17,7 +17,7 @@ type inputProps = { onChange: (value: unknown) => void; }; -const Template: ComponentStory<typeof FormField> = ({ +const Template: StoryFn<typeof FormField> = ({ children, ...args }: ComponentProps<typeof FormField>) => { @@ -37,23 +37,32 @@ const Template: ComponentStory<typeof FormField> = ({ ); }; -export const ToggleStory = Template.bind({}); -ToggleStory.storyName = "Toggle"; -ToggleStory.args = { - children: <Toggle />, +export const ToggleStory = { + render: Template, + name: "Toggle", + + args: { + children: <Toggle />, + }, }; -export const ToggleWithTitle = Template.bind({}); -ToggleWithTitle.args = { - children: <Toggle />, - title: "Toggle this value?", - infoTooltip: "Info tooltip", +export const ToggleWithTitle = { + render: Template, + + args: { + children: <Toggle />, + title: "Toggle this value?", + infoTooltip: "Info tooltip", + }, }; -export const ToggleWithInlineTitle = Template.bind({}); -ToggleWithInlineTitle.args = { - children: <Toggle />, - title: "Toggle this value?", - orientation: "horizontal", - infoTooltip: "Info tooltip", +export const ToggleWithInlineTitle = { + render: Template, + + args: { + children: <Toggle />, + title: "Toggle this value?", + orientation: "horizontal", + infoTooltip: "Info tooltip", + }, }; diff --git a/frontend/src/metabase/core/components/FormFileInput/FormFileInput.stories.tsx b/frontend/src/metabase/core/components/FormFileInput/FormFileInput.stories.tsx index 61b7e7770360b870523b2a49fb886866d082d72c..8b72d7ba9567b09f9752112394943fd9ff2afec4 100644 --- a/frontend/src/metabase/core/components/FormFileInput/FormFileInput.stories.tsx +++ b/frontend/src/metabase/core/components/FormFileInput/FormFileInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Form, FormProvider } from "metabase/forms"; @@ -9,7 +9,7 @@ export default { component: FormFileInput, }; -const Template: ComponentStory<typeof FormFileInput> = args => { +const Template: StoryFn<typeof FormFileInput> = args => { const initialValues = { value: undefined }; const handleSubmit = () => undefined; @@ -22,13 +22,19 @@ const Template: ComponentStory<typeof FormFileInput> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", +export const Default = { + render: Template, + + args: { + title: "Title", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + description: "Description", + }, }; diff --git a/frontend/src/metabase/core/components/FormInput/FormInput.stories.tsx b/frontend/src/metabase/core/components/FormInput/FormInput.stories.tsx index 49586aa96edd514310d8fd6e54291a9d22ed3f1a..7f0b5defd3b42c8a153d9f1cf0ed5b669ce4510d 100644 --- a/frontend/src/metabase/core/components/FormInput/FormInput.stories.tsx +++ b/frontend/src/metabase/core/components/FormInput/FormInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { useState } from "react"; import { Form, FormProvider } from "metabase/forms"; @@ -30,7 +30,7 @@ export default { }, }; -const Template: ComponentStory<typeof FormInput> = args => { +const Template: StoryFn<typeof FormInput> = args => { const initialValues = { value: false }; const handleSubmit = () => undefined; @@ -43,21 +43,30 @@ const Template: ComponentStory<typeof FormInput> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", +export const Default = { + render: Template, + + args: { + title: "Title", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + description: "Description", + }, }; -export const WithTitleAndActions = Template.bind({}); -WithTitleAndActions.args = { - title: "Title", - description: "Description", - optional: true, - actions: "Default", +export const WithTitleAndActions = { + render: Template, + + args: { + title: "Title", + description: "Description", + optional: true, + actions: "Default", + }, }; diff --git a/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.stories.tsx b/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.stories.tsx index dfc32d4e02fb7b7d59a2be6a6e1b91a7ad3d126d..c99126d53cfc607c0a5a0f3ece59dc068f0a6aa8 100644 --- a/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.stories.tsx +++ b/frontend/src/metabase/core/components/FormNumericInput/FormNumericInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Form, FormProvider } from "metabase/forms"; @@ -9,7 +9,7 @@ export default { component: FormNumericInput, }; -const Template: ComponentStory<typeof FormNumericInput> = args => { +const Template: StoryFn<typeof FormNumericInput> = args => { const initialValues = { value: undefined }; const handleSubmit = () => undefined; @@ -22,13 +22,19 @@ const Template: ComponentStory<typeof FormNumericInput> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", +export const Default = { + render: Template, + + args: { + title: "Title", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + description: "Description", + }, }; diff --git a/frontend/src/metabase/core/components/FormRadio/FormRadio.stories.tsx b/frontend/src/metabase/core/components/FormRadio/FormRadio.stories.tsx index 0fbfb2bdb6bc88fa9f75f5cda9ac00db41db7abb..5eac12865f3f0c27bf6e36f72efc9f3c237cea49 100644 --- a/frontend/src/metabase/core/components/FormRadio/FormRadio.stories.tsx +++ b/frontend/src/metabase/core/components/FormRadio/FormRadio.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Form, FormProvider } from "metabase/forms"; @@ -15,7 +15,7 @@ export default { component: FormRadio, }; -const Template: ComponentStory<typeof FormRadio> = args => { +const Template: StoryFn<typeof FormRadio> = args => { const initialValues = { value: undefined }; const handleSubmit = () => undefined; @@ -28,13 +28,19 @@ const Template: ComponentStory<typeof FormRadio> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", +export const Default = { + render: Template, + + args: { + title: "Title", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + description: "Description", + }, }; diff --git a/frontend/src/metabase/core/components/FormSelect/FormSelect.stories.tsx b/frontend/src/metabase/core/components/FormSelect/FormSelect.stories.tsx index c1f326248d7c2104d8e54ca1069eb273ce10a3be..2130ad86354735f233a027a082676a561b5c3b9c 100644 --- a/frontend/src/metabase/core/components/FormSelect/FormSelect.stories.tsx +++ b/frontend/src/metabase/core/components/FormSelect/FormSelect.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Form, FormProvider } from "metabase/forms"; @@ -15,7 +15,7 @@ export default { component: FormSelect, }; -const Template: ComponentStory<typeof FormSelect> = args => { +const Template: StoryFn<typeof FormSelect> = args => { const initialValues = { value: undefined }; const handleSubmit = () => undefined; @@ -28,15 +28,21 @@ const Template: ComponentStory<typeof FormSelect> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", - placeholder: "Use default", +export const Default = { + render: Template, + + args: { + title: "Title", + placeholder: "Use default", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - placeholder: "Use default", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + placeholder: "Use default", + description: "Description", + }, }; diff --git a/frontend/src/metabase/core/components/FormTextArea/FormTextArea.stories.tsx b/frontend/src/metabase/core/components/FormTextArea/FormTextArea.stories.tsx index 65fe4b49be76aee99bdd730dc7009c242cfcdbca..8cb7bff34d0e41c877e8a7fa3948bffef823bf0d 100644 --- a/frontend/src/metabase/core/components/FormTextArea/FormTextArea.stories.tsx +++ b/frontend/src/metabase/core/components/FormTextArea/FormTextArea.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Form, FormProvider } from "metabase/forms"; @@ -9,7 +9,7 @@ export default { component: FormTextArea, }; -const Template: ComponentStory<typeof FormTextArea> = args => { +const Template: StoryFn<typeof FormTextArea> = args => { const initialValues = { value: false }; const handleSubmit = () => undefined; @@ -22,13 +22,19 @@ const Template: ComponentStory<typeof FormTextArea> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", +export const Default = { + render: Template, + + args: { + title: "Title", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + description: "Description", + }, }; diff --git a/frontend/src/metabase/core/components/FormToggle/FormToggle.stories.tsx b/frontend/src/metabase/core/components/FormToggle/FormToggle.stories.tsx index ca0f7ef18a13c67ab3623324e26595afc592af32..c8007d2ea1c03e7995923499648b44f7c8ad332e 100644 --- a/frontend/src/metabase/core/components/FormToggle/FormToggle.stories.tsx +++ b/frontend/src/metabase/core/components/FormToggle/FormToggle.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { Form, FormProvider } from "metabase/forms"; @@ -9,7 +9,7 @@ export default { component: FormToggle, }; -const Template: ComponentStory<typeof FormToggle> = args => { +const Template: StoryFn<typeof FormToggle> = args => { const initialValues = { value: false }; const handleSubmit = () => undefined; @@ -22,13 +22,19 @@ const Template: ComponentStory<typeof FormToggle> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - title: "Title", +export const Default = { + render: Template, + + args: { + title: "Title", + }, }; -export const WithDescription = Template.bind({}); -WithDescription.args = { - title: "Title", - description: "Description", +export const WithDescription = { + render: Template, + + args: { + title: "Title", + description: "Description", + }, }; diff --git a/frontend/src/metabase/core/components/Input/Input.stories.tsx b/frontend/src/metabase/core/components/Input/Input.stories.tsx index d5d4ada42bd6317a6c25b7924fd0f3f49ff51cb3..7f610b4da142884cab1fec18000b15165e17d45c 100644 --- a/frontend/src/metabase/core/components/Input/Input.stories.tsx +++ b/frontend/src/metabase/core/components/Input/Input.stories.tsx @@ -1,18 +1,18 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { useState } from "react"; -import Input from "./Input"; +import Input, { type InputProps } from "./Input"; export default { title: "Core/Input", component: Input, }; -const UncontrolledTemplate: ComponentStory<typeof Input> = args => { +const UncontrolledTemplate: StoryFn<InputProps> = args => { return <Input {...args} />; }; -const ControlledTemplate: ComponentStory<typeof Input> = args => { +const ControlledTemplate: StoryFn<typeof Input> = args => { const [value, setValue] = useState(""); return ( <Input @@ -24,17 +24,27 @@ const ControlledTemplate: ComponentStory<typeof Input> = args => { ); }; -export const Default = UncontrolledTemplate.bind({}); +export const Default = { + render: UncontrolledTemplate, +}; + +export const WithError = { + render: UncontrolledTemplate, -export const WithError = UncontrolledTemplate.bind({}); -WithError.args = { - error: true, + args: { + error: true, + }, }; -export const WithRightIcon = UncontrolledTemplate.bind({}); -WithRightIcon.args = { - rightIcon: "info", - rightIconTooltip: "Useful tips", +export const WithRightIcon = { + render: UncontrolledTemplate, + + args: { + rightIcon: "info", + rightIconTooltip: "Useful tips", + }, }; -export const Controlled = ControlledTemplate.bind({}); +export const Controlled = { + render: ControlledTemplate, +}; diff --git a/frontend/src/metabase/core/components/Link/Link.stories.tsx b/frontend/src/metabase/core/components/Link/Link.stories.tsx index d50b7ea43fe39cf95009a3d39f7f0d25e006bad6..21e2ace4561f09986d5cd1f07bc6a80fd61cd8c1 100644 --- a/frontend/src/metabase/core/components/Link/Link.stories.tsx +++ b/frontend/src/metabase/core/components/Link/Link.stories.tsx @@ -1,6 +1,6 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import Link from "./"; +import Link, { type LinkProps } from "./"; export default { title: "Core/Link", @@ -13,7 +13,7 @@ const sampleStyle = { gap: "2rem", }; -const Template: ComponentStory<typeof Link> = args => { +const Template: StoryFn<LinkProps> = args => { return ( <div style={sampleStyle}> <Link {...args}>Click Me</Link> @@ -21,9 +21,11 @@ const Template: ComponentStory<typeof Link> = args => { ); }; -export const Default = Template.bind({}); +export const Default = { + render: Template, -Default.args = { - to: "/foo/bar", - variant: "default", + args: { + to: "/foo/bar", + variant: "default", + }, }; diff --git a/frontend/src/metabase/core/components/Markdown/Markdown.stories.tsx b/frontend/src/metabase/core/components/Markdown/Markdown.stories.tsx index ec922015c86401fcff39e40910e6c5e4b795e6ce..a34bb2867c55ccaf0e8f34202c51a5fe47c89fed 100644 --- a/frontend/src/metabase/core/components/Markdown/Markdown.stories.tsx +++ b/frontend/src/metabase/core/components/Markdown/Markdown.stories.tsx @@ -1,22 +1,25 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import Markdown from "./Markdown"; +import Markdown, { type MarkdownProps } from "./Markdown"; export default { title: "Core/Markdown", component: Markdown, }; -const Template: ComponentStory<typeof Markdown> = args => { +const Template: StoryFn<MarkdownProps> = args => { return <Markdown {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - children: ` -Our first email blast to the mailing list not directly linked to the release -of a new version. We wanted to see if this would effect visits to landing pages -for the features in 0.41. +export const Default = { + render: Template, -Here’s a [doc](https://metabase.test) with the findings.`, + args: { + children: ` + Our first email blast to the mailing list not directly linked to the release + of a new version. We wanted to see if this would effect visits to landing pages + for the features in 0.41. + + Here’s a [doc](https://metabase.test) with the findings.`, + }, }; diff --git a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.stories.tsx b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.stories.tsx index cd71a8526e91b5737961c235025d65de09d66b92..7bbdb66cb10a059feacbb471cb89e51cb215f964 100644 --- a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.stories.tsx +++ b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.stories.tsx @@ -1,14 +1,14 @@ import styled from "@emotion/styled"; -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import { MarkdownPreview } from "./MarkdownPreview"; +import { MarkdownPreview, type MarkdownPreviewProps } from "./MarkdownPreview"; export default { title: "Core/MarkdownPreview", component: MarkdownPreview, }; -const Template: ComponentStory<typeof MarkdownPreview> = args => { +const Template: StoryFn<MarkdownPreviewProps> = args => { return ( <Container> <MarkdownPreview {...args} /> @@ -20,22 +20,28 @@ const Container = styled.div` width: 200px; `; -export const PlainText = Template.bind({}); -PlainText.args = { - children: `Our first email blast to the mailing list not directly linked to the release of a new version. We wanted to see if this would effect visits to landing pages for the features in 0.41.`, +export const PlainText = { + render: Template, + + args: { + children: `Our first email blast to the mailing list not directly linked to the release of a new version. We wanted to see if this would effect visits to landing pages for the features in 0.41.`, + }, }; -export const Markdown = Template.bind({}); -Markdown.args = { - children: ` +export const Markdown = { + render: Template, + + args: { + children: ` -# New version + # New version -Our first email blast to the mailing list not directly linked to the release -of a new version. We wanted to see if this would effect visits to landing pages -for the features in 0.41. + Our first email blast to the mailing list not directly linked to the release + of a new version. We wanted to see if this would effect visits to landing pages + for the features in 0.41. ----- + ---- -Here’s a [doc](https://metabase.test) with the findings.`, + Here’s a [doc](https://metabase.test) with the findings.`, + }, }; diff --git a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.tsx b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.tsx index 36762dd2f73f182a87b4cac983bb627279aa2ff3..5a35a2de268058f97841e945aec8b5ae8529c83a 100644 --- a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.tsx +++ b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.tsx @@ -8,7 +8,7 @@ import Tooltip from "../Tooltip"; import C from "./MarkdownPreview.module.css"; -interface Props { +export interface MarkdownPreviewProps { children: string; className?: string; tooltipMaxWidth?: ComponentProps<typeof Tooltip>["maxWidth"]; @@ -26,7 +26,7 @@ export const MarkdownPreview = ({ lineClamp, allowedElements = DEFAULT_ALLOWED_ELEMENTS, oneLine, -}: Props) => { +}: MarkdownPreviewProps) => { const { isTruncated, ref } = useIsTruncated(); const setReactMarkdownRef: LegacyRef<HTMLDivElement> = div => { diff --git a/frontend/src/metabase/core/components/NumericInput/NumericInput.stories.tsx b/frontend/src/metabase/core/components/NumericInput/NumericInput.stories.tsx index dfac3bd278e8e41c208582759b762ea761b4b91c..ee3c4e3bf0e7304a3d7c923ff2cabbae0d42197b 100644 --- a/frontend/src/metabase/core/components/NumericInput/NumericInput.stories.tsx +++ b/frontend/src/metabase/core/components/NumericInput/NumericInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { useState } from "react"; import NumericInput from "./NumericInput"; @@ -8,9 +8,11 @@ export default { component: NumericInput, }; -const Template: ComponentStory<typeof NumericInput> = args => { +const Template: StoryFn<typeof NumericInput> = args => { const [value, setValue] = useState<number>(); return <NumericInput {...args} value={value} onChange={setValue} />; }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; diff --git a/frontend/src/metabase/core/components/Radio/Radio.stories.tsx b/frontend/src/metabase/core/components/Radio/Radio.stories.tsx index 16842711d817e189378677a267c106b6508f5c95..b4cfc8d04ab9b8539de88b4990898ade50c32fbf 100644 --- a/frontend/src/metabase/core/components/Radio/Radio.stories.tsx +++ b/frontend/src/metabase/core/components/Radio/Radio.stories.tsx @@ -1,14 +1,14 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; -import Radio from "./Radio"; +import Radio, { type RadioProps } from "./Radio"; export default { title: "Deprecated/Radio", component: Radio, }; -const Template: ComponentStory<typeof Radio> = args => { +const Template: StoryFn<RadioProps<any>> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: unknown) => updateArgs({ value }); @@ -23,20 +23,29 @@ Template.args = { ], }; -export const Default = Template.bind({}); -Default.args = { - ...Template.args, - variant: "normal", +export const Default = { + render: Template, + + args: { + ...Template.args, + variant: "normal", + }, }; -export const Underlined = Template.bind({}); -Underlined.args = { - ...Template.args, - variant: "underlined", +export const Underlined = { + render: Template, + + args: { + ...Template.args, + variant: "underlined", + }, }; -export const Bubble = Template.bind({}); -Bubble.args = { - ...Template.args, - variant: "bubble", +export const Bubble = { + render: Template, + + args: { + ...Template.args, + variant: "bubble", + }, }; diff --git a/frontend/src/metabase/core/components/Select/Select.stories.tsx b/frontend/src/metabase/core/components/Select/Select.stories.tsx index 11c018a85a4ac107c22cea636b4c5170cf6ce9a4..fff58b33ce6f76861de25578aefd0bc3e814acb3 100644 --- a/frontend/src/metabase/core/components/Select/Select.stories.tsx +++ b/frontend/src/metabase/core/components/Select/Select.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import Select from "./Select"; @@ -7,90 +7,93 @@ export default { component: Select, }; -const Template: ComponentStory<typeof Select> = args => { +const Template: StoryFn<typeof Select> = args => { return <Select {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - multiple: true, - defaultValue: ["type/PK", "type/Category"], - sections: [ - { - items: [ - { - description: "The primary key for this table.", - icon: "unknown", - name: "Entity Key", - value: "type/PK", - }, - { - description: - 'The "name" of each record. Usually a column called "name", "title", etc.', - icon: "string", - name: "Entity Name", - value: "type/Name", - }, - { - description: "Points to another table to make a connection.", - icon: "connections", - name: "Foreign Key", - value: "type/FK", - }, - ], - name: "Overall Row", - }, - { - items: [ - { - description: undefined, - icon: null, - name: "Category", - value: "type/Category", - }, - { - description: undefined, - icon: null, - name: "Comment", - value: "type/Comment", - }, - { - description: undefined, - icon: null, - name: "Description", - value: "type/Description", - }, - { - description: undefined, - icon: null, - name: "Title", - value: "type/Title", - }, - ], - name: "Common", - }, - { - items: [ - { - description: undefined, - icon: null, - name: "City", - value: "type/City", - }, - { - description: undefined, - icon: null, - name: "Country", - value: "type/Country", - }, - { - description: undefined, - icon: null, - name: "Latitude", - value: "type/Latitude", - }, - ], - name: "Location", - }, - ], +export const Default = { + render: Template, + + args: { + multiple: true, + defaultValue: ["type/PK", "type/Category"], + sections: [ + { + items: [ + { + description: "The primary key for this table.", + icon: "unknown", + name: "Entity Key", + value: "type/PK", + }, + { + description: + 'The "name" of each record. Usually a column called "name", "title", etc.', + icon: "string", + name: "Entity Name", + value: "type/Name", + }, + { + description: "Points to another table to make a connection.", + icon: "connections", + name: "Foreign Key", + value: "type/FK", + }, + ], + name: "Overall Row", + }, + { + items: [ + { + description: undefined, + icon: null, + name: "Category", + value: "type/Category", + }, + { + description: undefined, + icon: null, + name: "Comment", + value: "type/Comment", + }, + { + description: undefined, + icon: null, + name: "Description", + value: "type/Description", + }, + { + description: undefined, + icon: null, + name: "Title", + value: "type/Title", + }, + ], + name: "Common", + }, + { + items: [ + { + description: undefined, + icon: null, + name: "City", + value: "type/City", + }, + { + description: undefined, + icon: null, + name: "Country", + value: "type/Country", + }, + { + description: undefined, + icon: null, + name: "Latitude", + value: "type/Latitude", + }, + ], + name: "Location", + }, + ], + }, }; diff --git a/frontend/src/metabase/core/components/SelectButton/SelectButton.stories.tsx b/frontend/src/metabase/core/components/SelectButton/SelectButton.stories.tsx index cf2d0a6ca502a3b700d2c226a2b33941361f39fd..f03df83b897cd22c33b06f7fd22b4169ecc9d688 100644 --- a/frontend/src/metabase/core/components/SelectButton/SelectButton.stories.tsx +++ b/frontend/src/metabase/core/components/SelectButton/SelectButton.stories.tsx @@ -1,35 +1,44 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import SelectButton from "./SelectButton"; +import SelectButton, { type SelectButtonProps } from "./SelectButton"; export default { title: "Core/SelectButton", component: SelectButton, }; -const Template: ComponentStory<typeof SelectButton> = args => { +const Template: StoryFn<SelectButtonProps> = args => { return <SelectButton {...args} />; }; -export const Default = Template.bind({}); -Default.args = { - children: "Select an option", - hasValue: false, - fullWidth: false, +export const Default = { + render: Template, + + args: { + children: "Select an option", + hasValue: false, + fullWidth: false, + }, }; -export const Highlighted = Template.bind({}); -Highlighted.args = { - children: "Select an option", - hasValue: true, - fullWidth: false, - highlighted: true, +export const Highlighted = { + render: Template, + + args: { + children: "Select an option", + hasValue: true, + fullWidth: false, + highlighted: true, + }, }; -export const WithClearBehavior = Template.bind({}); -WithClearBehavior.args = { - children: "Some value is selected", - hasValue: true, - fullWidth: false, - onClear: () => null, +export const WithClearBehavior = { + render: Template, + + args: { + children: "Some value is selected", + hasValue: true, + fullWidth: false, + onClear: () => null, + }, }; diff --git a/frontend/src/metabase/core/components/Slider/Slider.stories.tsx b/frontend/src/metabase/core/components/Slider/Slider.stories.tsx index bd449e302fd6727732f53cb4df68981ba63c10b0..435b8e16984c68490e9a4f4a8344c810443f7fed 100644 --- a/frontend/src/metabase/core/components/Slider/Slider.stories.tsx +++ b/frontend/src/metabase/core/components/Slider/Slider.stories.tsx @@ -1,4 +1,5 @@ -import type { ComponentStory } from "@storybook/react"; +import type { SliderProps } from "@mantine/core"; +import type { StoryFn } from "@storybook/react"; import CS from "metabase/css/core/index.css"; @@ -10,13 +11,17 @@ export default { argTypes: { onChange: { action: "onChange" } }, }; -const Template: ComponentStory<typeof Slider> = args => { +const Template: StoryFn<SliderProps> = args => { const value = [10, 40]; + return ( <div className={CS.pt4}> + {/* @ts-expect-error - fix onChange type */} <Slider {...args} value={value} onChange={args.onChange} /> </div> ); }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; diff --git a/frontend/src/metabase/core/components/TabContent/TabContent.stories.tsx b/frontend/src/metabase/core/components/TabContent/TabContent.stories.tsx index 138ab4358618a94b965723f165ba37a92a2b8588..cf7e22be8933b1049ca8d3b8b1563adb10e6b547 100644 --- a/frontend/src/metabase/core/components/TabContent/TabContent.stories.tsx +++ b/frontend/src/metabase/core/components/TabContent/TabContent.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import Tab from "../Tab"; import TabList from "../TabList"; @@ -11,7 +11,7 @@ export default { title: "Core/TabContent", component: TabContent, }; -const Template: ComponentStory<typeof TabContent> = args => { +const Template: StoryFn<typeof TabContent> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: unknown) => updateArgs({ value }); @@ -27,7 +27,10 @@ const Template: ComponentStory<typeof TabContent> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - value: 1, +export const Default = { + render: Template, + + args: { + value: 1, + }, }; diff --git a/frontend/src/metabase/core/components/TabList/TabList.stories.tsx b/frontend/src/metabase/core/components/TabList/TabList.stories.tsx index d4936c2e89c956bdfc5f8e7d208f59f60f73418d..637b9306b582a9fe71d1a88df3e680e493d6dbce 100644 --- a/frontend/src/metabase/core/components/TabList/TabList.stories.tsx +++ b/frontend/src/metabase/core/components/TabList/TabList.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import Tab from "../Tab"; @@ -16,7 +16,7 @@ const sampleStyle = { border: "1px solid #ccc", }; -const Template: ComponentStory<typeof TabList> = args => { +const Template: StoryFn<typeof TabList> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: unknown) => updateArgs({ value }); @@ -43,7 +43,10 @@ const Template: ComponentStory<typeof TabList> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - value: 1, +export const Default = { + render: Template, + + args: { + value: 1, + }, }; diff --git a/frontend/src/metabase/core/components/TabRow/TabRow.stories.tsx b/frontend/src/metabase/core/components/TabRow/TabRow.stories.tsx index 0e28078c172252177da552c694db679fb2723685..6430a015de3b1abd8bb811b34088745e98730bdd 100644 --- a/frontend/src/metabase/core/components/TabRow/TabRow.stories.tsx +++ b/frontend/src/metabase/core/components/TabRow/TabRow.stories.tsx @@ -1,7 +1,7 @@ import type { UniqueIdentifier } from "@dnd-kit/core"; import { arrayMove } from "@dnd-kit/sortable"; -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { useState } from "react"; import { color } from "metabase/lib/colors"; @@ -28,7 +28,7 @@ const sampleStyle = { backgroundColor: "white", }; -const Template: ComponentStory<typeof TabRow> = args => { +const Template: StoryFn<typeof TabRow> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: unknown) => updateArgs({ value }); const [message, setMessage] = useState(""); @@ -80,12 +80,15 @@ const Template: ComponentStory<typeof TabRow> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - value: 1, +export const Default = { + render: Template, + + args: { + value: 1, + }, }; -const LinkTemplate: ComponentStory<typeof TabRow> = args => { +const LinkTemplate: StoryFn<typeof TabRow> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: unknown) => updateArgs({ value }); @@ -102,12 +105,15 @@ const LinkTemplate: ComponentStory<typeof TabRow> = args => { ); }; -export const WithLinks = LinkTemplate.bind({}); -WithLinks.args = { - value: 1, +export const WithLinks = { + render: LinkTemplate, + + args: { + value: 1, + }, }; -const DraggableTemplate: ComponentStory<typeof TabRow> = args => { +const DraggableTemplate: StoryFn<typeof TabRow> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: unknown) => updateArgs({ value }); @@ -145,7 +151,10 @@ const DraggableTemplate: ComponentStory<typeof TabRow> = args => { ); }; -export const Draggable = DraggableTemplate.bind({}); -Draggable.args = { - value: 1, +export const Draggable = { + render: DraggableTemplate, + + args: { + value: 1, + }, }; diff --git a/frontend/src/metabase/core/components/TextArea/TextArea.stories.tsx b/frontend/src/metabase/core/components/TextArea/TextArea.stories.tsx index b3856716f39a3e0654919c75716472f227d7c19f..3dc33a7e9cd5729bd625217e2965d431e63ed9d4 100644 --- a/frontend/src/metabase/core/components/TextArea/TextArea.stories.tsx +++ b/frontend/src/metabase/core/components/TextArea/TextArea.stories.tsx @@ -1,14 +1,16 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import TextArea from "./TextArea"; +import TextArea, { type TextAreaProps } from "./TextArea"; export default { title: "Core/Text Area", component: TextArea, }; -const Template: ComponentStory<typeof TextArea> = args => { +const Template: StoryFn<TextAreaProps> = args => { return <TextArea {...args} />; }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; diff --git a/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx b/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx index cac01672c1997b4ff96401e12324dad202d3c1ac..06b8e84c33ad53a156fafa27bd9deb3f9f63d6ec 100644 --- a/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx +++ b/frontend/src/metabase/core/components/TimeInput/TimeInput.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import moment from "moment-timezone"; import { useState } from "react"; @@ -9,7 +9,7 @@ export default { component: TimeInput, }; -const Template: ComponentStory<typeof TimeInput> = args => { +const Template: StoryFn<typeof TimeInput> = args => { const [value, setValue] = useState(moment("2020-01-01T10:20")); return ( @@ -17,4 +17,6 @@ const Template: ComponentStory<typeof TimeInput> = args => { ); }; -export const Default = Template.bind({}); +export const Default = { + render: Template, +}; diff --git a/frontend/src/metabase/core/components/Toggle/Toggle.stories.tsx b/frontend/src/metabase/core/components/Toggle/Toggle.stories.tsx index f619331b95916ffe1afaea534883816c96d290fd..9fd5037e04dd38339b028a0425cb532ed7b6074d 100644 --- a/frontend/src/metabase/core/components/Toggle/Toggle.stories.tsx +++ b/frontend/src/metabase/core/components/Toggle/Toggle.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import Toggle from "./Toggle"; @@ -8,14 +8,17 @@ export default { component: Toggle, }; -const Template: ComponentStory<typeof Toggle> = args => { +const Template: StoryFn<typeof Toggle> = args => { const [{ value }, updateArgs] = useArgs(); const handleChange = (value: boolean) => updateArgs({ value }); return <Toggle {...args} value={value} onChange={handleChange} />; }; -export const Default = Template.bind({}); -Default.args = { - value: false, +export const Default = { + render: Template, + + args: { + value: false, + }, }; diff --git a/frontend/src/metabase/core/components/Tooltip/Tooltip.stories.tsx b/frontend/src/metabase/core/components/Tooltip/Tooltip.stories.tsx index c4b174d8fe067a90f2b90c8190b77928ebe910c8..66c038e598d14ef1144828d9ea0bb3a1b5117f52 100644 --- a/frontend/src/metabase/core/components/Tooltip/Tooltip.stories.tsx +++ b/frontend/src/metabase/core/components/Tooltip/Tooltip.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import Tooltip from "./Tooltip"; @@ -7,29 +7,39 @@ export default { component: Tooltip, }; -const Template: ComponentStory<typeof Tooltip> = args => { +const Template: StoryFn<typeof Tooltip> = args => { return <Tooltip {...args}>Hover me</Tooltip>; }; -export const Default = Template.bind({}); -Default.args = { tooltip: "Tooltip text" }; +export const Default = { + render: Template, + args: { tooltip: "Tooltip text" }, +}; + +export const Controlled = { + render: Template, + args: { tooltip: "Controlled tooltip", isOpen: true }, +}; -export const Controlled = Template.bind({}); -Controlled.args = { tooltip: "Controlled tooltip", isOpen: true }; +export const CustomContent = { + render: Template, -export const CustomContent = Template.bind({}); -CustomContent.args = { - tooltip: ( - <div> - <div style={{ background: "blue" }}>Blue</div> - <div style={{ background: "red" }}>Red</div> - </div> - ), + args: { + tooltip: ( + <div> + <div style={{ background: "blue" }}>Blue</div> + <div style={{ background: "red" }}>Red</div> + </div> + ), + }, }; -export const LongScalarString = Template.bind({}); -LongScalarString.args = { - tooltip: - "looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong string", - isOpen: true, +export const LongScalarString = { + render: Template, + + args: { + tooltip: + "looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong string", + isOpen: true, + }, }; diff --git a/frontend/src/metabase/dashboard/actions/parameters.ts b/frontend/src/metabase/dashboard/actions/parameters.ts index 39d9b60bc814637b30e007732e86d423f84835ba..2a0ba9fb937d76cbf1f8bf58c4bf995fd54e6f7a 100644 --- a/frontend/src/metabase/dashboard/actions/parameters.ts +++ b/frontend/src/metabase/dashboard/actions/parameters.ts @@ -471,6 +471,7 @@ export const setParameterDefaultValue = createThunkAction( ...parameter, default: defaultValue, })); + dispatch(setParameterValue(parameterId, defaultValue)); return { id: parameterId, defaultValue }; }, ); diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardDetails.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardDetails.tsx new file mode 100644 index 0000000000000000000000000000000000000000..951b2badb3db57da92081c9c4356a25a3682d664 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardDetails.tsx @@ -0,0 +1,129 @@ +import cx from "classnames"; +import { useState } from "react"; +import { c, t } from "ttag"; + +import { skipToken, useGetUserQuery } from "metabase/api"; +import { SidesheetCardSection } from "metabase/common/components/Sidesheet"; +import DateTime from "metabase/components/DateTime"; +import Link from "metabase/core/components/Link"; +import Styles from "metabase/css/core/index.css"; +import { getUserName } from "metabase/lib/user"; +import { DashboardPublicLinkPopover } from "metabase/sharing/components/PublicLinkPopover"; +import { Box, FixedSizeIcon, Flex, Text } from "metabase/ui"; +import type { Dashboard } from "metabase-types/api"; + +import SidebarStyles from "./DashboardInfoSidebar.module.css"; + +export const DashboardDetails = ({ dashboard }: { dashboard: Dashboard }) => { + const lastEditInfo = dashboard["last-edit-info"]; + const createdAt = dashboard.created_at; + + // we don't hydrate creator user info on the dashboard object + const { data: creator } = useGetUserQuery(dashboard.creator_id ?? skipToken); + + return ( + <> + <SidesheetCardSection title={t`Creator and last editor`}> + {creator && ( + <Flex gap="sm" align="top"> + <FixedSizeIcon name="ai" className={SidebarStyles.IconMargin} /> + <Text> + {c( + "Describes when a dashboard was created. {0} is a date/time and {1} is a person's name", + ).jt`${( + <DateTime unit="day" value={createdAt} key="date" /> + )} by ${getUserName(creator)}`} + </Text> + </Flex> + )} + + {lastEditInfo && ( + <Flex gap="sm" align="top"> + <FixedSizeIcon name="pencil" className={SidebarStyles.IconMargin} /> + <Text> + {c( + "Describes when a dashboard was last edited. {0} is a date/time and {1} is a person's name", + ).jt`${( + <DateTime + unit="day" + value={lastEditInfo.timestamp} + key="date" + /> + )} by ${getUserName(lastEditInfo)}`} + </Text> + </Flex> + )} + </SidesheetCardSection> + <SidesheetCardSection + title={c( + "This is a heading that appears above the name of a collection - a collection that a dashboard is saved in. Feel free to translate this heading as though it said 'Saved in collection', if you think that would make more sense in your language.", + ).t`Saved in`} + > + <Flex gap="sm" align="top"> + <FixedSizeIcon + name="folder" + className={SidebarStyles.IconMargin} + color="var(--mb-color-brand)" + /> + <div> + <Text> + <Link + to={`/collection/${dashboard.collection_id}`} + variant="brand" + > + {dashboard.collection?.name} + </Link> + </Text> + </div> + </Flex> + </SidesheetCardSection> + <SharingDisplay dashboard={dashboard} /> + </> + ); +}; + +function SharingDisplay({ dashboard }: { dashboard: Dashboard }) { + const publicUUID = dashboard.public_uuid; + const embeddingEnabled = dashboard.enable_embedding; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + if (!publicUUID && !embeddingEnabled) { + return null; + } + + return ( + <SidesheetCardSection title={t`Visibility`}> + {publicUUID && ( + <Flex gap="sm" align="center"> + <FixedSizeIcon name="globe" color="var(--mb-color-brand)" /> + <Text>{t`Shared publicly`}</Text> + + <DashboardPublicLinkPopover + target={ + <FixedSizeIcon + name="link" + onClick={() => setIsPopoverOpen(prev => !prev)} + className={cx( + Styles.cursorPointer, + Styles.textBrandHover, + SidebarStyles.IconMargin, + )} + /> + } + isOpen={isPopoverOpen} + onClose={() => setIsPopoverOpen(false)} + dashboard={dashboard} + /> + </Flex> + )} + {embeddingEnabled && ( + <Flex gap="sm" align="center"> + <Box className={SidebarStyles.BrandCircle}> + <FixedSizeIcon name="embed" size="14px" /> + </Box> + <Text>{t`Embedded`}</Text> + </Flex> + )} + </SidesheetCardSection> + ); +} diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.module.css b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.module.css index 70613d52e255e14ce983454bafd680171ffefea6..66ff822fdb52cbe493d5cc4c9fcb0d4be3e747ac 100644 --- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.module.css +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.module.css @@ -2,4 +2,18 @@ max-height: 19rem; overflow: auto; line-height: 1.38rem; /* magic number to keep line-height from changing in edit mode */ + margin-left: -4px; /* to visually align the inner text with the heading */ +} + +.BrandCircle { + background-color: var(--mb-color-brand); + color: var(--mb-color-text-white); + border-radius: 50%; + height: 1rem; + width: 1rem; + padding: 1px; +} + +.IconMargin { + margin-top: 0.25rem; } diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx index e01c49b85cb8abad29d544eac05291ae7ccae49d..1a890b36828cfae5f6eb81731ea9bb2b271b0db4 100644 --- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx @@ -14,6 +14,7 @@ import SidesheetS from "metabase/common/components/Sidesheet/sidesheet.module.cs import { Timeline } from "metabase/common/components/Timeline"; import { getTimelineEvents } from "metabase/common/components/Timeline/utils"; import { useRevisionListQuery } from "metabase/common/hooks"; +import { EntityIdCard } from "metabase/components/EntityIdCard"; import EditableText from "metabase/core/components/EditableText"; import { revertToRevision, updateDashboard } from "metabase/dashboard/actions"; import { DASHBOARD_DESCRIPTION_MAX_LENGTH } from "metabase/dashboard/constants"; @@ -22,6 +23,7 @@ import { getUser } from "metabase/selectors/user"; import { Stack, Tabs, Text } from "metabase/ui"; import type { Dashboard, Revision, User } from "metabase-types/api"; +import { DashboardDetails } from "./DashboardDetails"; import DashboardInfoSidebarS from "./DashboardInfoSidebar.module.css"; interface DashboardInfoSidebarProps { @@ -174,6 +176,10 @@ const OverviewTab = ({ </Text> )} </SidesheetCard> + <SidesheetCard> + <DashboardDetails dashboard={dashboard} /> + </SidesheetCard> + <EntityIdCard entityId={dashboard.entity_id} /> </Stack> ); }; diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/tests/common.unit.spec.ts b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/tests/common.unit.spec.ts index 2281d3ddb751e8a895b426c35cb6e232d4bd72de..897994c71d5db407f3765ef2ea415a4e89dd6b6f 100644 --- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/tests/common.unit.spec.ts +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/tests/common.unit.spec.ts @@ -1,7 +1,10 @@ import userEvent from "@testing-library/user-event"; import { screen } from "__support__/ui"; -import { createMockDashboard } from "metabase-types/api/mocks"; +import { + createMockCollection, + createMockDashboard, +} from "metabase-types/api/mocks"; import { setup } from "./setup"; @@ -99,4 +102,69 @@ describe("DashboardInfoSidebar", () => { expect(setDashboardAttribute).toHaveBeenCalledWith("description", ""); }); + + it("should show last edited info", async () => { + await setup({ + dashboard: createMockDashboard({ + "last-edit-info": { + timestamp: "1793-09-22T00:00:00", + first_name: "Frodo", + last_name: "Baggins", + email: "dontlikejewelry@example.com", + id: 7, + }, + }), + }); + expect(screen.getByText("Creator and last editor")).toBeInTheDocument(); + expect(screen.getByText("September 22, 1793")).toBeInTheDocument(); + expect(screen.getByText("by Frodo Baggins")).toBeInTheDocument(); + }); + + it("should show creator info", async () => { + await setup({ + dashboard: createMockDashboard({ + creator_id: 1, + "last-edit-info": { + timestamp: "1793-09-22T00:00:00", + first_name: "Frodo", + last_name: "Baggins", + email: "dontlikejewelry@example.com", + id: 7, + }, + }), + }); + + expect(screen.getByText("Creator and last editor")).toBeInTheDocument(); + expect(screen.getByText("January 1, 2024")).toBeInTheDocument(); + expect(screen.getByText("by Testy Tableton")).toBeInTheDocument(); + }); + + it("should show collection", async () => { + await setup({ + dashboard: createMockDashboard({ + collection: createMockCollection({ + name: "My little collection ", + }), + }), + }); + + expect(screen.getByText("Saved in")).toBeInTheDocument(); + expect(await screen.findByText("My little collection")).toBeInTheDocument(); + }); + + it("should not show Visibility section when not shared publicly", async () => { + await setup(); + expect(screen.queryByText("Visibility")).not.toBeInTheDocument(); + }); + + it("should show Visibility section when dashboard has a public link", async () => { + await setup({ dashboard: createMockDashboard({ public_uuid: "123" }) }); + expect(screen.getByText("Visibility")).toBeInTheDocument(); + }); + + it("should show visibility section when embedding is enabled", async () => { + await setup({ dashboard: createMockDashboard({ enable_embedding: true }) }); + expect(screen.getByText("Visibility")).toBeInTheDocument(); + expect(screen.getByText("Embedded")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/metabase/databases/components/DatabaseEngineWarning/DatabaseEngineWarning.stories.tsx b/frontend/src/metabase/databases/components/DatabaseEngineWarning/DatabaseEngineWarning.stories.tsx index 18453f269931e23dbaf80cd3873ae568fc5d6236..1dcf1a1571d235f4462c32a17221047eb976c87a 100644 --- a/frontend/src/metabase/databases/components/DatabaseEngineWarning/DatabaseEngineWarning.stories.tsx +++ b/frontend/src/metabase/databases/components/DatabaseEngineWarning/DatabaseEngineWarning.stories.tsx @@ -1,11 +1,13 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { createMockEngine, createMockEngineSource, } from "metabase-types/api/mocks"; -import DatabaseEngineWarning from "./DatabaseEngineWarning"; +import DatabaseEngineWarning, { + type DatabaseEngineWarningProps, +} from "./DatabaseEngineWarning"; export default { title: "Databases/DatabaseEngineWarning", @@ -13,7 +15,7 @@ export default { argTypes: { onChange: { action: "onChange" } }, }; -const Template: ComponentStory<typeof DatabaseEngineWarning> = args => { +const Template: StoryFn<DatabaseEngineWarningProps> = args => { return <DatabaseEngineWarning {...args} />; }; Template.args = { @@ -50,26 +52,38 @@ Template.args = { }, }; -export const New = Template.bind({}); -New.args = { - engineKey: "presto-jdbc", - ...Template.args, +export const New = { + render: Template, + + args: { + engineKey: "presto-jdbc", + ...Template.args, + }, }; -export const Deprecated = Template.bind({}); -Deprecated.args = { - engineKey: "presto", - ...Template.args, +export const Deprecated = { + render: Template, + + args: { + engineKey: "presto", + ...Template.args, + }, }; -export const Community = Template.bind({}); -Community.args = { - engineKey: "communityEngine", - ...Template.args, +export const Community = { + render: Template, + + args: { + engineKey: "communityEngine", + ...Template.args, + }, }; -export const Partner = Template.bind({}); -Partner.args = { - engineKey: "partnerEngine", - ...Template.args, +export const Partner = { + render: Template, + + args: { + engineKey: "partnerEngine", + ...Template.args, + }, }; diff --git a/frontend/src/metabase/entities/collections/utils.unit.spec.ts b/frontend/src/metabase/entities/collections/utils.unit.spec.ts index 29de7878e340038b297954da581ee21033e1b302..28e8c2946249f6e3176e0abf75c7c3acb1cfe54a 100644 --- a/frontend/src/metabase/entities/collections/utils.unit.spec.ts +++ b/frontend/src/metabase/entities/collections/utils.unit.spec.ts @@ -350,7 +350,7 @@ describe("entities > collections > utils", () => { expectedIcon: "person", }, { - name: "Metabase Analytics", + name: "Usage Analytics", collection: createMockCollection({ type: "instance-analytics" }), expectedIcon: "audit", }, diff --git a/frontend/src/metabase/env.ts b/frontend/src/metabase/env.ts index 5ad8833c1ca9d07a427d59f229b58dde4630e010..40ac3d501db42c73c07c21032217fd9c9132857f 100644 --- a/frontend/src/metabase/env.ts +++ b/frontend/src/metabase/env.ts @@ -12,4 +12,4 @@ export const shouldLogAnalytics = process.env.MB_LOG_ANALYTICS === "true"; export const isChartsDebugLoggingEnabled = process.env.MB_LOG_CHARTS_DEBUG === "true"; -export const isEmbeddingSdk = !!process.env.IS_EMBEDDING_SDK_BUILD; +export const isEmbeddingSdk = !!process.env.IS_EMBEDDING_SDK; diff --git a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx index cec4a46cbf02b8bbd0d71537efffa055df6fb12a..65ae305a394ce75303b4f4533e84f7332c6f0337 100644 --- a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx +++ b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx @@ -15,8 +15,8 @@ import { waitForLoaderToBeRemoved, within, } from "__support__/ui"; -import { createMockModelResult } from "metabase/browse/test-utils"; -import type { ModelResult } from "metabase/browse/types"; +import type { ModelResult } from "metabase/browse/models"; +import { createMockModelResult } from "metabase/browse/models/test-utils"; import { ROOT_COLLECTION } from "metabase/entities/collections"; import * as Urls from "metabase/lib/urls"; import type { Card, Dashboard, DashboardId, User } from "metabase-types/api"; diff --git a/frontend/src/metabase/palette/components/PaletteResults.unit.spec.tsx b/frontend/src/metabase/palette/components/PaletteResults.unit.spec.tsx index 6314c8415360c97608446f4cf2bd4f98ea4ee60e..c19b042b947d5daf8f74818c8ad0ab458b17e506 100644 --- a/frontend/src/metabase/palette/components/PaletteResults.unit.spec.tsx +++ b/frontend/src/metabase/palette/components/PaletteResults.unit.spec.tsx @@ -86,7 +86,7 @@ const recents_1 = createMockRecentCollectionItem({ model: "dataset", moderated_status: "verified", parent_collection: { - id: null, + id: "root", name: "Our analytics", }, }); diff --git a/frontend/src/metabase/palette/hooks/useCommandPaletteBasicActions.tsx b/frontend/src/metabase/palette/hooks/useCommandPaletteBasicActions.tsx index afb3a279cdbe8427a9399f7b4f8f5d48ccdd96d0..f5afc2b23b3df5ec622f97ab55789088e8afa107 100644 --- a/frontend/src/metabase/palette/hooks/useCommandPaletteBasicActions.tsx +++ b/frontend/src/metabase/palette/hooks/useCommandPaletteBasicActions.tsx @@ -131,6 +131,28 @@ export const useCommandPaletteBasicActions = ({ }); } + if (hasDataAccess) { + actions.push({ + id: "new_metric", + name: t`New metric`, + section: "basic", + icon: "metric", + perform: () => { + dispatch(closeModal()); + dispatch(push("metric/query")); + dispatch( + push( + Urls.newQuestion({ + mode: "query", + cardType: "metric", + collectionId, + }), + ), + ); + }, + }); + } + if (hasDatabaseWithActionsEnabled && hasNativeWrite && hasModels) { actions.push({ id: "new_action", diff --git a/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.stories.tsx b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.stories.tsx index 943e821a20825b4bfd58e22c349d3a2ad9bf5db3..31a98879ef083a747dff076e533864e8888359ac 100644 --- a/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.stories.tsx +++ b/frontend/src/metabase/parameters/components/widgets/NumberInputWidget/NumberInputWidget.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { NumberInputWidget } from "./NumberInputWidget"; @@ -8,7 +8,7 @@ export default { component: NumberInputWidget, }; -const Template: ComponentStory<typeof NumberInputWidget> = args => { +const Template: StoryFn<typeof NumberInputWidget> = args => { const [{ value }, updateArgs] = useArgs(); const handleSetValue = (v: number[] | undefined) => { @@ -20,29 +20,41 @@ const Template: ComponentStory<typeof NumberInputWidget> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - value: [1], +export const Default = { + render: Template, + + args: { + value: [1], + }, }; -export const TwoArgs = Template.bind({}); -TwoArgs.args = { - value: [1, 2], - arity: 2, - infixText: "and", +export const TwoArgs = { + render: Template, + + args: { + value: [1, 2], + arity: 2, + infixText: "and", + }, }; -export const ThreeArgs = Template.bind({}); -ThreeArgs.args = { - value: [1, 2], - arity: 3, - infixText: "foo", - autoFocus: true, +export const ThreeArgs = { + render: Template, + + args: { + value: [1, 2], + arity: 3, + infixText: "foo", + autoFocus: true, + }, }; -export const NArgs = Template.bind({}); -NArgs.args = { - value: [1, 2, 3, 4, 5, 6], - arity: "n", - autoFocus: true, +export const NArgs = { + render: Template, + + args: { + value: [1, 2, 3, 4, 5, 6], + arity: "n", + autoFocus: true, + }, }; diff --git a/frontend/src/metabase/parameters/components/widgets/StringInputWidget/StringInputWidget.stories.tsx b/frontend/src/metabase/parameters/components/widgets/StringInputWidget/StringInputWidget.stories.tsx index 7c7fc4ae4e37fcb700864d59d954cad1ec47144d..e47f77a45f5c145520a2f3108c64cc149daaad59 100644 --- a/frontend/src/metabase/parameters/components/widgets/StringInputWidget/StringInputWidget.stories.tsx +++ b/frontend/src/metabase/parameters/components/widgets/StringInputWidget/StringInputWidget.stories.tsx @@ -1,5 +1,5 @@ -import { useArgs } from "@storybook/addons"; -import type { ComponentStory } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import type { StoryFn } from "@storybook/react"; import { StringInputWidget } from "./StringInputWidget"; @@ -8,7 +8,7 @@ export default { component: StringInputWidget, }; -const Template: ComponentStory<typeof StringInputWidget> = args => { +const Template: StoryFn<typeof StringInputWidget> = args => { const [{ value }, updateArgs] = useArgs(); const handleSetValue = (v: string[] | undefined) => { @@ -20,14 +20,20 @@ const Template: ComponentStory<typeof StringInputWidget> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - value: ["foo"], +export const Default = { + render: Template, + + args: { + value: ["foo"], + }, }; -export const NArgs = Template.bind({}); -NArgs.args = { - value: ["foo", "bar", "baz"], - arity: "n", - autoFocus: true, +export const NArgs = { + render: Template, + + args: { + value: ["foo", "bar", "baz"], + arity: "n", + autoFocus: true, + }, }; diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index f73face54ac2b97823d6de8eea27581aae700249..63a8579decebf1bd16e71127f91573e7b8b225de 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -25,12 +25,13 @@ import { } from "metabase/admin/permissions/types"; import type { ADMIN_SETTINGS_SECTIONS } from "metabase/admin/settings/selectors"; import type { - ActualModelFilters, - AvailableModelFilters, MetricFilterControlsProps, MetricFilterSettings, +} from "metabase/browse/metrics"; +import type { ModelFilterControlsProps, -} from "metabase/browse/utils"; + ModelFilterSettings, +} from "metabase/browse/models"; import { getIconBase } from "metabase/lib/icon"; import PluginPlaceholder from "metabase/plugins/components/PluginPlaceholder"; import type { SearchFilterComponent } from "metabase/search/types"; @@ -54,7 +55,6 @@ import type { GroupsPermissions, ModelCacheRefreshStatus, Revision, - SearchResult, User, UserListResult, } from "metabase-types/api"; @@ -506,21 +506,18 @@ export const PLUGIN_EMBEDDING = { }; export const PLUGIN_CONTENT_VERIFICATION = { + contentVerificationEnabled: false, VerifiedFilter: {} as SearchFilterComponent<"verified">, - availableModelFilters: {} as AvailableModelFilters, - ModelFilterControls: (() => null) as ComponentType<ModelFilterControlsProps>, - sortModelsByVerification: (_a: SearchResult, _b: SearchResult) => 0, sortCollectionsByVerification: ( _a: CollectionEssentials, _b: CollectionEssentials, ) => 0, - useModelFilterSettings: () => - [{}, _.noop] as [ - ActualModelFilters, - Dispatch<SetStateAction<ActualModelFilters>>, - ], - contentVerificationEnabled: false, + ModelFilterControls: (_props: ModelFilterControlsProps) => null, + getDefaultModelFilters: (_state: State): ModelFilterSettings => ({ + verified: false, + }), + getDefaultMetricFilters: (_state: State): MetricFilterSettings => ({ verified: false, }), diff --git a/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView-filters.stories.tsx b/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView-filters.stories.tsx index 08fa44c4bdd71e9c64b9718498386f0241d9a9d9..338d750e1d9909a1896cb6fe050e80b3a1e4b73e 100644 --- a/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView-filters.stories.tsx +++ b/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView-filters.stories.tsx @@ -1,6 +1,6 @@ // @ts-expect-error There is no type definition import createAsyncCallback from "@loki/create-async-callback"; -import type { ComponentStory, Story, StoryContext } from "@storybook/react"; +import type { StoryContext, StoryFn } from "@storybook/react"; import { userEvent, within } from "@storybook/testing-library"; import { type ComponentProps, useEffect } from "react"; import { Provider } from "react-redux"; @@ -33,7 +33,10 @@ import { createMockState, } from "metabase-types/store/mocks"; -import { PublicOrEmbeddedDashboardView } from "./PublicOrEmbeddedDashboardView"; +import { + PublicOrEmbeddedDashboardView, + type PublicOrEmbeddedDashboardViewProps, +} from "./PublicOrEmbeddedDashboardView"; export default { title: "embed/PublicOrEmbeddedDashboardView/filters", @@ -55,7 +58,7 @@ export default { }, }; -function ReduxDecorator(Story: Story, context: StoryContext) { +function ReduxDecorator(Story: StoryFn, context: StoryContext) { const parameterType: ParameterType = context.args.parameterType; const initialState = createMockState({ settings: createMockSettingsState({ @@ -106,7 +109,7 @@ function ReduxDecorator(Story: Story, context: StoryContext) { ); } -function FasterExplicitSizeUpdateDecorator(Story: Story) { +function FasterExplicitSizeUpdateDecorator(Story: StoryFn) { return ( <waitTimeContext.Provider value={0}> <Story /> @@ -120,7 +123,7 @@ function FasterExplicitSizeUpdateDecorator(Story: Story) { * make sure we finish resizing any ExplicitSize components the fastest. */ const TIME_UNTIL_ALL_ELEMENTS_STOP_RESIZING = 1500; -function WaitForResizeToStopDecorator(Story: Story) { +function WaitForResizeToStopDecorator(Story: StoryFn) { const asyncCallback = createAsyncCallback(); useEffect(() => { setTimeout(asyncCallback, TIME_UNTIL_ALL_ELEMENTS_STOP_RESIZING); @@ -134,7 +137,7 @@ declare global { overrideIsWithinIframe?: boolean; } } -function MockIsEmbeddingDecorator(Story: Story) { +function MockIsEmbeddingDecorator(Story: StoryFn) { window.overrideIsWithinIframe = true; return <Story />; } @@ -230,7 +233,7 @@ function createDashboard({ hasScroll }: CreateDashboardOpts = {}) { }); } -const Template: ComponentStory<typeof PublicOrEmbeddedDashboardView> = args => { +const Template: StoryFn<PublicOrEmbeddedDashboardViewProps> = args => { // @ts-expect-error -- custom prop to support non JSON-serializable value as args const parameterType: ParameterType = args.parameterType; const dashboard = args.dashboard; @@ -442,435 +445,538 @@ function getLastPopoverElement() { return lastPopover; } -// Light theme -export const LightThemeText = Template.bind({}); -LightThemeText.args = createDefaultArgs(); -LightThemeText.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { name: "Category" }); - await userEvent.click(filter); -}; +export const LightThemeText = { + render: Template, + args: createDefaultArgs(), -export const LightThemeTextWithValue = Template.bind({}); -LightThemeTextWithValue.args = createDefaultArgs(); -LightThemeTextWithValue.play = async ({ canvasElement }) => { - const asyncCallback = createAsyncCallback(); - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { name: "Category" }); - await userEvent.click(filter); - - const popover = getLastPopover(); - await userEvent.type( - popover.getByPlaceholderText("Enter some text"), - "filter value", - ); - await userEvent.click(getLastPopoverElement()); - asyncCallback(); + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { name: "Category" }); + await userEvent.click(filter); + }, }; -export const LightThemeParameterSearch = Template.bind({}); -LightThemeParameterSearch.args = createDefaultArgs({ - parameterType: "search", -}); -LightThemeParameterSearch.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { name: "Category" }); - await userEvent.click(filter); +export const LightThemeTextWithValue = { + render: Template, + args: createDefaultArgs(), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const asyncCallback = createAsyncCallback(); + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { name: "Category" }); + await userEvent.click(filter); + + const popover = getLastPopover(); + await userEvent.type( + popover.getByPlaceholderText("Enter some text"), + "filter value", + ); + await userEvent.click(getLastPopoverElement()); + asyncCallback(); + }, }; -export const LightThemeParameterSearchWithValue = Template.bind({}); -LightThemeParameterSearchWithValue.args = createDefaultArgs({ - parameterType: "search", -}); -LightThemeParameterSearchWithValue.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { name: "Category" }); - await userEvent.click(filter); - - const documentElement = within(document.documentElement); - const searchInput = documentElement.getByPlaceholderText("Search the list"); - await userEvent.click(documentElement.getByText("Widget")); - await userEvent.type(searchInput, "g"); - - const dropdown = getLastPopover(); - (dropdown.getByText("Gadget").parentNode as HTMLElement).setAttribute( - "data-hovered", - "true", - ); +export const LightThemeParameterSearch = { + render: Template, + + args: createDefaultArgs({ + parameterType: "search", + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { name: "Category" }); + await userEvent.click(filter); + }, }; -// Dark theme -export const DarkThemeText = Template.bind({}); -DarkThemeText.args = createDefaultArgs({ theme: "night" }); -DarkThemeText.play = LightThemeText.play; +export const LightThemeParameterSearchWithValue = { + render: Template, -export const DarkThemeTextWithValue = Template.bind({}); -DarkThemeTextWithValue.args = createDefaultArgs({ theme: "night" }); -DarkThemeTextWithValue.play = LightThemeTextWithValue.play; + args: createDefaultArgs({ + parameterType: "search", + }), -export const DarkThemeParameterSearch = Template.bind({}); -DarkThemeParameterSearch.args = createDefaultArgs({ - theme: "night", - parameterType: "search", -}); -DarkThemeParameterSearch.play = LightThemeParameterSearch.play; + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { name: "Category" }); + await userEvent.click(filter); -export const DarkThemeParameterSearchWithValue = Template.bind({}); -DarkThemeParameterSearchWithValue.args = createDefaultArgs({ - theme: "night", - parameterType: "search", -}); -DarkThemeParameterSearchWithValue.play = - LightThemeParameterSearchWithValue.play; + const documentElement = within(document.documentElement); + const searchInput = documentElement.getByPlaceholderText("Search the list"); + await userEvent.click(documentElement.getByText("Widget")); + await userEvent.type(searchInput, "g"); -// Parameter list + const dropdown = getLastPopover(); + (dropdown.getByText("Gadget").parentNode as HTMLElement).setAttribute( + "data-hovered", + "true", + ); + }, +}; -// Multiple values -export const LightThemeParameterList = Template.bind({}); -LightThemeParameterList.args = createDefaultArgs({ - parameterType: "dropdown_multiple", -}); -LightThemeParameterList.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { name: "Category" }); - await userEvent.click(filter); +export const DarkThemeText = { + render: Template, + args: createDefaultArgs({ theme: "night" }), + play: LightThemeText.play, }; -export const LightThemeParameterListWithValue = Template.bind({}); -LightThemeParameterListWithValue.args = createDefaultArgs({ - parameterType: "dropdown_multiple", -}); -LightThemeParameterListWithValue.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { name: "Category" }); - await userEvent.click(filter); - - const popover = getLastPopover(); - await userEvent.type(popover.getByPlaceholderText("Search the list"), "g"); - await userEvent.click(popover.getByText("Widget")); - const gizmo = popover.getByRole("checkbox", { - name: "Gizmo", - }) as HTMLInputElement; - gizmo.disabled = true; -}; - -export const DarkThemeParameterList = Template.bind({}); -DarkThemeParameterList.args = createDefaultArgs({ - theme: "night", - parameterType: "dropdown_multiple", -}); -DarkThemeParameterList.play = LightThemeParameterList.play; +export const DarkThemeTextWithValue = { + render: Template, + args: createDefaultArgs({ theme: "night" }), + play: LightThemeTextWithValue.play, +}; -export const DarkThemeParameterListWithValue = Template.bind({}); -DarkThemeParameterListWithValue.args = createDefaultArgs({ - theme: "night", - parameterType: "dropdown_multiple", -}); -DarkThemeParameterListWithValue.play = LightThemeParameterListWithValue.play; +export const DarkThemeParameterSearch = { + render: Template, -// Single value -export const LightThemeParameterListSingleWithValue = Template.bind({}); -LightThemeParameterListSingleWithValue.args = createDefaultArgs({ - parameterType: "dropdown_single", -}); -LightThemeParameterListSingleWithValue.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { name: "Category" }); - await userEvent.click(filter); - - const documentElement = within(document.documentElement); - await userEvent.type( - documentElement.getByPlaceholderText("Search the list"), - "g", - ); - await userEvent.click(documentElement.getByText("Widget")); - const popover = getLastPopover(); - (popover.getByText("Gadget").parentNode as HTMLElement).classList.add( - "pseudo-hover", - ); -}; + args: createDefaultArgs({ + theme: "night", + parameterType: "search", + }), -export const DarkThemeParameterListSingleWithValue = Template.bind({}); -DarkThemeParameterListSingleWithValue.args = createDefaultArgs({ - theme: "night", - parameterType: "dropdown_single", -}); -DarkThemeParameterListSingleWithValue.play = - LightThemeParameterListSingleWithValue.play; + play: LightThemeParameterSearch.play, +}; -// Date filters +export const DarkThemeParameterSearchWithValue = { + render: Template, -// All options -export const LightThemeDateFilterAllOptions = Template.bind({}); -LightThemeDateFilterAllOptions.args = createDefaultArgs({ - parameterType: "date_all_options", -}); -LightThemeDateFilterAllOptions.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Date all options", - }); - await userEvent.click(filter); + args: createDefaultArgs({ + theme: "night", + parameterType: "search", + }), - const popover = getLastPopover(); - const today = popover.getByRole("button", { name: "Today" }); - today.classList.add("pseudo-hover"); + play: LightThemeParameterSearchWithValue.play, }; -export const DarkThemeDateFilterAllOptions = Template.bind({}); -DarkThemeDateFilterAllOptions.args = createDefaultArgs({ - theme: "night", - parameterType: "date_all_options", -}); -DarkThemeDateFilterAllOptions.play = LightThemeDateFilterAllOptions.play; - -// Month and Year -export const LightThemeDateFilterMonthYear = Template.bind({}); -LightThemeDateFilterMonthYear.args = createDefaultArgs({ - parameterType: "date_month_year", - parameterValues: { - [DATE_FILTER_ID]: "2024-01", - }, -}); -LightThemeDateFilterMonthYear.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Date Month and Year", - }); - await userEvent.click(filter); +export const LightThemeParameterList = { + render: Template, - const popover = getLastPopover(); - const month = popover.getByText("March"); - month.classList.add("pseudo-hover"); + args: createDefaultArgs({ + parameterType: "dropdown_multiple", + }), - await userEvent.click( - popover.getAllByDisplayValue("2024").at(-1) as HTMLElement, - ); - const dropdown = getLastPopover(); - dropdown - .getByRole("option", { name: "2023" }) - .setAttribute("data-hovered", "true"); -}; - -export const DarkThemeDateFilterMonthYear = Template.bind({}); -DarkThemeDateFilterMonthYear.args = createDefaultArgs({ - theme: "night", - parameterType: "date_month_year", - parameterValues: { - [DATE_FILTER_ID]: "2024-01", + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { name: "Category" }); + await userEvent.click(filter); }, -}); -DarkThemeDateFilterMonthYear.play = LightThemeDateFilterMonthYear.play; - -// Quarter and Year -export const LightThemeDateFilterQuarterYear = Template.bind({}); -LightThemeDateFilterQuarterYear.args = createDefaultArgs({ - parameterType: "date_quarter_year", - parameterValues: { - [DATE_FILTER_ID]: "Q1-2024", +}; + +export const LightThemeParameterListWithValue = { + render: Template, + + args: createDefaultArgs({ + parameterType: "dropdown_multiple", + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { name: "Category" }); + await userEvent.click(filter); + + const popover = getLastPopover(); + await userEvent.type(popover.getByPlaceholderText("Search the list"), "g"); + await userEvent.click(popover.getByText("Widget")); + const gizmo = popover.getByRole("checkbox", { + name: "Gizmo", + }) as HTMLInputElement; + gizmo.disabled = true; }, -}); -LightThemeDateFilterQuarterYear.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Date Quarter and Year", - }); - await userEvent.click(filter); +}; + +export const DarkThemeParameterList = { + render: Template, - const popover = getLastPopover(); - const month = popover.getByText("Q2"); - month.classList.add("pseudo-hover"); + args: createDefaultArgs({ + theme: "night", + parameterType: "dropdown_multiple", + }), + + play: LightThemeParameterList.play, }; -export const LightThemeDateFilterQuarterYearDropdown = Template.bind({}); -LightThemeDateFilterQuarterYearDropdown.args = createDefaultArgs({ - parameterType: "date_quarter_year", - parameterValues: { - [DATE_FILTER_ID]: "Q1-2024", - }, -}); -LightThemeDateFilterQuarterYearDropdown.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Date Quarter and Year", - }); - await userEvent.click(filter); +export const DarkThemeParameterListWithValue = { + render: Template, - const popover = getLastPopover(); + args: createDefaultArgs({ + theme: "night", + parameterType: "dropdown_multiple", + }), - await userEvent.click( - popover.getAllByDisplayValue("2024").at(-1) as HTMLElement, - ); - const dropdown = getLastPopover(); - dropdown - .getByRole("option", { name: "2023" }) - .setAttribute("data-hovered", "true"); -}; - -export const DarkThemeDateFilterQuarterYear = Template.bind({}); -DarkThemeDateFilterQuarterYear.args = createDefaultArgs({ - theme: "night", - parameterType: "date_quarter_year", - parameterValues: { - [DATE_FILTER_ID]: "Q1-2024", + play: LightThemeParameterListWithValue.play, +}; + +export const LightThemeParameterListSingleWithValue = { + render: Template, + + args: createDefaultArgs({ + parameterType: "dropdown_single", + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { name: "Category" }); + await userEvent.click(filter); + + const documentElement = within(document.documentElement); + await userEvent.type( + documentElement.getByPlaceholderText("Search the list"), + "g", + ); + await userEvent.click(documentElement.getByText("Widget")); + const popover = getLastPopover(); + (popover.getByText("Gadget").parentNode as HTMLElement).classList.add( + "pseudo-hover", + ); }, -}); -DarkThemeDateFilterQuarterYear.play = LightThemeDateFilterQuarterYear.play; - -export const DarkThemeDateFilterQuarterYearDropdown = Template.bind({}); -DarkThemeDateFilterQuarterYearDropdown.args = createDefaultArgs({ - theme: "night", - parameterType: "date_quarter_year", - parameterValues: { - [DATE_FILTER_ID]: "Q1-2024", +}; + +export const DarkThemeParameterListSingleWithValue = { + render: Template, + + args: createDefaultArgs({ + theme: "night", + parameterType: "dropdown_single", + }), + + play: LightThemeParameterListSingleWithValue.play, +}; + +export const LightThemeDateFilterAllOptions = { + render: Template, + + args: createDefaultArgs({ + parameterType: "date_all_options", + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Date all options", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + const today = popover.getByRole("button", { name: "Today" }); + today.classList.add("pseudo-hover"); }, -}); -DarkThemeDateFilterQuarterYearDropdown.play = - LightThemeDateFilterQuarterYearDropdown.play; - -// Single date -export const LightThemeDateFilterSingle = Template.bind({}); -LightThemeDateFilterSingle.args = createDefaultArgs({ - parameterType: "date_single", - parameterValues: { - [DATE_FILTER_ID]: "2024-06-01", +}; + +export const DarkThemeDateFilterAllOptions = { + render: Template, + + args: createDefaultArgs({ + theme: "night", + parameterType: "date_all_options", + }), + + play: LightThemeDateFilterAllOptions.play, +}; + +export const LightThemeDateFilterMonthYear = { + render: Template, + + args: createDefaultArgs({ + parameterType: "date_month_year", + parameterValues: { + [DATE_FILTER_ID]: "2024-01", + }, + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Date Month and Year", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + const month = popover.getByText("March"); + month.classList.add("pseudo-hover"); + + await userEvent.click( + popover.getAllByDisplayValue("2024").at(-1) as HTMLElement, + ); + const dropdown = getLastPopover(); + dropdown + .getByRole("option", { name: "2023" }) + .setAttribute("data-hovered", "true"); }, -}); -LightThemeDateFilterSingle.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Date single", - }); - await userEvent.click(filter); +}; + +export const DarkThemeDateFilterMonthYear = { + render: Template, - const popover = getLastPopover(); - popover.getByText("15").classList.add("pseudo-hover"); + args: createDefaultArgs({ + theme: "night", + parameterType: "date_month_year", + parameterValues: { + [DATE_FILTER_ID]: "2024-01", + }, + }), + + play: LightThemeDateFilterMonthYear.play, }; -export const DarkThemeDateFilterSingle = Template.bind({}); -DarkThemeDateFilterSingle.args = createDefaultArgs({ - theme: "night", - parameterType: "date_single", - parameterValues: { - [DATE_FILTER_ID]: "2024-06-01", +export const LightThemeDateFilterQuarterYear = { + render: Template, + + args: createDefaultArgs({ + parameterType: "date_quarter_year", + parameterValues: { + [DATE_FILTER_ID]: "Q1-2024", + }, + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Date Quarter and Year", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + const month = popover.getByText("Q2"); + month.classList.add("pseudo-hover"); }, -}); -DarkThemeDateFilterSingle.play = LightThemeDateFilterSingle.play; - -// Range -export const LightThemeDateFilterRange = Template.bind({}); -LightThemeDateFilterRange.args = createDefaultArgs({ - parameterType: "date_range", - parameterValues: { - [DATE_FILTER_ID]: "2024-06-01~2024-06-10", +}; + +export const LightThemeDateFilterQuarterYearDropdown = { + render: Template, + + args: createDefaultArgs({ + parameterType: "date_quarter_year", + parameterValues: { + [DATE_FILTER_ID]: "Q1-2024", + }, + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Date Quarter and Year", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + + await userEvent.click( + popover.getAllByDisplayValue("2024").at(-1) as HTMLElement, + ); + const dropdown = getLastPopover(); + dropdown + .getByRole("option", { name: "2023" }) + .setAttribute("data-hovered", "true"); }, -}); -LightThemeDateFilterRange.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Date range", - }); - await userEvent.click(filter); +}; - const popover = getLastPopover(); - popover.getByText("15").classList.add("pseudo-hover"); +export const DarkThemeDateFilterQuarterYear = { + render: Template, + + args: createDefaultArgs({ + theme: "night", + parameterType: "date_quarter_year", + parameterValues: { + [DATE_FILTER_ID]: "Q1-2024", + }, + }), + + play: LightThemeDateFilterQuarterYear.play, }; -export const DarkThemeDateFilterRange = Template.bind({}); -DarkThemeDateFilterRange.args = createDefaultArgs({ - theme: "night", - parameterType: "date_range", - parameterValues: { - [DATE_FILTER_ID]: "2024-06-01~2024-06-10", - }, -}); -DarkThemeDateFilterRange.play = LightThemeDateFilterRange.play; - -// Relative -export const LightThemeDateFilterRelative = Template.bind({}); -LightThemeDateFilterRelative.args = createDefaultArgs({ - parameterType: "date_relative", - parameterValues: { - [DATE_FILTER_ID]: "thisday", +export const DarkThemeDateFilterQuarterYearDropdown = { + render: Template, + + args: createDefaultArgs({ + theme: "night", + parameterType: "date_quarter_year", + parameterValues: { + [DATE_FILTER_ID]: "Q1-2024", + }, + }), + + play: LightThemeDateFilterQuarterYearDropdown.play, +}; + +export const LightThemeDateFilterSingle = { + render: Template, + + args: createDefaultArgs({ + parameterType: "date_single", + parameterValues: { + [DATE_FILTER_ID]: "2024-06-01", + }, + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Date single", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + popover.getByText("15").classList.add("pseudo-hover"); }, -}); -LightThemeDateFilterRelative.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Date relative", - }); - await userEvent.click(filter); +}; + +export const DarkThemeDateFilterSingle = { + render: Template, + + args: createDefaultArgs({ + theme: "night", + parameterType: "date_single", + parameterValues: { + [DATE_FILTER_ID]: "2024-06-01", + }, + }), - const popover = getLastPopover(); - popover - .getByRole("button", { name: "Yesterday" }) - .classList.add("pseudo-hover"); + play: LightThemeDateFilterSingle.play, }; -export const DarkThemeDateFilterRelative = Template.bind({}); -DarkThemeDateFilterRelative.args = createDefaultArgs({ - theme: "night", - parameterType: "date_relative", - parameterValues: { - [DATE_FILTER_ID]: "thisday", +export const LightThemeDateFilterRange = { + render: Template, + + args: createDefaultArgs({ + parameterType: "date_range", + parameterValues: { + [DATE_FILTER_ID]: "2024-06-01~2024-06-10", + }, + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Date range", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + popover.getByText("15").classList.add("pseudo-hover"); }, -}); -DarkThemeDateFilterRelative.play = LightThemeDateFilterRelative.play; - -// Unit of time -export const LightThemeUnitOfTime = Template.bind({}); -LightThemeUnitOfTime.args = createDefaultArgs({ - parameterType: "temporal_unit", - parameterValues: { - [UNIT_OF_TIME_FILTER_ID]: "minute", +}; + +export const DarkThemeDateFilterRange = { + render: Template, + + args: createDefaultArgs({ + theme: "night", + parameterType: "date_range", + parameterValues: { + [DATE_FILTER_ID]: "2024-06-01~2024-06-10", + }, + }), + + play: LightThemeDateFilterRange.play, +}; + +export const LightThemeDateFilterRelative = { + render: Template, + + args: createDefaultArgs({ + parameterType: "date_relative", + parameterValues: { + [DATE_FILTER_ID]: "thisday", + }, + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Date relative", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + popover + .getByRole("button", { name: "Yesterday" }) + .classList.add("pseudo-hover"); }, -}); -LightThemeUnitOfTime.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Time grouping", - }); - await userEvent.click(filter); +}; - const popover = getLastPopover(); - (popover.getByText("Hour").parentNode as HTMLElement).classList.add( - "pseudo-hover", - ); +export const DarkThemeDateFilterRelative = { + render: Template, + + args: createDefaultArgs({ + theme: "night", + parameterType: "date_relative", + parameterValues: { + [DATE_FILTER_ID]: "thisday", + }, + }), + + play: LightThemeDateFilterRelative.play, }; -export const DarkThemeUnitOfTime = Template.bind({}); -DarkThemeUnitOfTime.args = createDefaultArgs({ - theme: "night", - parameterType: "temporal_unit", - parameterValues: { - [UNIT_OF_TIME_FILTER_ID]: "minute", +export const LightThemeUnitOfTime = { + render: Template, + + args: createDefaultArgs({ + parameterType: "temporal_unit", + parameterValues: { + [UNIT_OF_TIME_FILTER_ID]: "minute", + }, + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Time grouping", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + (popover.getByText("Hour").parentNode as HTMLElement).classList.add( + "pseudo-hover", + ); }, -}); -DarkThemeUnitOfTime.play = LightThemeUnitOfTime.play; +}; -// Number widget -export const LightThemeNumber = Template.bind({}); -LightThemeNumber.args = createDefaultArgs({ - parameterType: "number", -}); -LightThemeNumber.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - const filter = await canvas.findByRole("button", { - name: "Number Equals", - }); - await userEvent.click(filter); +export const DarkThemeUnitOfTime = { + render: Template, - const popover = getLastPopover(); - const searchInput = popover.getByPlaceholderText("Enter a number"); - await userEvent.type(searchInput, "11"); - await userEvent.click(getLastPopoverElement()); + args: createDefaultArgs({ + theme: "night", + parameterType: "temporal_unit", + parameterValues: { + [UNIT_OF_TIME_FILTER_ID]: "minute", + }, + }), - await userEvent.type(searchInput, "99"); + play: LightThemeUnitOfTime.play, }; -export const DarkThemeNumber = Template.bind({}); -DarkThemeNumber.args = createDefaultArgs({ - theme: "night", - parameterType: "number", -}); -DarkThemeNumber.play = LightThemeNumber.play; +export const LightThemeNumber = { + render: Template, + + args: createDefaultArgs({ + parameterType: "number", + }), + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const canvas = within(canvasElement); + const filter = await canvas.findByRole("button", { + name: "Number Equals", + }); + await userEvent.click(filter); + + const popover = getLastPopover(); + const searchInput = popover.getByPlaceholderText("Enter a number"); + await userEvent.type(searchInput, "11"); + await userEvent.click(getLastPopoverElement()); + + await userEvent.type(searchInput, "99"); + }, +}; + +export const DarkThemeNumber = { + render: Template, + + args: createDefaultArgs({ + theme: "night", + parameterType: "number", + }), + + play: LightThemeNumber.play, +}; diff --git a/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView.stories.tsx b/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView.stories.tsx index ff887636525aa6434024905a68f1053532ec19be..e137ccf391f345dafabf73267dc9fff4353f51db 100644 --- a/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView.stories.tsx +++ b/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView.stories.tsx @@ -1,6 +1,6 @@ // @ts-expect-error There is no type definition import createAsyncCallback from "@loki/create-async-callback"; -import type { ComponentStory, Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { type ComponentProps, useEffect } from "react"; import { Provider } from "react-redux"; @@ -32,7 +32,10 @@ import { createMockState, } from "metabase-types/store/mocks"; -import { PublicOrEmbeddedDashboardView } from "./PublicOrEmbeddedDashboardView"; +import { + PublicOrEmbeddedDashboardView, + type PublicOrEmbeddedDashboardViewProps, +} from "./PublicOrEmbeddedDashboardView"; export default { title: "embed/PublicOrEmbeddedDashboardView", @@ -48,7 +51,7 @@ export default { }, }; -function ReduxDecorator(Story: Story) { +function ReduxDecorator(Story: StoryFn) { return ( <Provider store={store}> <Story /> @@ -56,7 +59,7 @@ function ReduxDecorator(Story: Story) { ); } -function FasterExplicitSizeUpdateDecorator(Story: Story) { +function FasterExplicitSizeUpdateDecorator(Story: StoryFn) { return ( <waitTimeContext.Provider value={0}> <Story /> @@ -70,7 +73,7 @@ function FasterExplicitSizeUpdateDecorator(Story: Story) { * make sure we finish resizing any ExplicitSize components the fastest. */ const TIME_UNTIL_ALL_ELEMENTS_STOP_RESIZING = 1000; -function WaitForResizeToStopDecorator(Story: Story) { +function WaitForResizeToStopDecorator(Story: StoryFn) { const asyncCallback = createAsyncCallback(); useEffect(() => { setTimeout(asyncCallback, TIME_UNTIL_ALL_ELEMENTS_STOP_RESIZING); @@ -84,7 +87,7 @@ declare global { overrideIsWithinIframe?: boolean; } } -function MockIsEmbeddingDecorator(Story: Story) { +function MockIsEmbeddingDecorator(Story: StoryFn) { window.overrideIsWithinIframe = true; return <Story />; } @@ -167,7 +170,7 @@ function createDashboard({ hasScroll, dashcards }: CreateDashboardOpts = {}) { }); } -const Template: ComponentStory<typeof PublicOrEmbeddedDashboardView> = args => { +const Template: StoryFn<PublicOrEmbeddedDashboardViewProps> = args => { return <PublicOrEmbeddedDashboardView {...args} />; }; @@ -188,102 +191,138 @@ const defaultArgs: Partial< ], }; -// Light theme -export const LightThemeDefault = Template.bind({}); -LightThemeDefault.args = defaultArgs; +export const LightThemeDefault = { + render: Template, + args: defaultArgs, +}; + +export const LightThemeScroll = { + render: Template, -export const LightThemeScroll = Template.bind({}); -LightThemeScroll.args = { - ...defaultArgs, - dashboard: createDashboard({ hasScroll: true }), + args: { + ...defaultArgs, + dashboard: createDashboard({ hasScroll: true }), + }, + + decorators: [ScrollDecorator], }; -LightThemeScroll.decorators = [ScrollDecorator]; -export const LightThemeNoBackgroundDefault = Template.bind({}); -LightThemeNoBackgroundDefault.args = { - ...defaultArgs, - background: false, +export const LightThemeNoBackgroundDefault = { + render: Template, + + args: { + ...defaultArgs, + background: false, + }, }; -export const LightThemeNoBackgroundScroll = Template.bind({}); -LightThemeNoBackgroundScroll.args = { - ...defaultArgs, - background: false, - dashboard: createDashboard({ hasScroll: true }), +export const LightThemeNoBackgroundScroll = { + render: Template, + + args: { + ...defaultArgs, + background: false, + dashboard: createDashboard({ hasScroll: true }), + }, + + decorators: [ScrollDecorator], }; -LightThemeNoBackgroundScroll.decorators = [ScrollDecorator]; -// Dark theme -export const DarkThemeDefault = Template.bind({}); -DarkThemeDefault.args = { - ...defaultArgs, - theme: "night", +export const DarkThemeDefault = { + render: Template, + + args: { + ...defaultArgs, + theme: "night", + }, + + decorators: [DarkBackgroundDecorator], }; -DarkThemeDefault.decorators = [DarkBackgroundDecorator]; -export const DarkThemeScroll = Template.bind({}); -DarkThemeScroll.args = { - ...defaultArgs, - theme: "night", - dashboard: createDashboard({ hasScroll: true }), +export const DarkThemeScroll = { + render: Template, + + args: { + ...defaultArgs, + theme: "night", + dashboard: createDashboard({ hasScroll: true }), + }, + + decorators: [DarkBackgroundDecorator, ScrollDecorator], }; -DarkThemeScroll.decorators = [DarkBackgroundDecorator, ScrollDecorator]; -export const DarkThemeNoBackgroundDefault = Template.bind({}); -DarkThemeNoBackgroundDefault.args = { - ...defaultArgs, - theme: "night", - background: false, +export const DarkThemeNoBackgroundDefault = { + render: Template, + + args: { + ...defaultArgs, + theme: "night", + background: false, + }, + + decorators: [DarkBackgroundDecorator], }; -DarkThemeNoBackgroundDefault.decorators = [DarkBackgroundDecorator]; - -export const DarkThemeNoBackgroundScroll = Template.bind({}); -DarkThemeNoBackgroundScroll.args = { - ...defaultArgs, - theme: "night", - background: false, - dashboard: createDashboard({ hasScroll: true }), + +export const DarkThemeNoBackgroundScroll = { + render: Template, + + args: { + ...defaultArgs, + theme: "night", + background: false, + dashboard: createDashboard({ hasScroll: true }), + }, + + decorators: [DarkBackgroundDecorator, ScrollDecorator], }; -DarkThemeNoBackgroundScroll.decorators = [ - DarkBackgroundDecorator, - ScrollDecorator, -]; - -// Transparent theme -export const TransparentThemeDefault = Template.bind({}); -TransparentThemeDefault.args = { - ...defaultArgs, - theme: "transparent", + +export const TransparentThemeDefault = { + render: Template, + + args: { + ...defaultArgs, + theme: "transparent", + }, + + decorators: [LightBackgroundDecorator], }; -TransparentThemeDefault.decorators = [LightBackgroundDecorator]; -export const TransparentThemeScroll = Template.bind({}); -TransparentThemeScroll.args = { - ...defaultArgs, - theme: "transparent", - dashboard: createDashboard({ hasScroll: true }), +export const TransparentThemeScroll = { + render: Template, + + args: { + ...defaultArgs, + theme: "transparent", + dashboard: createDashboard({ hasScroll: true }), + }, + + decorators: [LightBackgroundDecorator, ScrollDecorator], }; -TransparentThemeScroll.decorators = [LightBackgroundDecorator, ScrollDecorator]; -export const TransparentThemeNoBackgroundDefault = Template.bind({}); -TransparentThemeNoBackgroundDefault.args = { - ...defaultArgs, - theme: "transparent", - background: false, +export const TransparentThemeNoBackgroundDefault = { + render: Template, + + args: { + ...defaultArgs, + theme: "transparent", + background: false, + }, + + decorators: [LightBackgroundDecorator], }; -TransparentThemeNoBackgroundDefault.decorators = [LightBackgroundDecorator]; - -export const TransparentThemeNoBackgroundScroll = Template.bind({}); -TransparentThemeNoBackgroundScroll.args = { - ...defaultArgs, - theme: "transparent", - background: false, - dashboard: createDashboard({ hasScroll: true }), + +export const TransparentThemeNoBackgroundScroll = { + render: Template, + + args: { + ...defaultArgs, + theme: "transparent", + background: false, + dashboard: createDashboard({ hasScroll: true }), + }, + + decorators: [LightBackgroundDecorator, ScrollDecorator], }; -TransparentThemeNoBackgroundScroll.decorators = [ - LightBackgroundDecorator, - ScrollDecorator, -]; // Other components compatibility test export function ComponentCompatibility() { @@ -367,34 +406,40 @@ export function ComponentCompatibility() { // @ts-expect-error: incompatible prop types with registerVisualization registerVisualization(ObjectDetail); -export const CardVisualizationsLightTheme = Template.bind({}); -CardVisualizationsLightTheme.args = { - ...defaultArgs, - dashboard: createDashboard({ - dashcards: [ - createMockDashboardCard({ - id: DASHCARD_TABLE_ID, - dashboard_tab_id: TAB_ID, - card: createMockCard({ - id: CARD_TABLE_ID, - name: "Table detail", - display: "object", +export const CardVisualizationsLightTheme = { + render: Template, + + args: { + ...defaultArgs, + dashboard: createDashboard({ + dashcards: [ + createMockDashboardCard({ + id: DASHCARD_TABLE_ID, + dashboard_tab_id: TAB_ID, + card: createMockCard({ + id: CARD_TABLE_ID, + name: "Table detail", + display: "object", + }), + size_x: 12, + size_y: 8, }), - size_x: 12, - size_y: 8, - }), - ], - }), + ], + }), + }, }; -export const CardVisualizationsDarkTheme = Template.bind({}); -CardVisualizationsDarkTheme.args = { - ...CardVisualizationsLightTheme.args, - theme: "night", +export const CardVisualizationsDarkTheme = { + render: Template, + + args: { + ...CardVisualizationsLightTheme.args, + theme: "night", + }, }; const EXPLICIT_SIZE_WAIT_TIME = 300; -function ScrollDecorator(Story: Story) { +function ScrollDecorator(Story: StoryFn) { useEffect(() => { setTimeout(() => { document.querySelector("[data-testid=embed-frame]")?.scrollBy(0, 9999); @@ -403,7 +448,7 @@ function ScrollDecorator(Story: Story) { return <Story />; } -function DarkBackgroundDecorator(Story: Story) { +function DarkBackgroundDecorator(Story: StoryFn) { return ( <Box bg="#434e56" mih="100vh"> <Story /> @@ -411,7 +456,7 @@ function DarkBackgroundDecorator(Story: Story) { ); } -function LightBackgroundDecorator(Story: Story) { +function LightBackgroundDecorator(Story: StoryFn) { return ( <Box bg="#ddd" mih="100vh"> <Story /> diff --git a/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView.tsx b/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView.tsx index 14b5583398afe33acfa69ab4fc2cad7d795d109a..ae806e7250938c61580640ba6e3450275154dc7f 100644 --- a/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView.tsx +++ b/frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard/PublicOrEmbeddedDashboardView.tsx @@ -42,7 +42,7 @@ import { EmbedFrame } from "../../components/EmbedFrame"; import { DashboardContainer } from "./PublicOrEmbeddedDashboard.styled"; -interface PublicOrEmbeddedDashboardViewProps { +interface InnerPublicOrEmbeddedDashboardViewProps { dashboard: Dashboard | null; selectedTabId: SelectedTabId; parameters: UiParameter[]; @@ -68,6 +68,12 @@ interface PublicOrEmbeddedDashboardViewProps { downloadsEnabled: boolean; } +export type PublicOrEmbeddedDashboardViewProps = + InnerPublicOrEmbeddedDashboardViewProps & + DashboardRefreshPeriodControls & + DashboardNightModeControls & + DashboardFullscreenControls; + export function PublicOrEmbeddedDashboardView({ dashboard, hasNightModeToggle, @@ -94,7 +100,7 @@ export function PublicOrEmbeddedDashboardView({ slowCards, cardTitled, downloadsEnabled, -}: PublicOrEmbeddedDashboardViewProps & +}: InnerPublicOrEmbeddedDashboardViewProps & DashboardRefreshPeriodControls & DashboardNightModeControls & DashboardFullscreenControls) { diff --git a/frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion/PublicOrEmbeddedQuestionView.stories.tsx b/frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion/PublicOrEmbeddedQuestionView.stories.tsx index 2b8faa1608d4cdd0ad03277d1b95f1dbe26e8bbe..64f5e170f7e601da99724b0c7efc7f6e96f68e07 100644 --- a/frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion/PublicOrEmbeddedQuestionView.stories.tsx +++ b/frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion/PublicOrEmbeddedQuestionView.stories.tsx @@ -1,6 +1,6 @@ // @ts-expect-error There is no type definition import createAsyncCallback from "@loki/create-async-callback"; -import type { ComponentStory, Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { userEvent, within } from "@storybook/testing-library"; import { type ComponentProps, useEffect } from "react"; import { Provider } from "react-redux"; @@ -30,7 +30,10 @@ import { createMockState, } from "metabase-types/store/mocks"; -import { PublicOrEmbeddedQuestionView } from "./PublicOrEmbeddedQuestionView"; +import { + PublicOrEmbeddedQuestionView, + type PublicOrEmbeddedQuestionViewProps, +} from "./PublicOrEmbeddedQuestionView"; // @ts-expect-error: incompatible prop types with registerVisualization registerVisualization(PivotTable); @@ -49,7 +52,7 @@ export default { }, }; -function ReduxDecorator(Story: Story) { +function ReduxDecorator(Story: StoryFn) { return ( <Provider store={store}> <Story /> @@ -57,7 +60,7 @@ function ReduxDecorator(Story: Story) { ); } -function FasterExplicitSizeUpdateDecorator(Story: Story) { +function FasterExplicitSizeUpdateDecorator(Story: StoryFn) { return ( <waitTimeContext.Provider value={0}> <Story /> @@ -71,7 +74,7 @@ function FasterExplicitSizeUpdateDecorator(Story: Story) { * make sure we finish resizing any ExplicitSize components the fastest. */ const TIME_UNTIL_ALL_ELEMENTS_STOP_RESIZING = 1000; -function WaitForResizeToStopDecorator(Story: Story) { +function WaitForResizeToStopDecorator(Story: StoryFn) { const asyncCallback = createAsyncCallback(); useEffect(() => { setTimeout(asyncCallback, TIME_UNTIL_ALL_ELEMENTS_STOP_RESIZING); @@ -85,7 +88,7 @@ declare global { overrideIsWithinIframe?: boolean; } } -function MockIsEmbeddingDecorator(Story: Story) { +function MockIsEmbeddingDecorator(Story: StoryFn) { window.overrideIsWithinIframe = true; return <Story />; } @@ -99,7 +102,7 @@ const initialState = createMockState({ const store = getStore(publicReducers, initialState); -const Template: ComponentStory<typeof PublicOrEmbeddedQuestionView> = args => { +const Template: StoryFn<PublicOrEmbeddedQuestionViewProps> = args => { return <PublicOrEmbeddedQuestionView {...args} />; }; @@ -126,56 +129,76 @@ const defaultArgs: Partial< }), }; -// Light theme -export const LightThemeDefault = Template.bind({}); -LightThemeDefault.args = defaultArgs; - -export const LightThemeDefaultNoResults = Template.bind({}); -LightThemeDefaultNoResults.args = { - ...defaultArgs, - result: createMockDataset(), +export const LightThemeDefault = { + render: Template, + args: defaultArgs, }; -export const LightThemeDownload = Template.bind({}); -LightThemeDownload.args = { - ...LightThemeDefault.args, - downloadsEnabled: true, +export const LightThemeDefaultNoResults = { + render: Template, + + args: { + ...defaultArgs, + result: createMockDataset(), + }, }; -LightThemeDownload.play = async ({ canvasElement }) => { - const asyncCallback = createAsyncCallback(); - await downloadQuestionAsPng(canvasElement, asyncCallback); + +export const LightThemeDownload = { + render: Template, + + args: { + ...LightThemeDefault.args, + downloadsEnabled: true, + }, + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const asyncCallback = createAsyncCallback(); + await downloadQuestionAsPng(canvasElement, asyncCallback); + }, }; -// Dark theme -export const DarkThemeDefault = Template.bind({}); -DarkThemeDefault.args = { - ...defaultArgs, - theme: "night", +export const DarkThemeDefault = { + render: Template, + + args: { + ...defaultArgs, + theme: "night", + }, }; -export const DarkThemeDefaultNoResults = Template.bind({}); -DarkThemeDefaultNoResults.args = { - ...defaultArgs, - theme: "night", - result: createMockDataset(), +export const DarkThemeDefaultNoResults = { + render: Template, + + args: { + ...defaultArgs, + theme: "night", + result: createMockDataset(), + }, }; -export const DarkThemeDownload = Template.bind({}); -DarkThemeDownload.args = { - ...DarkThemeDefault.args, - downloadsEnabled: true, +export const DarkThemeDownload = { + render: Template, + + args: { + ...DarkThemeDefault.args, + downloadsEnabled: true, + }, + + play: LightThemeDownload.play, }; -DarkThemeDownload.play = LightThemeDownload.play; -// Transparent theme -export const TransparentThemeDefault = Template.bind({}); -TransparentThemeDefault.args = { - ...defaultArgs, - theme: "transparent", +export const TransparentThemeDefault = { + render: Template, + + args: { + ...defaultArgs, + theme: "transparent", + }, + + decorators: [LightBackgroundDecorator], }; -TransparentThemeDefault.decorators = [LightBackgroundDecorator]; -function LightBackgroundDecorator(Story: Story) { +function LightBackgroundDecorator(Story: StoryFn) { return ( <Box bg="#ddd" h="100%"> <Story /> @@ -183,142 +206,155 @@ function LightBackgroundDecorator(Story: Story) { ); } -// Pivot table +export const PivotTableLightTheme = { + render: Template, -// Light theme -export const PivotTableLightTheme = Template.bind({}); -PivotTableLightTheme.args = { - ...defaultArgs, - card: createMockCard({ - id: getNextId(), - display: "pivot", - visualization_settings: PIVOT_TABLE_MOCK_DATA.settings, - }), - result: createMockDataset({ - data: createMockDatasetData({ - cols: PIVOT_TABLE_MOCK_DATA.cols, - rows: PIVOT_TABLE_MOCK_DATA.rows, + args: { + ...defaultArgs, + card: createMockCard({ + id: getNextId(), + display: "pivot", + visualization_settings: PIVOT_TABLE_MOCK_DATA.settings, }), - }), -}; -PivotTableLightTheme.play = async ({ canvasElement }) => { - const cell = await within(canvasElement).findByText("field-123"); - (cell.parentNode?.parentNode as HTMLElement).classList.add("pseudo-hover"); + result: createMockDataset({ + data: createMockDatasetData({ + cols: PIVOT_TABLE_MOCK_DATA.cols, + rows: PIVOT_TABLE_MOCK_DATA.rows, + }), + }), + }, + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const cell = await within(canvasElement).findByText("field-123"); + (cell.parentNode?.parentNode as HTMLElement).classList.add("pseudo-hover"); + }, }; -// Dark theme -export const PivotTableDarkTheme = Template.bind({}); -PivotTableDarkTheme.args = { - ...PivotTableLightTheme.args, - theme: "night", +export const PivotTableDarkTheme = { + render: Template, + + args: { + ...PivotTableLightTheme.args, + theme: "night", + }, + + play: PivotTableLightTheme.play, }; -PivotTableDarkTheme.play = PivotTableLightTheme.play; - -// Smart scalar - -// Light theme -export const SmartScalarLightTheme = Template.bind({}); -SmartScalarLightTheme.args = { - ...defaultArgs, - card: createMockCard({ - id: getNextId(), - display: "smartscalar", - visualization_settings: { - "graph.dimensions": ["timestamp"], - "graph.metrics": ["count"], - }, - }), - result: createMockDataset({ - data: createMockDatasetData({ - cols: [ - createMockColumn(DateTimeColumn({ name: "Timestamp" })), - createMockColumn(NumberColumn({ name: "Count" })), - ], - insights: [ - { - "previous-value": 150, - unit: "week", - offset: -199100, - "last-change": 0.4666666666666667, - col: "count", - slope: 10, - "last-value": 220, - "best-fit": ["+", -199100, ["*", 10, "x"]], - }, - ], - rows: [ - ["2024-07-21T00:00:00Z", 150], - ["2024-07-28T00:00:00Z", 220], - ], + +export const SmartScalarLightTheme = { + render: Template, + + args: { + ...defaultArgs, + card: createMockCard({ + id: getNextId(), + display: "smartscalar", + visualization_settings: { + "graph.dimensions": ["timestamp"], + "graph.metrics": ["count"], + }, }), - }), + result: createMockDataset({ + data: createMockDatasetData({ + cols: [ + createMockColumn(DateTimeColumn({ name: "Timestamp" })), + createMockColumn(NumberColumn({ name: "Count" })), + ], + insights: [ + { + "previous-value": 150, + unit: "week", + offset: -199100, + "last-change": 0.4666666666666667, + col: "count", + slope: 10, + "last-value": 220, + "best-fit": ["+", -199100, ["*", 10, "x"]], + }, + ], + rows: [ + ["2024-07-21T00:00:00Z", 150], + ["2024-07-28T00:00:00Z", 220], + ], + }), + }), + }, }; -// Dark theme -export const SmartScalarDarkTheme = Template.bind({}); -SmartScalarDarkTheme.args = { - ...SmartScalarLightTheme.args, - theme: "night", +export const SmartScalarDarkTheme = { + render: Template, + + args: { + ...SmartScalarLightTheme.args, + theme: "night", + }, }; -// Light theme tooltip -export const SmartScalarLightThemeTooltip = Template.bind({}); -SmartScalarLightThemeTooltip.args = { - ...defaultArgs, - card: createMockCard({ - id: getNextId(), - display: "smartscalar", - visualization_settings: { - "graph.dimensions": ["timestamp"], - "graph.metrics": ["count"], - }, - }), - result: createMockDataset({ - data: createMockDatasetData({ - cols: [ - createMockColumn(DateTimeColumn({ name: "Timestamp" })), - createMockColumn(NumberColumn({ name: "Count" })), - ], - insights: [ - { - "previous-value": 150, - unit: "week", - offset: -199100, - "last-change": 0.4666666666666667, - col: "count", - slope: 10, - "last-value": 220, - "best-fit": ["+", -199100, ["*", 10, "x"]], - }, - ], - rows: [ - ["2024-07-21T00:00:00Z", 150], - ["2024-07-28T00:00:00Z", 220], - ], +export const SmartScalarLightThemeTooltip = { + render: Template, + + args: { + ...defaultArgs, + card: createMockCard({ + id: getNextId(), + display: "smartscalar", + visualization_settings: { + "graph.dimensions": ["timestamp"], + "graph.metrics": ["count"], + }, }), - }), -}; -SmartScalarLightThemeTooltip.decorators = [NarrowContainer]; -SmartScalarLightThemeTooltip.play = async ({ canvasElement }) => { - const value = "vs. July 21, 2024, 12:00 AM"; - const valueElement = await within(canvasElement).findByText(value); - await userEvent.hover(valueElement); - const tooltip = document.documentElement.querySelector( - '[role="tooltip"]', - ) as HTMLElement; - await within(tooltip).findByText(`${value}:`); + result: createMockDataset({ + data: createMockDatasetData({ + cols: [ + createMockColumn(DateTimeColumn({ name: "Timestamp" })), + createMockColumn(NumberColumn({ name: "Count" })), + ], + insights: [ + { + "previous-value": 150, + unit: "week", + offset: -199100, + "last-change": 0.4666666666666667, + col: "count", + slope: 10, + "last-value": 220, + "best-fit": ["+", -199100, ["*", 10, "x"]], + }, + ], + rows: [ + ["2024-07-21T00:00:00Z", 150], + ["2024-07-28T00:00:00Z", 220], + ], + }), + }), + }, + + decorators: [NarrowContainer], + + play: async ({ canvasElement }: { canvasElement: HTMLCanvasElement }) => { + const value = "vs. July 21, 2024, 12:00 AM"; + const valueElement = await within(canvasElement).findByText(value); + await userEvent.hover(valueElement); + const tooltip = document.documentElement.querySelector( + '[role="tooltip"]', + ) as HTMLElement; + await within(tooltip).findByText(`${value}:`); + }, }; -// Dark theme tooltip -export const SmartScalarDarkThemeTooltip = Template.bind({}); -SmartScalarDarkThemeTooltip.args = { - ...SmartScalarLightThemeTooltip.args, - theme: "night", +export const SmartScalarDarkThemeTooltip = { + render: Template, + + args: { + ...SmartScalarLightThemeTooltip.args, + theme: "night", + }, + + decorators: [NarrowContainer], + play: SmartScalarLightThemeTooltip.play, }; -SmartScalarDarkThemeTooltip.decorators = [NarrowContainer]; -SmartScalarDarkThemeTooltip.play = SmartScalarLightThemeTooltip.play; -function NarrowContainer(Story: Story) { +function NarrowContainer(Story: StoryFn) { return ( <Box w="300px" h="250px" pos="relative"> <Story /> diff --git a/frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion/PublicOrEmbeddedQuestionView.tsx b/frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion/PublicOrEmbeddedQuestionView.tsx index ace0b04f6660bc6c3d40292a138306864843c47a..413a51dcee4a0cbc0a8283dbe921f54caba74de3 100644 --- a/frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion/PublicOrEmbeddedQuestionView.tsx +++ b/frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion/PublicOrEmbeddedQuestionView.tsx @@ -21,7 +21,7 @@ import type { VisualizationSettings, } from "metabase-types/api"; -interface PublicOrEmbeddedQuestionViewProps { +export interface PublicOrEmbeddedQuestionViewProps { initialized: boolean; card: Card<DatasetQuery> | null; metadata: Metadata; diff --git a/frontend/src/metabase/pulse/components/WebhookChannelEdit.tsx b/frontend/src/metabase/pulse/components/WebhookChannelEdit.tsx index 710450a95a6513b85d1869fac07b53b359d17697..d1acbb5613ba06aca2ef6f93695a40e8ef66f83f 100644 --- a/frontend/src/metabase/pulse/components/WebhookChannelEdit.tsx +++ b/frontend/src/metabase/pulse/components/WebhookChannelEdit.tsx @@ -58,7 +58,7 @@ export const WebhookChannelEdit = ({ }) .unwrap() .then(() => { - setLabel(t`Succes`); + setLabel(t`Success!`); }) .catch(() => { setLabel(t`Something went wrong`); diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts index 05991eb5f5251368cede51c94f160c4848dc1eb7..a4ac122fa5f728eca1f4989d619a08e3e36b799c 100644 --- a/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts @@ -258,10 +258,17 @@ export function updateQueryWithCompareOffsetAggregations( }; } +const DISABLED_TEMPORAL_COMPARISON_MESSAGE = true; + export function canAddTemporalCompareAggregation( query: Lib.Query, stageIndex: number, ): boolean { + if (DISABLED_TEMPORAL_COMPARISON_MESSAGE) { + // TODO: reenable temporal comparisons once we fix offset issues + return false; + } + const aggregations = Lib.aggregations(query, stageIndex); if (aggregations.length === 0) { // Hide the "Compare to the past" option if there are no aggregations diff --git a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeList/ChartTypeList.tsx b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeList/ChartTypeList.tsx index 9e056b37fab2b0d33fa30af31d320450a0e1a194..fba34c9e6993c4fcb713c0bf6ddbf7e9355d9f25 100644 --- a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeList/ChartTypeList.tsx +++ b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeList/ChartTypeList.tsx @@ -1,9 +1,10 @@ -import { ChartTypeOption, type ChartTypeOptionProps } from "../ChartTypeOption"; +import { Grid } from "metabase/ui"; +import type { CardDisplayType } from "metabase-types/api"; -import { OptionList } from "./ChartTypeList.styled"; +import { ChartTypeOption, type ChartTypeOptionProps } from "../ChartTypeOption"; export type ChartTypeListProps = { - visualizationList: ChartTypeOptionProps["visualizationType"][]; + visualizationList: CardDisplayType[]; "data-testid"?: string; } & Pick< ChartTypeOptionProps, @@ -16,14 +17,21 @@ export const ChartTypeList = ({ selectedVisualization, "data-testid": dataTestId, }: ChartTypeListProps) => ( - <OptionList data-testid={dataTestId}> + <Grid + data-testid={dataTestId} + align="flex-start" + justify="flex-start" + grow={false} + > {visualizationList.map(type => ( - <ChartTypeOption - key={type} - visualizationType={type} - selectedVisualization={selectedVisualization} - onSelectVisualization={onSelectVisualization} - /> + <Grid.Col span={3} key={type} data-testid="chart-type-list-col"> + <ChartTypeOption + key={type} + visualizationType={type} + selectedVisualization={selectedVisualization} + onSelectVisualization={onSelectVisualization} + /> + </Grid.Col> ))} - </OptionList> + </Grid> ); diff --git a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.module.css b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.module.css new file mode 100644 index 0000000000000000000000000000000000000000..f4fa14d7f16473728c0701ac20c33f0b3cea3187 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.module.css @@ -0,0 +1,12 @@ +.BorderedButton { + border: var(--border-size) var(--border-style) var(--mb-color-border) !important; +} + +.SettingsButton { + opacity: 0; +} + +.VisualizationButton:hover + .SettingsButton, +.SettingsButton:hover { + opacity: 1; +} diff --git a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.tsx b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.tsx index 5a1ac14db8dbe9db58433ec57074eac5b9dbe7e8..e504820c2c39d4c7610e2cad143f2958e35f6be2 100644 --- a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.tsx +++ b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.tsx @@ -1,14 +1,11 @@ +import cx from "classnames"; + import { checkNotNull } from "metabase/lib/types"; -import { Icon } from "metabase/ui"; +import { ActionIcon, Center, Icon, Stack, Text } from "metabase/ui"; import visualizations from "metabase/visualizations"; import type { CardDisplayType } from "metabase-types/api"; -import { - OptionIconContainer, - OptionRoot, - OptionText, - SettingsButton, -} from "./ChartTypeOption.styled"; +import ChartTypeOptionS from "./ChartTypeOption.module.css"; export type ChartTypeOptionProps = { onSelectVisualization: (display: CardDisplayType) => void; @@ -23,30 +20,67 @@ export const ChartTypeOption = ({ }: ChartTypeOptionProps) => { const visualization = checkNotNull(visualizations.get(visualizationType)); const isSelected = selectedVisualization === visualizationType; + return ( - <OptionRoot - isSelected={isSelected} - data-testid={`${visualization.uiName}-container`} - role="option" - aria-selected={isSelected} - > - <OptionIconContainer - onClick={() => onSelectVisualization(visualizationType)} - data-testid={`${visualization.uiName}-button`} + <Center pos="relative" data-testid="chart-type-option"> + <Stack + align="center" + spacing="xs" + role="option" + aria-selected={isSelected} + data-testid={`${visualization.uiName}-container`} > - <Icon name={visualization.iconName} size={20} /> + <ActionIcon + w="3.125rem" + h="3.125rem" + radius="xl" + onClick={() => onSelectVisualization(visualizationType)} + color="brand" + data-is-selected={isSelected} + variant={isSelected ? "filled" : "outline"} + className={cx( + ChartTypeOptionS.BorderedButton, + ChartTypeOptionS.VisualizationButton, + )} + data-testid={`${visualization.uiName}-button`} + > + <Icon + name={visualization.iconName} + color={isSelected ? "white" : "brand"} + size={20} + /> + </ActionIcon> + {isSelected && ( - <SettingsButton - onlyIcon - icon="gear" - iconSize={16} + <ActionIcon + pos="absolute" + top="-0.5rem" + right="-0.6rem" + radius="xl" + color="text-light" + variant="viewHeader" + bg="white" + className={cx( + ChartTypeOptionS.BorderedButton, + ChartTypeOptionS.SettingsButton, + )} onClick={() => onSelectVisualization(visualizationType)} - /> + > + <Icon name="gear" size={16} /> + </ActionIcon> )} - </OptionIconContainer> - <OptionText data-testid="chart-type-option-label"> - {visualization.uiName} - </OptionText> - </OptionRoot> + + <Text + lh="unset" + align="center" + fw="bold" + fz="sm" + color={isSelected ? "brand" : "text-medium"} + data-testid="chart-type-option-label" + > + {visualization.uiName} + </Text> + </Stack> + </Center> ); }; diff --git a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.unit.spec.tsx b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.unit.spec.tsx index 0271f3e7b536935b909249c152f54c1037250cb4..2b748bdd5fd88bccd53848d67d546edc07d55c85 100644 --- a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.unit.spec.tsx +++ b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeOption/ChartTypeOption.unit.spec.tsx @@ -14,40 +14,45 @@ import { ChartTypeOption, type ChartTypeOptionProps } from "./ChartTypeOption"; registerVisualizations(); -const EXPECTED_VISUALIZATION_VALUES: Record< - CardDisplayType, +const EXPECTED_VISUALIZATION_VALUES: Array<{ + visualizationType: CardDisplayType; + displayName: string; + iconName: IconName | (string & unknown); +}> = [ + { visualizationType: "scalar", displayName: "Number", iconName: "number" }, { - key: CardDisplayType; - displayName: string; - iconName: IconName | (string & unknown); - } -> = { - scalar: { key: "scalar", displayName: "Number", iconName: "number" }, - smartscalar: { - key: "smartscalar", + visualizationType: "smartscalar", displayName: "Trend", iconName: "smartscalar", }, - progress: { key: "progress", displayName: "Progress", iconName: "progress" }, - gauge: { key: "gauge", displayName: "Gauge", iconName: "gauge" }, - table: { key: "table", displayName: "Table", iconName: "table2" }, - line: { key: "line", displayName: "Line", iconName: "line" }, - area: { key: "area", displayName: "Area", iconName: "area" }, - bar: { key: "bar", displayName: "Bar", iconName: "bar" }, - waterfall: { - key: "waterfall", + { + visualizationType: "progress", + displayName: "Progress", + iconName: "progress", + }, + { visualizationType: "gauge", displayName: "Gauge", iconName: "gauge" }, + { visualizationType: "table", displayName: "Table", iconName: "table2" }, + { visualizationType: "line", displayName: "Line", iconName: "line" }, + { visualizationType: "area", displayName: "Area", iconName: "area" }, + { visualizationType: "bar", displayName: "Bar", iconName: "bar" }, + { + visualizationType: "waterfall", displayName: "Waterfall", iconName: "waterfall", }, - combo: { key: "combo", displayName: "Combo", iconName: "lineandbar" }, - row: { key: "row", displayName: "Row", iconName: "horizontal_bar" }, - scatter: { key: "scatter", displayName: "Scatter", iconName: "bubble" }, - pie: { key: "pie", displayName: "Pie", iconName: "pie" }, - map: { key: "map", displayName: "Map", iconName: "pinmap" }, - funnel: { key: "funnel", displayName: "Funnel", iconName: "funnel" }, - object: { key: "object", displayName: "Detail", iconName: "document" }, - pivot: { key: "pivot", displayName: "Pivot Table", iconName: "pivot_table" }, -}; + { visualizationType: "combo", displayName: "Combo", iconName: "lineandbar" }, + { visualizationType: "row", displayName: "Row", iconName: "horizontal_bar" }, + { visualizationType: "scatter", displayName: "Scatter", iconName: "bubble" }, + { visualizationType: "pie", displayName: "Pie", iconName: "pie" }, + { visualizationType: "map", displayName: "Map", iconName: "pinmap" }, + { visualizationType: "funnel", displayName: "Funnel", iconName: "funnel" }, + { visualizationType: "object", displayName: "Detail", iconName: "document" }, + { + visualizationType: "pivot", + displayName: "Pivot Table", + iconName: "pivot_table", + }, +]; const setup = ({ selectedVisualization = "table", @@ -66,11 +71,12 @@ const setup = ({ }; describe("ChartTypeOption", () => { - Object.entries(EXPECTED_VISUALIZATION_VALUES).forEach( - ([key, { iconName, displayName }]) => { - it(`should display a label and icon for ${key} visualization type`, () => { + describe.each(EXPECTED_VISUALIZATION_VALUES)( + "display and click behavior for each visualization", + ({ visualizationType, displayName, iconName }) => { + it(`should display a label and icon for ${visualizationType} visualization type`, () => { setup({ - visualizationType: key as CardDisplayType, + visualizationType, }); expect( @@ -84,25 +90,24 @@ describe("ChartTypeOption", () => { it("should call 'onClick' when the button is clicked", async () => { const { onSelectVisualization } = setup({ - visualizationType: key as CardDisplayType, + visualizationType, }); await userEvent.click(screen.getByTestId(`${displayName}-button`)); - expect(onSelectVisualization).toHaveBeenCalledWith(key); + expect(onSelectVisualization).toHaveBeenCalledWith(visualizationType); }); - // TODO: include styles in the tests when component is migrated to Mantine it("should have aria-selected attribute if selectedVisualization=visualizationType", () => { setup({ - selectedVisualization: key as CardDisplayType, - visualizationType: key as CardDisplayType, + selectedVisualization: visualizationType, + visualizationType, }); - expect(screen.getByTestId(`${displayName}-container`)).toHaveAttribute( - "aria-selected", - "true", - ); + const displayContainer = screen.getByTestId(`${displayName}-container`); + expect(displayContainer).toHaveAttribute("aria-selected", "true"); + + expect(displayContainer).toHaveRole("option"); }); }, ); diff --git a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeSettings/ChartTypeSettings.tsx b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeSettings/ChartTypeSettings.tsx index 37383cfe631dcbfa5a76017e3d5dbf93c34efe78..2c0436df2bd6ae8453b07d86d28a438c676c554f 100644 --- a/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeSettings/ChartTypeSettings.tsx +++ b/frontend/src/metabase/query_builder/components/chart-type-selector/ChartTypeSettings/ChartTypeSettings.tsx @@ -1,11 +1,9 @@ import { t } from "ttag"; -import { Box } from "metabase/ui"; +import { Box, Space, Text } from "metabase/ui"; import { ChartTypeList, type ChartTypeListProps } from "../ChartTypeList"; -import { OptionLabel } from "./ChartTypeSettings.styled"; - export type ChartTypeSettingsProps = { sensibleVisualizations: ChartTypeListProps["visualizationList"]; nonSensibleVisualizations: ChartTypeListProps["visualizationList"]; @@ -24,7 +22,17 @@ export const ChartTypeSettings = ({ onSelectVisualization={onSelectVisualization} selectedVisualization={selectedVisualization} /> - <OptionLabel>{t`Other charts`}</OptionLabel> + + <Space h="xl" /> + + <Text + fw="bold" + color="text-medium" + tt="uppercase" + fz="sm" + >{t`Other charts`}</Text> + + <Space h="sm" /> <ChartTypeList data-testid="display-options-not-sensible" diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorHelpText/ExpressionEditorHelpText.stories.tsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorHelpText/ExpressionEditorHelpText.stories.tsx index 0ffc34ea22360860695bfdead4e2f95acd43b1b8..7875655cadb7434341246cab9e724ee30ca35320 100644 --- a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorHelpText/ExpressionEditorHelpText.stories.tsx +++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorHelpText/ExpressionEditorHelpText.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { useRef } from "react"; import { createMockMetadata } from "__support__/metadata"; @@ -14,7 +14,7 @@ export default { component: ExpressionEditorHelpText, }; -const Template: ComponentStory<typeof ExpressionEditorHelpText> = () => { +const Template: StoryFn<typeof ExpressionEditorHelpText> = () => { const target = useRef(null); const database = createMockDatabase(); const metadata = createMockMetadata({ databases: [database] }); diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar/ChartTypeSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar/ChartTypeSidebar.tsx index 9e854b83fa02834cb28df382819b364e5c7b1220..797fa363ccbeade36bc57a13297800d16a331401 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar/ChartTypeSidebar.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar/ChartTypeSidebar.tsx @@ -17,6 +17,7 @@ import { type UseChartTypeVisualizationsProps, useChartTypeVisualizations, } from "metabase/query_builder/components/chart-type-selector"; +import { Stack } from "metabase/ui"; import * as Lib from "metabase-lib"; import type Question from "metabase-lib/v1/Question"; import type { CardDisplayType } from "metabase-types/api"; @@ -72,12 +73,14 @@ export const ChartTypeSidebar = ({ onDone={() => dispatch(onCloseChartType())} data-testid="chart-type-sidebar" > - <ChartTypeSettings - selectedVisualization={selectedVisualization} - onSelectVisualization={handleSelectVisualization} - sensibleVisualizations={sensibleVisualizations} - nonSensibleVisualizations={nonSensibleVisualizations} - /> + <Stack spacing={0} m="lg"> + <ChartTypeSettings + selectedVisualization={selectedVisualization} + onSelectVisualization={handleSelectVisualization} + sensibleVisualizations={sensibleVisualizations} + nonSensibleVisualizations={nonSensibleVisualizations} + /> + </Stack> </SidebarContent> ); }; diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionDetails.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionDetails.tsx index 5cd10c0ea1db6ebc6ec81908dc9e4b7e7320a0b6..bebb4f6b6b065451d56e97cf4889cbb8f287587b 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionDetails.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionDetails.tsx @@ -26,11 +26,24 @@ export const QuestionDetails = ({ question }: { question: Question }) => { return ( <> <SidesheetCardSection title={t`Creator and last editor`}> + <Flex gap="sm" align="top"> + <Icon name="ai" className={SidebarStyles.IconMargin} /> + <Text> + {c( + "Describes when a question was created. {0} is a date/time and {1} is a person's name", + ).jt`${( + <DateTime unit="day" value={createdAt} key="date" /> + )} by ${getUserName(createdBy)}`} + </Text> + </Flex> + {lastEditInfo && ( <Flex gap="sm" align="top"> - <Icon name="ai" className={SidebarStyles.IconMargin} /> + <Icon name="pencil" className={SidebarStyles.IconMargin} /> <Text> - {c("{0} is a date/time and {1} is a person's name").jt`${( + {c( + "Describes when a question was last edited. {0} is a date/time and {1} is a person's name", + ).jt`${( <DateTime unit="day" value={lastEditInfo.timestamp} @@ -40,15 +53,6 @@ export const QuestionDetails = ({ question }: { question: Question }) => { </Text> </Flex> )} - - <Flex gap="sm" align="top"> - <Icon name="pencil" className={SidebarStyles.IconMargin} /> - <Text> - {c("{0} is a date/time and {1} is a person's name").jt`${( - <DateTime unit="day" value={createdAt} key="date" /> - )} by ${getUserName(createdBy)}`} - </Text> - </Flex> </SidesheetCardSection> <SidesheetCardSection title={t`Saved in`}> <Flex gap="sm" align="top" color="var(--mb-color-brand)"> diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.module.css b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.module.css index 3341dec5cbdc46c2a17931db08714ba19e2bbda2..66ff822fdb52cbe493d5cc4c9fcb0d4be3e747ac 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.module.css +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.module.css @@ -2,6 +2,7 @@ max-height: 19rem; overflow: auto; line-height: 1.38rem; /* magic number to keep line-height from changing in edit mode */ + margin-left: -4px; /* to visually align the inner text with the heading */ } .BrandCircle { diff --git a/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.unit.spec.tsx index e4bd561cb6d1f0687525b27aa8ce42c7fdc985c6..474e3198c93a05b3d99a0bd42b22e4e43f1aae20 100644 --- a/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.unit.spec.tsx @@ -179,7 +179,9 @@ describe("AggregateStep", () => { expect(queryIcon("add")).not.toBeInTheDocument(); }); - it("should not allow to use temporal comparisons for metrics", async () => { + // TODO: unskip this once we enable "Compare to the past" again + // eslint-disable-next-line jest/no-disabled-tests + it.skip("should not allow to use temporal comparisons for metrics", async () => { const query = createQueryWithClauses({ aggregations: [{ operatorName: "count" }], }); @@ -192,7 +194,9 @@ describe("AggregateStep", () => { expect(screen.queryByText(/compare/i)).not.toBeInTheDocument(); }); - it("should allow to use temporal comparisons for non-metrics", async () => { + // TODO: unskip this once we enable "Compare to the past" again + // eslint-disable-next-line jest/no-disabled-tests + it.skip("should allow to use temporal comparisons for non-metrics", async () => { const query = createQueryWithClauses({ aggregations: [{ operatorName: "count" }], }); diff --git a/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.unit.spec.tsx index 1558bbc630654b06b64f3b444431a81bbd010342..6258b4db6c06f0e39c0cd70a72785de31cc42b86 100644 --- a/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.unit.spec.tsx @@ -30,6 +30,7 @@ import { import { DEFAULT_QUESTION, createMockNotebookStep } from "../../test-utils"; import type { NotebookStep } from "../../types"; +import { NotebookProvider } from "../Notebook/context"; import { DataStep } from "./DataStep"; @@ -78,16 +79,18 @@ const setup = ({ }); renderWithProviders( - <DataStep - step={step} - query={step.query} - stageIndex={step.stageIndex} - readOnly={readOnly} - color="brand" - isLastOpened={false} - reportTimezone="UTC" - updateQuery={updateQuery} - />, + <NotebookProvider> + <DataStep + step={step} + query={step.query} + stageIndex={step.stageIndex} + readOnly={readOnly} + color="brand" + isLastOpened={false} + reportTimezone="UTC" + updateQuery={updateQuery} + /> + </NotebookProvider>, { storeInitialState }, ); diff --git a/frontend/src/metabase/querying/notebook/components/JoinStep/JoinStep.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/JoinStep/JoinStep.unit.spec.tsx index 98912ff854a0beca09df2c4715464255e51d1287..4378b5b87d109cdbb511ac4c03368197edb89152 100644 --- a/frontend/src/metabase/querying/notebook/components/JoinStep/JoinStep.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/JoinStep/JoinStep.unit.spec.tsx @@ -37,6 +37,7 @@ import { createMockState } from "metabase-types/store/mocks"; import { createMockNotebookStep } from "../../test-utils"; import type { NotebookStep } from "../../types"; +import { NotebookProvider } from "../Notebook/context"; import { JoinStep } from "./JoinStep"; @@ -187,16 +188,18 @@ function setup({ }; return ( - <JoinStep - step={step} - stageIndex={step.stageIndex} - query={query} - color="brand" - isLastOpened={false} - readOnly={readOnly} - reportTimezone="UTC" - updateQuery={onChange} - /> + <NotebookProvider> + <JoinStep + step={step} + stageIndex={step.stageIndex} + query={query} + color="brand" + isLastOpened={false} + readOnly={readOnly} + reportTimezone="UTC" + updateQuery={onChange} + /> + </NotebookProvider> ); } diff --git a/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.tsx b/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.tsx index a044175e05bcf48ad2f81e6c6c74ea702b01e90e..b00cdee14b888165b8355ce3c3e9e701f3e3ab37 100644 --- a/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.tsx +++ b/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.tsx @@ -1,5 +1,6 @@ import { t } from "ttag"; +import type { DataPickerValue } from "metabase/common/components/DataPicker"; import { useDispatch } from "metabase/lib/redux"; import { setUIControls } from "metabase/query_builder/actions"; import { Box, Button } from "metabase/ui"; @@ -8,6 +9,8 @@ import type Question from "metabase-lib/v1/Question"; import { NotebookStepList } from "../NotebookStepList"; +import { NotebookProvider } from "./context"; + export type NotebookProps = { question: Question; isDirty: boolean; @@ -19,6 +22,7 @@ export type NotebookProps = { runQuestionQuery: () => Promise<void>; setQueryBuilderMode?: (mode: string) => void; readOnly?: boolean; + modelsFilterList?: DataPickerValue["model"][]; }; export const Notebook = ({ @@ -32,6 +36,7 @@ export const Notebook = ({ hasVisualizeButton = true, runQuestionQuery, setQueryBuilderMode, + modelsFilterList, }: NotebookProps) => { const dispatch = useDispatch(); @@ -68,18 +73,24 @@ export const Notebook = ({ }; return ( - <Box pos="relative" p={{ base: "1rem", sm: "2rem" }}> - <NotebookStepList - updateQuestion={handleUpdateQuestion} - question={question} - reportTimezone={reportTimezone} - readOnly={readOnly} - /> - {hasVisualizeButton && isRunnable && ( - <Button variant="filled" style={{ minWidth: 220 }} onClick={visualize}> - {t`Visualize`} - </Button> - )} - </Box> + <NotebookProvider modelsFilterList={modelsFilterList}> + <Box pos="relative" p={{ base: "1rem", sm: "2rem" }}> + <NotebookStepList + updateQuestion={handleUpdateQuestion} + question={question} + reportTimezone={reportTimezone} + readOnly={readOnly} + /> + {hasVisualizeButton && isRunnable && ( + <Button + variant="filled" + style={{ minWidth: 220 }} + onClick={visualize} + > + {t`Visualize`} + </Button> + )} + </Box> + </NotebookProvider> ); }; diff --git a/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.unit.spec.tsx index 4810e9b5a9a049eab9c2193b638d07f681c237f4..66b2135c7bb11b6111cc1fb32dcb18ff84b531d1 100644 --- a/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/Notebook/Notebook.unit.spec.tsx @@ -1,24 +1,138 @@ -import { renderWithProviders, screen, within } from "__support__/ui"; +import userEvent from "@testing-library/user-event"; + +import { + setupCollectionByIdEndpoint, + setupCollectionItemsEndpoint, + setupCollectionsEndpoints, + setupDatabasesEndpoints, + setupRecentViewsAndSelectionsEndpoints, + setupSearchEndpoints, +} from "__support__/server-mocks"; +import { + mockGetBoundingClientRect, + mockScrollBy, + renderWithProviders, + screen, + waitForLoaderToBeRemoved, + within, +} from "__support__/ui"; +import type { RecentMetric } from "metabase/browse/metrics"; +import { + createMockMetricResult, + createMockRecentMetric, +} from "metabase/browse/metrics/test-utils"; +import type { RecentModel } from "metabase/browse/models"; +import { + createMockModelResult, + createMockRecentModel, +} from "metabase/browse/models/test-utils"; +import type { DataPickerValue } from "metabase/common/components/DataPicker"; +import { checkNotNull } from "metabase/lib/types"; +import type { IconName } from "metabase/ui"; import { SAMPLE_METADATA, createQueryWithClauses, } from "metabase-lib/test-helpers"; import Question from "metabase-lib/v1/Question"; -import type { CardType } from "metabase-types/api"; -import { createMockCard } from "metabase-types/api/mocks"; - -import { Notebook } from "./Notebook"; - -type SetupOpts = { - question: Question; - reportTimezone?: string; - readOnly?: boolean; - isRunnable?: boolean; - isDirty?: boolean; - isResultDirty?: boolean; - hasVisualizeButton?: boolean; +import type { + CardType, + CollectionItemModel, + RecentItem, +} from "metabase-types/api"; +import { + createMockCard, + createMockCollection, + createMockCollectionItem, + createMockRecentCollectionItem, + createMockRecentTableItem, +} from "metabase-types/api/mocks"; +import { createSampleDatabase } from "metabase-types/api/mocks/presets"; + +import { Notebook, type NotebookProps } from "./Notebook"; + +type SetupOpts = Pick<NotebookProps, "question"> & + Partial< + Pick< + NotebookProps, + | "reportTimezone" + | "readOnly" + | "isRunnable" + | "isDirty" + | "isResultDirty" + | "hasVisualizeButton" + | "modelsFilterList" + > + > & { + hasRecents?: boolean; + }; + +const MOCK_DATABASE = createSampleDatabase(); +const TEST_COLLECTION = createMockCollection({ id: "root" }); + +const TEST_RECENT_TABLE = createMockRecentTableItem(); +const TEST_RECENT_METRIC = createMockRecentMetric( + createMockMetricResult({ + collection: TEST_COLLECTION, + }) as unknown as RecentMetric, +); +const TEST_RECENT_MODEL = createMockRecentModel( + createMockModelResult({ + collection: TEST_COLLECTION, + }) as unknown as RecentModel, +); + +const TEST_RECENT_CARD = createMockRecentCollectionItem({ + model: "card", + name: "Card", + parent_collection: TEST_COLLECTION, +}); + +const dataPickerValueMap: Record< + DataPickerValue["model"], + { + tabIcon: IconName; + tabDisplayName: string; + recentItem: RecentItem; + itemPickerData: string[]; + pickerColIdx?: number; + } +> = { + table: { + tabIcon: "table", + tabDisplayName: "Tables", + recentItem: TEST_RECENT_TABLE, + itemPickerData: checkNotNull(MOCK_DATABASE.tables).map( + table => table.display_name, + ), + pickerColIdx: 2, // tables are always level 2 in the data picker + }, + card: { + tabIcon: "folder", + tabDisplayName: "Saved questions", + recentItem: TEST_RECENT_CARD, + itemPickerData: ["card"], + }, + dataset: { + tabIcon: "model", + tabDisplayName: "Models", + recentItem: TEST_RECENT_MODEL, + itemPickerData: ["dataset"], + }, + metric: { + tabIcon: "metric", + tabDisplayName: "Metrics", + recentItem: TEST_RECENT_METRIC, + itemPickerData: ["metric"], + }, }; +const TEST_ENTITY_TYPES: DataPickerValue["model"][] = [ + "table", + "metric", + "card", + "dataset", +] as const; + function setup({ question, reportTimezone = "UTC", @@ -27,7 +141,47 @@ function setup({ isDirty = false, isResultDirty = false, hasVisualizeButton = false, + modelsFilterList = undefined, + hasRecents = true, }: SetupOpts) { + mockScrollBy(); + + setupDatabasesEndpoints([MOCK_DATABASE]); + setupRecentViewsAndSelectionsEndpoints( + hasRecents + ? [ + TEST_RECENT_TABLE, + TEST_RECENT_METRIC, + TEST_RECENT_MODEL, + TEST_RECENT_CARD, + ] + : [], + ["selections"], + ); + + const collectionItems = TEST_ENTITY_TYPES.map(entityType => + createMockCollectionItem({ + model: entityType as CollectionItemModel, + collection: TEST_COLLECTION, + collection_id: TEST_COLLECTION.id, + name: entityType, + }), + ); + + setupSearchEndpoints(collectionItems); + setupCollectionsEndpoints({ collections: [TEST_COLLECTION] }); + setupCollectionByIdEndpoint({ collections: [TEST_COLLECTION] }); + setupCollectionItemsEndpoint({ + collection: TEST_COLLECTION, + collectionItems, + }); + setupCollectionItemsEndpoint({ + collection: { ...TEST_COLLECTION, id: 1 }, + collectionItems, + }); + + mockGetBoundingClientRect(); + const updateQuestion = jest.fn(); const runQuestionQuery = jest.fn(); const setQueryBuilderMode = jest.fn(); @@ -44,6 +198,7 @@ function setup({ updateQuestion={updateQuestion} runQuestionQuery={runQuestionQuery} setQueryBuilderMode={setQueryBuilderMode} + modelsFilterList={modelsFilterList} />, ); @@ -117,4 +272,196 @@ describe("Notebook", () => { within(step).queryByLabelText("Remove step"), ).not.toBeInTheDocument(); }); + + describe("when filtering with modelsFilterList", () => { + describe("tab behavior", () => { + it("should not show tabs if only no type is chosen and recents are populated", async () => { + setup({ + question: createSummarizedQuestion("question"), + modelsFilterList: [], + }); + + await goToEntityModal(); + + expect( + await screen.findByTestId("single-picker-view"), + ).toBeInTheDocument(); + }); + + it("should not show tabs if only one type is chosen and recents are not populated", async () => { + setup({ + question: createSummarizedQuestion("question"), + modelsFilterList: ["table"], + hasRecents: false, + }); + + await goToEntityModal(); + + expect( + await screen.findByTestId("single-picker-view"), + ).toBeInTheDocument(); + + assertDataInPickerColumn({ + columnIndex: Number(dataPickerValueMap["table"].pickerColIdx), + data: dataPickerValueMap["table"].itemPickerData, + }); + }); + + it("should show tabs if more than one type is chosen", async () => { + const models: DataPickerValue["model"][] = ["dataset", "card"]; + + setup({ + question: createSummarizedQuestion("question"), + modelsFilterList: models, + hasRecents: false, + }); + + await goToEntityModal(); + + expect(await screen.findByTestId("tabs-view")).toBeInTheDocument(); + + for (const model of models) { + const { + tabDisplayName, + tabIcon, + pickerColIdx = 1, + itemPickerData, + } = dataPickerValueMap[model]; + + await goToDataPickerTab({ + name: tabDisplayName, + iconName: tabIcon, + }); + + await userEvent.click(screen.getByText("Our analytics")); + + assertDataInPickerColumn({ + columnIndex: pickerColIdx, + data: itemPickerData, + }); + } + }); + + it("should show all tabs if no filter is selected", async () => { + setup({ + question: createSummarizedQuestion("question"), + }); + + await goToEntityModal(); + + expect(await screen.findByTestId("tabs-view")).toBeInTheDocument(); + + for (const model of TEST_ENTITY_TYPES) { + const { tabDisplayName, tabIcon } = dataPickerValueMap[model]; + + await goToDataPickerTab({ + name: tabDisplayName, + iconName: tabIcon, + }); + } + }); + }); + + describe.each<DataPickerValue["model"]>(TEST_ENTITY_TYPES)( + "when filtering with %s", + entityType => { + // eslint-disable-next-line jest/expect-expect + it(`should only show the ${entityType} picker when modelsFilterList=[${entityType}]`, async () => { + setup({ + question: createSummarizedQuestion("question"), + modelsFilterList: [entityType], + }); + + const { + pickerColIdx = 1, + tabDisplayName, + tabIcon, + recentItem, + itemPickerData, + } = dataPickerValueMap[entityType]; + + await goToEntityModal(); + + await goToDataPickerTab({ name: tabDisplayName, iconName: tabIcon }); + + if (entityType !== "table") { + // nested items so we want to go to the next nesting + await userEvent.click(screen.getByText("Our analytics")); + } + + assertDataInPickerColumn({ + columnIndex: pickerColIdx, + data: itemPickerData, + }); + + await goToDataPickerTab({ name: "Recents", iconName: "clock" }); + + assertDataInRecents({ + data: [ + "display_name" in recentItem + ? recentItem.display_name + : recentItem.name, + ], + }); + }); + }, + ); + }); }); + +const goToEntityModal = async () => { + await userEvent.click(screen.getByText("Orders")); + + expect(screen.getByTestId("entity-picker-modal")).toBeInTheDocument(); + + await waitForLoaderToBeRemoved(); + await waitForLoaderToBeRemoved(); +}; + +const goToDataPickerTab = async ({ + name, + iconName, +}: { + name: string; + iconName: IconName; +}) => { + const tabsView = within(screen.getByTestId("tabs-view")); + + const tabButton = tabsView.getByRole("tab", { + name: `${iconName} icon ${name}`, + }); + + expect( + within(tabButton).getByLabelText(`${iconName} icon`), + ).toBeInTheDocument(); + + await userEvent.click(tabsView.getByText(name)); + + expect(tabButton).toHaveAttribute("data-active", "true"); +}; + +const assertDataInPickerColumn = ({ + columnIndex, + data, +}: { + columnIndex: number; + data: string[]; +}) => { + const pickerItems = within( + screen.getByTestId(`item-picker-level-${columnIndex}`), + ).getAllByTestId("picker-item"); + + // Need to figure out this one for the nested items + for (let i = 0; i < data.length; i++) { + expect(within(pickerItems[i]).getByText(data[i])).toBeInTheDocument(); + } +}; + +const assertDataInRecents = ({ data }: { data: string[] }) => { + const pickerItems = screen.getAllByTestId("result-item"); + + // Need to figure out this one for the nested items + for (let i = 0; i < data.length; i++) { + expect(within(pickerItems[i]).getByText(data[i])).toBeInTheDocument(); + } +}; diff --git a/frontend/src/metabase/querying/notebook/components/Notebook/context.tsx b/frontend/src/metabase/querying/notebook/components/Notebook/context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f5ceaabd3d93e404624ff52befd77e50d7a4c9b9 --- /dev/null +++ b/frontend/src/metabase/querying/notebook/components/Notebook/context.tsx @@ -0,0 +1,32 @@ +import { type PropsWithChildren, createContext, useContext } from "react"; + +import type { DataPickerValue } from "metabase/common/components/DataPicker"; + +type NotebookContextType = { + modelsFilterList: DataPickerValue["model"][]; +}; + +export const NotebookContext = createContext<NotebookContextType | undefined>( + undefined, +); + +export const NotebookProvider = ({ + modelsFilterList = ["table", "card", "dataset", "metric"], + children, +}: PropsWithChildren<Partial<NotebookContextType>>) => { + return ( + <NotebookContext.Provider value={{ modelsFilterList }}> + {children} + </NotebookContext.Provider> + ); +}; + +export const useNotebookContext = () => { + const context = useContext(NotebookContext); + if (context === undefined) { + throw new Error( + "useNotebookContext must be used within a NotebookProvider", + ); + } + return context; +}; diff --git a/frontend/src/metabase/querying/notebook/components/NotebookDataPicker/NotebookDataPicker.tsx b/frontend/src/metabase/querying/notebook/components/NotebookDataPicker/NotebookDataPicker.tsx index a130d0f235713e0acb7b8817f01941b8c391b70b..8500a3980dd998c970333f25075f83b914fce2b8 100644 --- a/frontend/src/metabase/querying/notebook/components/NotebookDataPicker/NotebookDataPicker.tsx +++ b/frontend/src/metabase/querying/notebook/components/NotebookDataPicker/NotebookDataPicker.tsx @@ -18,6 +18,7 @@ import { Group, Icon, Tooltip, UnstyledButton } from "metabase/ui"; import * as Lib from "metabase-lib"; import type { DatabaseId, TableId } from "metabase-types/api"; +import { useNotebookContext } from "../Notebook/context"; import { NotebookCell } from "../NotebookCell"; import { getUrl } from "./utils"; @@ -44,10 +45,15 @@ export function NotebookDataPicker({ table, databaseId, placeholder = title, - hasMetrics, + hasMetrics = false, isDisabled, onChange, }: NotebookDataPickerProps) { + const { modelsFilterList } = useNotebookContext(); + const filterList = hasMetrics + ? modelsFilterList + : modelsFilterList.filter(model => model !== "metric"); + const [isOpen, setIsOpen] = useState(!table); const store = useStore(); const dispatch = useDispatch(); @@ -133,12 +139,7 @@ export function NotebookDataPicker({ title={title} value={tableValue} databaseId={databaseId} - models={[ - "table", - "card", - "dataset", - ...(hasMetrics ? ["metric" as const] : []), - ]} + models={filterList} onChange={handleChange} onClose={() => setIsOpen(false)} /> diff --git a/frontend/src/metabase/querying/notebook/components/NotebookStep/NotebookStep.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/NotebookStep/NotebookStep.unit.spec.tsx index b64336b7e8fdcb2debefc872112822dfd6da38c9..c33723546e87fc0ca1e2da340c21ca22f8979cae 100644 --- a/frontend/src/metabase/querying/notebook/components/NotebookStep/NotebookStep.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/NotebookStep/NotebookStep.unit.spec.tsx @@ -14,6 +14,7 @@ import type { NotebookStep as INotebookStep, NotebookStepType, } from "../../types"; +import { NotebookProvider } from "../Notebook/context"; import { NotebookStep } from "./NotebookStep"; @@ -31,14 +32,16 @@ function setup({ step = createMockNotebookStep() }: SetupOpts = {}) { setupRecentViewsAndSelectionsEndpoints([], ["selections"]); renderWithProviders( - <NotebookStep - step={step} - isLastStep={false} - isLastOpened={false} - reportTimezone="Europe/London" - openStep={openStep} - updateQuery={updateQuery} - />, + <NotebookProvider> + <NotebookStep + step={step} + isLastStep={false} + isLastOpened={false} + reportTimezone="Europe/London" + openStep={openStep} + updateQuery={updateQuery} + /> + </NotebookProvider>, ); return { diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 383d6822af8be6422c697cbb5e9f7ddfa0c8c824..0f133b7f557a6306a919948229cb14e5a5231fc1 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -9,6 +9,13 @@ import { ForgotPassword } from "metabase/auth/components/ForgotPassword"; import { Login } from "metabase/auth/components/Login"; import { Logout } from "metabase/auth/components/Logout"; import { ResetPassword } from "metabase/auth/components/ResetPassword"; +import { + BrowseDatabases, + BrowseMetrics, + BrowseModels, + BrowseSchemas, + BrowseTables, +} from "metabase/browse"; import CollectionLanding from "metabase/collections/components/CollectionLanding"; import { MoveCollectionModal } from "metabase/collections/components/MoveCollectionModal"; import { TrashCollectionLanding } from "metabase/collections/components/TrashCollectionLanding"; @@ -50,11 +57,6 @@ import SearchApp from "metabase/search/containers/SearchApp"; import { Setup } from "metabase/setup/components/Setup"; import getCollectionTimelineRoutes from "metabase/timelines/collections/routes"; -import { BrowseDatabases } from "./browse/components/BrowseDatabases"; -import { BrowseMetrics } from "./browse/components/BrowseMetrics"; -import { BrowseModels } from "./browse/components/BrowseModels"; -import BrowseSchemas from "./browse/components/BrowseSchemas"; -import { BrowseTables } from "./browse/components/BrowseTables"; import { CanAccessMetabot, CanAccessSettings, diff --git a/frontend/src/metabase/setup/components/UserForm/UserForm.tsx b/frontend/src/metabase/setup/components/UserForm/UserForm.tsx index 246141a6011326bf228c330cea48259c6c44dbad..85f7db74786f65bfcc37977619f1cead276bdc09 100644 --- a/frontend/src/metabase/setup/components/UserForm/UserForm.tsx +++ b/frontend/src/metabase/setup/components/UserForm/UserForm.tsx @@ -32,12 +32,14 @@ const USER_SCHEMA = Yup.object({ interface UserFormProps { user?: UserInfo; + isHosted: boolean; onValidatePassword: (password: string) => Promise<string | undefined>; onSubmit: (user: UserInfo) => Promise<void>; } export const UserForm = ({ user, + isHosted, onValidatePassword, onSubmit, }: UserFormProps) => { @@ -66,7 +68,7 @@ export const UserForm = ({ title={t`First name`} placeholder={t`Johnny`} nullable - autoFocus + autoFocus={!isHosted} /> <FormInput name="last_name" @@ -91,6 +93,10 @@ export const UserForm = ({ type="password" title={t`Create a password`} placeholder={t`Shhh...`} + // Hosted instances always pass user information in the URLSearchParams + // during the initial setup. Password is the first empty field + // so it makes sense to focus on it. + autoFocus={isHosted && initialValues.site_name !== ""} /> <FormInput name="password_confirm" diff --git a/frontend/src/metabase/setup/components/UserStep/UserStep.tsx b/frontend/src/metabase/setup/components/UserStep/UserStep.tsx index 8cf836f2554646007abd4db5cca14c4f9734ac7b..b2d533e70ff1354c67c4358717056d81d1581ef1 100644 --- a/frontend/src/metabase/setup/components/UserStep/UserStep.tsx +++ b/frontend/src/metabase/setup/components/UserStep/UserStep.tsx @@ -16,6 +16,7 @@ import { StepDescription } from "./UserStep.styled"; export const UserStep = ({ stepLabel }: NumberedStepProps): JSX.Element => { const { isStepActive, isStepCompleted } = useStep("user_info"); + const user = useSelector(getUser); const isHosted = useSelector(getIsHosted); @@ -45,6 +46,7 @@ export const UserStep = ({ stepLabel }: NumberedStepProps): JSX.Element => { )} <UserForm user={user} + isHosted={isHosted} onValidatePassword={validatePassword} onSubmit={handleSubmit} /> diff --git a/frontend/src/metabase/setup/components/UserStep/UserStep.unit.spec.tsx b/frontend/src/metabase/setup/components/UserStep/UserStep.unit.spec.tsx index d41ee9a6300b52e259c9c07af4fae0a18fb60774..6cff805c133dd3ebb625901115bce096d2ef6e63 100644 --- a/frontend/src/metabase/setup/components/UserStep/UserStep.unit.spec.tsx +++ b/frontend/src/metabase/setup/components/UserStep/UserStep.unit.spec.tsx @@ -1,3 +1,4 @@ +import { mockSettings } from "__support__/settings"; import { renderWithProviders, screen } from "__support__/ui"; import type { SetupStep } from "metabase/setup/types"; import type { UserInfo } from "metabase-types/store"; @@ -12,10 +13,16 @@ import { UserStep } from "./UserStep"; interface SetupOpts { step?: SetupStep; user?: UserInfo; + isHosted?: boolean; } -const setup = ({ step = "user_info", user }: SetupOpts = {}) => { +const setup = ({ + step = "user_info", + user, + isHosted = false, +}: SetupOpts = {}) => { const state = createMockState({ + settings: mockSettings({ "is-hosted?": isHosted }), setup: createMockSetupState({ step, user, @@ -32,6 +39,30 @@ describe("UserStep", () => { expect(screen.getByText("What should we call you?")).toBeInTheDocument(); }); + it("should autofocus the first name input field", () => { + setup({ step: "user_info" }); + + expect(screen.getByLabelText("First name")).toHaveFocus(); + }); + + it("should autofocus the password input field for hosted instances", () => { + const user = createMockUserInfo(); + setup({ step: "user_info", isHosted: true, user }); + + expect(screen.getByLabelText("Create a password")).toHaveFocus(); + }); + + it("should pre-fill the user information if provided", () => { + const user = createMockUserInfo(); + setup({ step: "user_info", user }); + + Object.values(user) + .filter(v => v.length > 0) + .forEach(v => { + expect(screen.getByDisplayValue(v)).toBeInTheDocument(); + }); + }); + it("should render in completed state", () => { setup({ step: "db_connection", diff --git a/frontend/src/metabase/setup/reducers.ts b/frontend/src/metabase/setup/reducers.ts index dbfdec7c91ce38234a9a71cffc1cfa97fe8d1f4a..469d50ec0e75ed710db35d360b55b0a7fc9836c8 100644 --- a/frontend/src/metabase/setup/reducers.ts +++ b/frontend/src/metabase/setup/reducers.ts @@ -17,15 +17,33 @@ import { updateTracking, } from "./actions"; +const getUserFromQueryParams = () => { + const params = new URLSearchParams(window.location.search); + const getParam = (key: string, defaultValue = "") => + params.get(key) || defaultValue; + + return { + first_name: getParam("first_name") || null, + last_name: getParam("last_name") || null, + email: getParam("email"), + site_name: getParam("site_name"), + password: "", + password_confirm: "", + }; +}; + const initialState: SetupState = { step: "welcome", isLocaleLoaded: false, isTrackingAllowed: true, + user: getUserFromQueryParams(), }; export const reducer = createReducer(initialState, builder => { builder.addCase(loadUserDefaults.fulfilled, (state, { payload: user }) => { - state.user = user; + if (user) { + state.user = user; + } }); builder.addCase( loadLocaleDefaults.fulfilled, diff --git a/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx b/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx index da54504a02a346db7ce952ba5e3a36020c54855c..36915b7a09bfa90edbf66fc47fc4bba7326ee677 100644 --- a/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; import { formatStaticValue } from "metabase/static-viz/lib/format"; @@ -9,6 +9,8 @@ import { import { DEFAULT_VISUALIZATION_THEME } from "metabase/visualizations/shared/utils/theme"; import type { RenderingContext } from "metabase/visualizations/types"; +import type { StaticChartProps } from "../StaticVisualization"; + import { ComboChart } from "./ComboChart"; import { data } from "./stories-data"; @@ -17,7 +19,7 @@ export default { component: ComboChart, }; -const Template: ComponentStory<typeof ComboChart> = args => { +const Template: StoryFn<StaticChartProps> = args => { return ( <div style={{ border: "1px solid black", display: "inline-block" }}> <ComboChart {...args} isStorybook /> @@ -35,717 +37,1066 @@ const renderingContext: RenderingContext = { theme: DEFAULT_VISUALIZATION_THEME, }; -export const LineLinearXScale = Template.bind({}); -LineLinearXScale.args = { - rawSeries: data.lineLinearXScale as any, - renderingContext, +export const LineLinearXScale = { + render: Template, + + args: { + rawSeries: data.lineLinearXScale as any, + renderingContext, + }, }; -export const LineLinearXScaleUnsorted = Template.bind({}); -LineLinearXScaleUnsorted.args = { - rawSeries: data.lineLinearXScaleUnsorted as any, - renderingContext, +export const LineLinearXScaleUnsorted = { + render: Template, + + args: { + rawSeries: data.lineLinearXScaleUnsorted as any, + renderingContext, + }, }; -export const LogYScaleCustomYAxisRange = Template.bind({}); -LogYScaleCustomYAxisRange.args = { - rawSeries: data.logYScaleCustomYAxisRange as any, - renderingContext, +export const LogYScaleCustomYAxisRange = { + render: Template, + + args: { + rawSeries: data.logYScaleCustomYAxisRange as any, + renderingContext, + }, }; -export const PowYScaleCustomYAxisRange = Template.bind({}); -PowYScaleCustomYAxisRange.args = { - rawSeries: data.powYScaleCustomYAxisRange as any, - renderingContext, +export const PowYScaleCustomYAxisRange = { + render: Template, + + args: { + rawSeries: data.powYScaleCustomYAxisRange as any, + renderingContext, + }, }; -export const LineLogYScale = Template.bind({}); -LineLogYScale.args = { - rawSeries: data.lineLogYScale as any, - renderingContext, +export const LineLogYScale = { + render: Template, + + args: { + rawSeries: data.lineLogYScale as any, + renderingContext, + }, }; -export const GoalLineLogYScale = Template.bind({}); -GoalLineLogYScale.args = { - rawSeries: data.goalLineLogYScale as any, - renderingContext, +export const GoalLineLogYScale = { + render: Template, + + args: { + rawSeries: data.goalLineLogYScale as any, + renderingContext, + }, }; -export const GoalLinePowYScale = Template.bind({}); -GoalLinePowYScale.args = { - rawSeries: data.goalLinePowYScale as any, - renderingContext, +export const GoalLinePowYScale = { + render: Template, + + args: { + rawSeries: data.goalLinePowYScale as any, + renderingContext, + }, }; -export const LineLogYScaleNegative = Template.bind({}); -LineLogYScaleNegative.args = { - rawSeries: data.lineLogYScaleNegative as any, - renderingContext, +export const LineLogYScaleNegative = { + render: Template, + + args: { + rawSeries: data.lineLogYScaleNegative as any, + renderingContext, + }, }; -export const LineShowDotsAuto = Template.bind({}); -LineShowDotsAuto.args = { - rawSeries: data.lineShowDotsAuto as any, - renderingContext, +export const LineShowDotsAuto = { + render: Template, + + args: { + rawSeries: data.lineShowDotsAuto as any, + renderingContext, + }, }; -export const LineShowDotsOn = Template.bind({}); -LineShowDotsOn.args = { - rawSeries: data.lineShowDotsOn as any, - renderingContext, +export const LineShowDotsOn = { + render: Template, + + args: { + rawSeries: data.lineShowDotsOn as any, + renderingContext, + }, }; -export const LineShowDotsOff = Template.bind({}); -LineShowDotsOff.args = { - rawSeries: data.lineShowDotsOff as any, - renderingContext, +export const LineShowDotsOff = { + render: Template, + + args: { + rawSeries: data.lineShowDotsOff as any, + renderingContext, + }, }; -export const LineCustomYAxisRangeEqualsExtents = Template.bind({}); -LineCustomYAxisRangeEqualsExtents.args = { - rawSeries: data.lineCustomYAxisRangeEqualsExtents as any, - renderingContext, +export const LineCustomYAxisRangeEqualsExtents = { + render: Template, + + args: { + rawSeries: data.lineCustomYAxisRangeEqualsExtents as any, + renderingContext, + }, }; -export const CustomYAxisRangeWithColumnScaling = Template.bind({}); -CustomYAxisRangeWithColumnScaling.args = { - rawSeries: data.customYAxisRangeWithColumnScaling as any, - renderingContext, +export const CustomYAxisRangeWithColumnScaling = { + render: Template, + + args: { + rawSeries: data.customYAxisRangeWithColumnScaling as any, + renderingContext, + }, }; -export const LineFullyNullDimension37902 = Template.bind({}); -LineFullyNullDimension37902.args = { - rawSeries: data.lineFullyNullDimension37902 as any, - renderingContext, +export const LineFullyNullDimension37902 = { + render: Template, + + args: { + rawSeries: data.lineFullyNullDimension37902 as any, + renderingContext, + }, }; -export const AreaFullyNullDimension37902 = Template.bind({}); -AreaFullyNullDimension37902.args = { - rawSeries: data.areaFullyNullDimension37902 as any, - renderingContext, +export const AreaFullyNullDimension37902 = { + render: Template, + + args: { + rawSeries: data.areaFullyNullDimension37902 as any, + renderingContext, + }, }; -export const BarLinearXScale = Template.bind({}); -BarLinearXScale.args = { - rawSeries: data.barLinearXScale as any, - renderingContext, +export const BarLinearXScale = { + render: Template, + + args: { + rawSeries: data.barLinearXScale as any, + renderingContext, + }, }; -export const BarHistogramXScale = Template.bind({}); -BarHistogramXScale.args = { - rawSeries: data.barHistogramXScale as any, - renderingContext, +export const BarHistogramXScale = { + render: Template, + + args: { + rawSeries: data.barHistogramXScale as any, + renderingContext, + }, }; -export const BarHistogramMultiSeries = Template.bind({}); -BarHistogramMultiSeries.args = { - rawSeries: data.barHistogramMultiSeries as any, - renderingContext, +export const BarHistogramMultiSeries = { + render: Template, + + args: { + rawSeries: data.barHistogramMultiSeries as any, + renderingContext, + }, }; -export const BarHistogramMultiSeriesBinned = Template.bind({}); -BarHistogramMultiSeriesBinned.args = { - rawSeries: data.barHistogramMultiSeriesBinned as any, - renderingContext, +export const BarHistogramMultiSeriesBinned = { + render: Template, + + args: { + rawSeries: data.barHistogramMultiSeriesBinned as any, + renderingContext, + }, }; -export const BarHistogramSeriesBreakout = Template.bind({}); -BarHistogramSeriesBreakout.args = { - rawSeries: data.barHistogramSeriesBreakout as any, - renderingContext, +export const BarHistogramSeriesBreakout = { + render: Template, + + args: { + rawSeries: data.barHistogramSeriesBreakout as any, + renderingContext, + }, }; -export const BarHistogramStacked = Template.bind({}); -BarHistogramStacked.args = { - rawSeries: data.barHistogramStacked as any, - renderingContext, +export const BarHistogramStacked = { + render: Template, + + args: { + rawSeries: data.barHistogramStacked as any, + renderingContext, + }, }; -export const BarHistogramStackedNormalized = Template.bind({}); -BarHistogramStackedNormalized.args = { - rawSeries: data.barHistogramStackedNormalized as any, - renderingContext, +export const BarHistogramStackedNormalized = { + render: Template, + + args: { + rawSeries: data.barHistogramStackedNormalized as any, + renderingContext, + }, }; -export const BarHistogramUnaggregatedDimension = Template.bind({}); -BarHistogramUnaggregatedDimension.args = { - rawSeries: data.barHistogramUnaggregatedDimension as any, - renderingContext, +export const BarHistogramUnaggregatedDimension = { + render: Template, + + args: { + rawSeries: data.barHistogramUnaggregatedDimension as any, + renderingContext, + }, }; -export const BarOrdinalXScale = Template.bind({}); -BarOrdinalXScale.args = { - rawSeries: data.barOrdinalXScale as any, - renderingContext, +export const BarOrdinalXScale = { + render: Template, + + args: { + rawSeries: data.barOrdinalXScale as any, + renderingContext, + }, }; -export const BarOrdinalXScaleAutoRotatedLabels = Template.bind({}); -BarOrdinalXScaleAutoRotatedLabels.args = { - rawSeries: data.barOrdinalXScaleAutoRotatedLabels as any, - renderingContext, +export const BarOrdinalXScaleAutoRotatedLabels = { + render: Template, + + args: { + rawSeries: data.barOrdinalXScaleAutoRotatedLabels as any, + renderingContext, + }, }; -export const BarStackedTotalFormattedValues = Template.bind({}); -BarStackedTotalFormattedValues.args = { - rawSeries: data.barStackedTotalFormattedValues as any, - renderingContext, +export const BarStackedTotalFormattedValues = { + render: Template, + + args: { + rawSeries: data.barStackedTotalFormattedValues as any, + renderingContext, + }, }; -export const BarStackedPowYAxis = Template.bind({}); -BarStackedPowYAxis.args = { - rawSeries: data.barStackedPowYAxis as any, - renderingContext, +export const BarStackedPowYAxis = { + render: Template, + + args: { + rawSeries: data.barStackedPowYAxis as any, + renderingContext, + }, }; -export const BarStackedPowYAxisNegatives = Template.bind({}); -BarStackedPowYAxisNegatives.args = { - rawSeries: data.barStackedPowYAxisNegatives as any, - renderingContext, +export const BarStackedPowYAxisNegatives = { + render: Template, + + args: { + rawSeries: data.barStackedPowYAxisNegatives as any, + renderingContext, + }, }; -export const YAxisCompactWithoutDataLabels = Template.bind({}); -YAxisCompactWithoutDataLabels.args = { - rawSeries: data.yAxisCompactWithoutDataLabels as any, - renderingContext, +export const YAxisCompactWithoutDataLabels = { + render: Template, + + args: { + rawSeries: data.yAxisCompactWithoutDataLabels as any, + renderingContext, + }, }; -export const BarFormattingFull = Template.bind({}); -BarFormattingFull.args = { - rawSeries: data.barFormattingFull as any, - renderingContext, +export const BarFormattingFull = { + render: Template, + + args: { + rawSeries: data.barFormattingFull as any, + renderingContext, + }, }; -export const BarAutoFormattingCompact = Template.bind({}); -BarAutoFormattingCompact.args = { - rawSeries: data.barAutoFormattingCompact as any, - renderingContext, +export const BarAutoFormattingCompact = { + render: Template, + + args: { + rawSeries: data.barAutoFormattingCompact as any, + renderingContext, + }, }; -export const BarAutoFormattingFull = Template.bind({}); -BarAutoFormattingFull.args = { - rawSeries: data.barAutoFormattingFull as any, - renderingContext, - getColor: color, -} as any; +export const BarAutoFormattingFull = { + render: Template, -export const BarLogYScaleStacked = Template.bind({}); -BarLogYScaleStacked.args = { - rawSeries: data.barLogYScaleStacked as any, - renderingContext, + args: { + rawSeries: data.barAutoFormattingFull as any, + renderingContext, + getColor: color, + } as any, }; -export const BarLogYScaleStackedNegative = Template.bind({}); -BarLogYScaleStackedNegative.args = { - rawSeries: data.barLogYScaleStackedNegative as any, - renderingContext, +export const BarLogYScaleStacked = { + render: Template, + + args: { + rawSeries: data.barLogYScaleStacked as any, + renderingContext, + }, }; -export const BarStackedNormalizedEmptySpace37880 = Template.bind({}); -BarStackedNormalizedEmptySpace37880.args = { - rawSeries: data.barStackedNormalizedEmptySpace37880 as any, - renderingContext, +export const BarLogYScaleStackedNegative = { + render: Template, + + args: { + rawSeries: data.barLogYScaleStackedNegative as any, + renderingContext, + }, }; -export const BarTwoAxesStackedWithNegativeValues = Template.bind({}); -BarTwoAxesStackedWithNegativeValues.args = { - rawSeries: data.barTwoAxesStackedWithNegativeValues as any, - renderingContext, +export const BarStackedNormalizedEmptySpace37880 = { + render: Template, + + args: { + rawSeries: data.barStackedNormalizedEmptySpace37880 as any, + renderingContext, + }, }; -export const BarBreakoutWithLineSeriesStackedRightAxisOnly = Template.bind({}); -BarBreakoutWithLineSeriesStackedRightAxisOnly.args = { - rawSeries: data.barBreakoutWithLineSeriesStackedRightAxisOnly as any, - renderingContext, +export const BarTwoAxesStackedWithNegativeValues = { + render: Template, + + args: { + rawSeries: data.barTwoAxesStackedWithNegativeValues as any, + renderingContext, + }, }; -export const BarsBreakoutSortedWithNegativeValuesPowerYAxis = Template.bind({}); -BarsBreakoutSortedWithNegativeValuesPowerYAxis.args = { - rawSeries: data.barsBreakoutSortedWithNegativeValuesPowerYAxis as any, - renderingContext, +export const BarBreakoutWithLineSeriesStackedRightAxisOnly = { + render: Template, + + args: { + rawSeries: data.barBreakoutWithLineSeriesStackedRightAxisOnly as any, + renderingContext, + }, }; -export const BarFullyNullDimension37902 = Template.bind({}); -BarFullyNullDimension37902.args = { - rawSeries: data.barFullyNullDimension37902 as any, - renderingContext, +export const BarsBreakoutSortedWithNegativeValuesPowerYAxis = { + render: Template, + + args: { + rawSeries: data.barsBreakoutSortedWithNegativeValuesPowerYAxis as any, + renderingContext, + }, }; -export const SplitYAxis = Template.bind({}); -SplitYAxis.args = { - rawSeries: data.autoYSplit as any, - renderingContext, +export const BarFullyNullDimension37902 = { + render: Template, + + args: { + rawSeries: data.barFullyNullDimension37902 as any, + renderingContext, + }, }; -export const GoalLineOutOfBounds37848 = Template.bind({}); -GoalLineOutOfBounds37848.args = { - rawSeries: data.goalLineOutOfBounds37848 as any, - renderingContext, +export const SplitYAxis = { + render: Template, + + args: { + rawSeries: data.autoYSplit as any, + renderingContext, + }, }; -export const GoalLineUnderSeries38824 = Template.bind({}); -GoalLineUnderSeries38824.args = { - rawSeries: data.goalLineUnderSeries38824 as any, - renderingContext, +export const GoalLineOutOfBounds37848 = { + render: Template, + + args: { + rawSeries: data.goalLineOutOfBounds37848 as any, + renderingContext, + }, }; -export const GoalVerySmall = Template.bind({}); -GoalVerySmall.args = { - rawSeries: data.goalVerySmall as any, - renderingContext, +export const GoalLineUnderSeries38824 = { + render: Template, + + args: { + rawSeries: data.goalLineUnderSeries38824 as any, + renderingContext, + }, +}; + +export const GoalVerySmall = { + render: Template, + + args: { + rawSeries: data.goalVerySmall as any, + renderingContext, + }, }; -export const GoalBetweenExtentAndChartBound = Template.bind({}); -GoalBetweenExtentAndChartBound.args = { - rawSeries: data.goalBetweenExtentAndChartBound as any, - renderingContext, +export const GoalBetweenExtentAndChartBound = { + render: Template, + + args: { + rawSeries: data.goalBetweenExtentAndChartBound as any, + renderingContext, + }, }; -export const GoalLineDisabled = Template.bind({}); -GoalLineDisabled.args = { - rawSeries: data.goalLineDisabled as any, - renderingContext, +export const GoalLineDisabled = { + render: Template, + + args: { + rawSeries: data.goalLineDisabled as any, + renderingContext, + }, }; -export const TrendSingleSeriesLine = Template.bind({}); -TrendSingleSeriesLine.args = { - rawSeries: data.trendSingleSeriesLine as any, - renderingContext, +export const TrendSingleSeriesLine = { + render: Template, + + args: { + rawSeries: data.trendSingleSeriesLine as any, + renderingContext, + }, }; -export const TrendMultiSeriesLine = Template.bind({}); -TrendMultiSeriesLine.args = { - rawSeries: data.trendMultiSeriesLine as any, - renderingContext, +export const TrendMultiSeriesLine = { + render: Template, + + args: { + rawSeries: data.trendMultiSeriesLine as any, + renderingContext, + }, }; -export const TrendSingleSeriesArea = Template.bind({}); -TrendSingleSeriesArea.args = { - rawSeries: data.trendSingleSeriesArea as any, - renderingContext, +export const TrendSingleSeriesArea = { + render: Template, + + args: { + rawSeries: data.trendSingleSeriesArea as any, + renderingContext, + }, }; -export const TrendMultiSeriesArea = Template.bind({}); -TrendMultiSeriesArea.args = { - rawSeries: data.trendMultiSeriesArea as any, - renderingContext, +export const TrendMultiSeriesArea = { + render: Template, + + args: { + rawSeries: data.trendMultiSeriesArea as any, + renderingContext, + }, }; -export const TrendMultiSeriesStackedArea = Template.bind({}); -TrendMultiSeriesStackedArea.args = { - rawSeries: data.trendMultiSeriesStackedArea as any, - renderingContext, +export const TrendMultiSeriesStackedArea = { + render: Template, + + args: { + rawSeries: data.trendMultiSeriesStackedArea as any, + renderingContext, + }, }; -export const TrendMultiSeriesNormalizedStackedArea = Template.bind({}); -TrendMultiSeriesNormalizedStackedArea.args = { - rawSeries: data.trendMultiSeriesNormalizedStackedArea as any, - renderingContext, +export const TrendMultiSeriesNormalizedStackedArea = { + render: Template, + + args: { + rawSeries: data.trendMultiSeriesNormalizedStackedArea as any, + renderingContext, + }, }; -export const TrendSingleSeriesBar = Template.bind({}); -TrendSingleSeriesBar.args = { - rawSeries: data.trendSingleSeriesBar as any, - renderingContext, +export const TrendSingleSeriesBar = { + render: Template, + + args: { + rawSeries: data.trendSingleSeriesBar as any, + renderingContext, + }, }; -export const TrendMultiSeriesBar = Template.bind({}); -TrendMultiSeriesBar.args = { - rawSeries: data.trendMultiSeriesBar as any, - renderingContext, +export const TrendMultiSeriesBar = { + render: Template, + + args: { + rawSeries: data.trendMultiSeriesBar as any, + renderingContext, + }, }; -export const TrendMultiSeriesStackedBar = Template.bind({}); -TrendMultiSeriesStackedBar.args = { - rawSeries: data.trendMultiSeriesStackedBar as any, - renderingContext, +export const TrendMultiSeriesStackedBar = { + render: Template, + + args: { + rawSeries: data.trendMultiSeriesStackedBar as any, + renderingContext, + }, }; -export const TrendMultiSeriesNormalizedStackedBar = Template.bind({}); -TrendMultiSeriesNormalizedStackedBar.args = { - rawSeries: data.trendMultiSeriesNormalizedStackedBar as any, - renderingContext, +export const TrendMultiSeriesNormalizedStackedBar = { + render: Template, + + args: { + rawSeries: data.trendMultiSeriesNormalizedStackedBar as any, + renderingContext, + }, }; -export const TrendCombo = Template.bind({}); -TrendCombo.args = { - rawSeries: data.trendCombo as any, - renderingContext, +export const TrendCombo = { + render: Template, + + args: { + rawSeries: data.trendCombo as any, + renderingContext, + }, }; -export const TrendComboPower = Template.bind({}); -TrendComboPower.args = { - rawSeries: data.trendComboPower as any, - renderingContext, +export const TrendComboPower = { + render: Template, + + args: { + rawSeries: data.trendComboPower as any, + renderingContext, + }, }; -export const TrendComboLog = Template.bind({}); -TrendComboLog.args = { - rawSeries: data.trendComboLog as any, - renderingContext, +export const TrendComboLog = { + render: Template, + + args: { + rawSeries: data.trendComboLog as any, + renderingContext, + }, }; -export const ComboHistogram = Template.bind({}); -ComboHistogram.args = { - rawSeries: data.comboHistogram as any, - renderingContext, +export const ComboHistogram = { + render: Template, + + args: { + rawSeries: data.comboHistogram as any, + renderingContext, + }, }; -export const CombinedBarTimeSeriesDifferentGranularityWithBreakout = - Template.bind({}); -CombinedBarTimeSeriesDifferentGranularityWithBreakout.args = { - rawSeries: data.combinedBarTimeSeriesDifferentGranularityWithBreakout as any, - renderingContext, +export const CombinedBarTimeSeriesDifferentGranularityWithBreakout = { + render: Template, + + args: { + rawSeries: + data.combinedBarTimeSeriesDifferentGranularityWithBreakout as any, + renderingContext, + }, }; -export const NumericXAxisIncludesZero37082 = Template.bind({}); -NumericXAxisIncludesZero37082.args = { - rawSeries: data.numericXAxisIncludesZero37082 as any, - renderingContext, +export const NumericXAxisIncludesZero37082 = { + render: Template, + + args: { + rawSeries: data.numericXAxisIncludesZero37082 as any, + renderingContext, + }, }; -export const WrongYAxisRange37306 = Template.bind({}); -WrongYAxisRange37306.args = { - rawSeries: data.wrongYAxisRange37306 as any, - renderingContext, +export const WrongYAxisRange37306 = { + render: Template, + + args: { + rawSeries: data.wrongYAxisRange37306 as any, + renderingContext, + }, }; -export const LongDimensionNameCutOff37420 = Template.bind({}); -LongDimensionNameCutOff37420.args = { - rawSeries: data.longDimensionNameCutOff37420 as any, - renderingContext, +export const LongDimensionNameCutOff37420 = { + render: Template, + + args: { + rawSeries: data.longDimensionNameCutOff37420 as any, + renderingContext, + }, }; -export const CompactXAxisDoesNotWork38917 = Template.bind({}); -CompactXAxisDoesNotWork38917.args = { - rawSeries: data.compactXAxisDoesNotWork38917 as any, - renderingContext, +export const CompactXAxisDoesNotWork38917 = { + render: Template, + + args: { + rawSeries: data.compactXAxisDoesNotWork38917 as any, + renderingContext, + }, }; -export const DataLabelsUnderTrendGoalLines41280 = Template.bind({}); -DataLabelsUnderTrendGoalLines41280.args = { - rawSeries: data.dataLabelsUnderTrendGoalLines41280 as any, - renderingContext, +export const DataLabelsUnderTrendGoalLines41280 = { + render: Template, + + args: { + rawSeries: data.dataLabelsUnderTrendGoalLines41280 as any, + renderingContext, + }, }; -export const TicksNativeWeekWithGapShortRange = Template.bind({}); -TicksNativeWeekWithGapShortRange.args = { - rawSeries: data.ticksNativeWeekWithGapShortRange as any, - renderingContext, + +export const TicksNativeWeekWithGapShortRange = { + render: Template, + + args: { + rawSeries: data.ticksNativeWeekWithGapShortRange as any, + renderingContext, + }, }; -export const TicksNativeWeekWithGapLongRange = Template.bind({}); -TicksNativeWeekWithGapLongRange.args = { - rawSeries: data.ticksNativeWeekWithGapLongRange as any, - renderingContext, +export const TicksNativeWeekWithGapLongRange = { + render: Template, + + args: { + rawSeries: data.ticksNativeWeekWithGapLongRange as any, + renderingContext, + }, }; -export const BarStackLinearXAxis = Template.bind({}); -BarStackLinearXAxis.args = { - rawSeries: data.barStackLinearXAxis as any, - renderingContext, +export const BarStackLinearXAxis = { + render: Template, + + args: { + rawSeries: data.barStackLinearXAxis as any, + renderingContext, + }, }; -export const AreaStackLinearXAxis = Template.bind({}); -AreaStackLinearXAxis.args = { - rawSeries: data.areaStackLinearXAxis as any, - renderingContext, +export const AreaStackLinearXAxis = { + render: Template, + + args: { + rawSeries: data.areaStackLinearXAxis as any, + renderingContext, + }, }; -export const NullCategoryValueFormatting = Template.bind({}); -NullCategoryValueFormatting.args = { - rawSeries: data.nullCategoryValueFormatting as any, - renderingContext, +export const NullCategoryValueFormatting = { + render: Template, + + args: { + rawSeries: data.nullCategoryValueFormatting as any, + renderingContext, + }, }; -export const NumberOfInsightsError39608 = Template.bind({}); -NumberOfInsightsError39608.args = { - rawSeries: data.numberOfInsightsError39608 as any, - renderingContext, +export const NumberOfInsightsError39608 = { + render: Template, + + args: { + rawSeries: data.numberOfInsightsError39608 as any, + renderingContext, + }, }; -export const AreaStackInterpolateMissingValues = Template.bind({}); -AreaStackInterpolateMissingValues.args = { - rawSeries: data.areaStackInterpolateMissingValues as any, - renderingContext, +export const AreaStackInterpolateMissingValues = { + render: Template, + + args: { + rawSeries: data.areaStackInterpolateMissingValues as any, + renderingContext, + }, }; -export const AreaStackAllSeriesWithoutInterpolation = Template.bind({}); -AreaStackAllSeriesWithoutInterpolation.args = { - rawSeries: data.areaStackAllSeriesWithoutInterpolation as any, - renderingContext, +export const AreaStackAllSeriesWithoutInterpolation = { + render: Template, + + args: { + rawSeries: data.areaStackAllSeriesWithoutInterpolation as any, + renderingContext, + }, }; -export const AreaOverBar = Template.bind({}); -AreaOverBar.args = { - rawSeries: data.areaOverBar as any, - renderingContext, +export const AreaOverBar = { + render: Template, + + args: { + rawSeries: data.areaOverBar as any, + renderingContext, + }, }; -export const TimeSeriesTicksCompactFormattingMixedTimezones = Template.bind({}); -TimeSeriesTicksCompactFormattingMixedTimezones.args = { - rawSeries: data.timeSeriesTicksCompactFormattingMixedTimezones as any, - renderingContext, +export const TimeSeriesTicksCompactFormattingMixedTimezones = { + render: Template, + + args: { + rawSeries: data.timeSeriesTicksCompactFormattingMixedTimezones as any, + renderingContext, + }, }; -export const TimezoneTicksPlacement = Template.bind({}); -TimezoneTicksPlacement.args = { - rawSeries: data.timezoneTicksPlacement as any, - renderingContext, +export const TimezoneTicksPlacement = { + render: Template, + + args: { + rawSeries: data.timezoneTicksPlacement as any, + renderingContext, + }, }; -export const BarRelativeDatetimeOrdinalScale = Template.bind({}); -BarRelativeDatetimeOrdinalScale.args = { - rawSeries: data.barRelativeDatetimeOrdinalScale as any, - renderingContext, +export const BarRelativeDatetimeOrdinalScale = { + render: Template, + + args: { + rawSeries: data.barRelativeDatetimeOrdinalScale as any, + renderingContext, + }, }; -export const BarTwoDaysOfWeek = Template.bind({}); -BarTwoDaysOfWeek.args = { - rawSeries: data.barTwoDaysOfWeek as any, - renderingContext, +export const BarTwoDaysOfWeek = { + render: Template, + + args: { + rawSeries: data.barTwoDaysOfWeek as any, + renderingContext, + }, }; -export const AreaStackedAutoDataLabels = Template.bind({}); -AreaStackedAutoDataLabels.args = { - rawSeries: data.areaStackedAutoDataLabels as any, - renderingContext, +export const AreaStackedAutoDataLabels = { + render: Template, + + args: { + rawSeries: data.areaStackedAutoDataLabels as any, + renderingContext, + }, }; -export const ImageCutOff37275 = Template.bind({}); -ImageCutOff37275.args = { - rawSeries: data.imageCutOff37275 as any, - renderingContext, +export const ImageCutOff37275 = { + render: Template, + + args: { + rawSeries: data.imageCutOff37275 as any, + renderingContext, + }, }; -export const IncorrectLabelYAxisSplit41285 = Template.bind({}); -IncorrectLabelYAxisSplit41285.args = { - rawSeries: data.incorrectLabelYAxisSplit41285 as any, - renderingContext, +export const IncorrectLabelYAxisSplit41285 = { + render: Template, + + args: { + rawSeries: data.incorrectLabelYAxisSplit41285 as any, + renderingContext, + }, }; -export const NativeAutoYSplit = Template.bind({}); -NativeAutoYSplit.args = { - rawSeries: data.nativeAutoYSplit as any, - renderingContext, +export const NativeAutoYSplit = { + render: Template, + + args: { + rawSeries: data.nativeAutoYSplit as any, + renderingContext, + }, }; -export const TimeSeriesYyyymmddNumbersFormat = Template.bind({}); -TimeSeriesYyyymmddNumbersFormat.args = { - rawSeries: data.timeSeriesYyyymmddNumbersFormat as any, - renderingContext, +export const TimeSeriesYyyymmddNumbersFormat = { + render: Template, + + args: { + rawSeries: data.timeSeriesYyyymmddNumbersFormat as any, + renderingContext, + }, }; -export const BreakoutNullAndEmptyString = Template.bind({}); -BreakoutNullAndEmptyString.args = { - rawSeries: data.breakoutNullAndEmptyString as any, - renderingContext, +export const BreakoutNullAndEmptyString = { + render: Template, + + args: { + rawSeries: data.breakoutNullAndEmptyString as any, + renderingContext, + }, }; -export const NoGoodAxisSplit = Template.bind({}); -NoGoodAxisSplit.args = { - rawSeries: data.noGoodAxisSplit as any, - renderingContext, +export const NoGoodAxisSplit = { + render: Template, + + args: { + rawSeries: data.noGoodAxisSplit as any, + renderingContext, + }, }; -export const HistogramTicks45Degrees = Template.bind({}); -HistogramTicks45Degrees.args = { - rawSeries: data.histogramTicks45Degrees as any, - renderingContext, +export const HistogramTicks45Degrees = { + render: Template, + + args: { + rawSeries: data.histogramTicks45Degrees as any, + renderingContext, + }, }; -export const HistogramTicks90Degrees = Template.bind({}); -HistogramTicks90Degrees.args = { - rawSeries: data.histogramTicks90Degrees as any, - renderingContext, +export const HistogramTicks90Degrees = { + render: Template, + + args: { + rawSeries: data.histogramTicks90Degrees as any, + renderingContext, + }, }; -export const LineUnpinFromZero = Template.bind({}); -LineUnpinFromZero.args = { - rawSeries: data.lineUnpinFromZero as any, - renderingContext, +export const LineUnpinFromZero = { + render: Template, + + args: { + rawSeries: data.lineUnpinFromZero as any, + renderingContext, + }, }; -export const LineSettings = Template.bind({}); -LineSettings.args = { - rawSeries: data.lineSettings as any, - renderingContext, +export const LineSettings = { + render: Template, + + args: { + rawSeries: data.lineSettings as any, + renderingContext, + }, }; -export const LineReplaceMissingValuesZero = Template.bind({}); -LineReplaceMissingValuesZero.args = { - rawSeries: data.lineReplaceMissingValuesZero as any, - renderingContext, +export const LineReplaceMissingValuesZero = { + render: Template, + + args: { + rawSeries: data.lineReplaceMissingValuesZero as any, + renderingContext, + }, }; -export const LineChartBrokenDimensionsMetricsSettings = Template.bind({}); -LineChartBrokenDimensionsMetricsSettings.args = { - rawSeries: data.lineChartBrokenDimensionsMetricsSettings as any, - renderingContext, +export const LineChartBrokenDimensionsMetricsSettings = { + render: Template, + + args: { + rawSeries: data.lineChartBrokenDimensionsMetricsSettings as any, + renderingContext, + }, }; -export const ComboStackedBarsAreasNormalized = Template.bind({}); -ComboStackedBarsAreasNormalized.args = { - rawSeries: data.comboStackedBarsAreasNormalized as any, - renderingContext, +export const ComboStackedBarsAreasNormalized = { + render: Template, + + args: { + rawSeries: data.comboStackedBarsAreasNormalized as any, + renderingContext, + }, }; -export const ComboStackedBarsAreas = Template.bind({}); -ComboStackedBarsAreas.args = { - rawSeries: data.comboStackedBarsAreas as any, - renderingContext, +export const ComboStackedBarsAreas = { + render: Template, + + args: { + rawSeries: data.comboStackedBarsAreas as any, + renderingContext, + }, }; -export const TwoBarsTwoAreasOneLineLinear = Template.bind({}); -TwoBarsTwoAreasOneLineLinear.args = { - rawSeries: data.twoBarsTwoAreasOneLineLinear as any, - renderingContext, +export const TwoBarsTwoAreasOneLineLinear = { + render: Template, + + args: { + rawSeries: data.twoBarsTwoAreasOneLineLinear as any, + renderingContext, + }, }; -export const TwoBarsTwoAreasOneLinePower = Template.bind({}); -TwoBarsTwoAreasOneLinePower.args = { - rawSeries: data.twoBarsTwoAreasOneLinePower as any, - renderingContext, +export const TwoBarsTwoAreasOneLinePower = { + render: Template, + + args: { + rawSeries: data.twoBarsTwoAreasOneLinePower as any, + renderingContext, + }, }; -export const TwoBarsTwoAreasOneLineLog = Template.bind({}); -TwoBarsTwoAreasOneLineLog.args = { - rawSeries: data.twoBarsTwoAreasOneLineLog as any, - renderingContext, +export const TwoBarsTwoAreasOneLineLog = { + render: Template, + + args: { + rawSeries: data.twoBarsTwoAreasOneLineLog as any, + renderingContext, + }, }; -export const BarCorrectWidthWhenTwoYAxes = Template.bind({}); -BarCorrectWidthWhenTwoYAxes.args = { - rawSeries: data.barCorrectWidthWhenTwoYAxes as any, - renderingContext, +export const BarCorrectWidthWhenTwoYAxes = { + render: Template, + + args: { + rawSeries: data.barCorrectWidthWhenTwoYAxes as any, + renderingContext, + }, }; -export const BarDataLabelsNegatives = Template.bind({}); -BarDataLabelsNegatives.args = { - rawSeries: data.barDataLabelsNegatives as any, - renderingContext, +export const BarDataLabelsNegatives = { + render: Template, + + args: { + rawSeries: data.barDataLabelsNegatives as any, + renderingContext, + }, }; -export const BarStackedNormalizedSeriesLabels = Template.bind({}); -BarStackedNormalizedSeriesLabels.args = { - rawSeries: data.barStackedNormalizedSeriesLabels as any, - renderingContext, +export const BarStackedNormalizedSeriesLabels = { + render: Template, + + args: { + rawSeries: data.barStackedNormalizedSeriesLabels as any, + renderingContext, + }, }; -export const BarStackedSeriesLabelsAndTotals = Template.bind({}); -BarStackedSeriesLabelsAndTotals.args = { - rawSeries: data.barStackedSeriesLabelsAndTotals as any, - renderingContext, +export const BarStackedSeriesLabelsAndTotals = { + render: Template, + + args: { + rawSeries: data.barStackedSeriesLabelsAndTotals as any, + renderingContext, + }, }; -export const BarStackedSeriesLabelsNoTotals = Template.bind({}); -BarStackedSeriesLabelsNoTotals.args = { - rawSeries: data.barStackedSeriesLabelsNoTotals as any, - renderingContext, +export const BarStackedSeriesLabelsNoTotals = { + render: Template, + + args: { + rawSeries: data.barStackedSeriesLabelsNoTotals as any, + renderingContext, + }, }; -export const BarStackedSeriesLabelsRotated = Template.bind({}); -BarStackedSeriesLabelsRotated.args = { - rawSeries: data.barStackedSeriesLabelsRotated as any, - renderingContext, +export const BarStackedSeriesLabelsRotated = { + render: Template, + + args: { + rawSeries: data.barStackedSeriesLabelsRotated as any, + renderingContext, + }, }; -export const BarStackedSeriesLabelsAutoCompactness = Template.bind({}); -BarStackedSeriesLabelsAutoCompactness.args = { - rawSeries: data.barStackedSeriesLabelsAutoCompactness as any, - renderingContext, +export const BarStackedSeriesLabelsAutoCompactness = { + render: Template, + + args: { + rawSeries: data.barStackedSeriesLabelsAutoCompactness as any, + renderingContext, + }, }; -export const BarStackedSeriesLabelsAndTotalsOrdinal = Template.bind({}); -BarStackedSeriesLabelsAndTotalsOrdinal.args = { - rawSeries: data.barStackedSeriesLabelsAndTotalsOrdinal as any, - renderingContext, +export const BarStackedSeriesLabelsAndTotalsOrdinal = { + render: Template, + + args: { + rawSeries: data.barStackedSeriesLabelsAndTotalsOrdinal as any, + renderingContext, + }, }; -export const BarStackedSeriesLabelsNormalizedAutoCompactness = Template.bind( - {}, -); -BarStackedSeriesLabelsNormalizedAutoCompactness.args = { - rawSeries: data.barStackedSeriesLabelsNormalizedAutoCompactness as any, - renderingContext, +export const BarStackedSeriesLabelsNormalizedAutoCompactness = { + render: Template, + + args: { + rawSeries: data.barStackedSeriesLabelsNormalizedAutoCompactness as any, + renderingContext, + }, }; -export const BarStackedLabelsNullVsZero = Template.bind({}); -BarStackedLabelsNullVsZero.args = { - rawSeries: data.barStackedLabelsNullVsZero as any, - renderingContext, +export const BarStackedLabelsNullVsZero = { + render: Template, + + args: { + rawSeries: data.barStackedLabelsNullVsZero as any, + renderingContext, + }, }; -export const BarMinHeightLimit = Template.bind({}); -BarMinHeightLimit.args = { - rawSeries: data.barMinHeightLimit as any, - renderingContext, +export const BarMinHeightLimit = { + render: Template, + + args: { + rawSeries: data.barMinHeightLimit as any, + renderingContext, + }, }; -export const ComboDataLabelsAutoCompactnessPropagatesFromLine = Template.bind( - {}, -); -ComboDataLabelsAutoCompactnessPropagatesFromLine.args = { - rawSeries: data.comboDataLabelsAutoCompactnessPropagatesFromLine as any, - renderingContext, +export const ComboDataLabelsAutoCompactnessPropagatesFromLine = { + render: Template, + + args: { + rawSeries: data.comboDataLabelsAutoCompactnessPropagatesFromLine as any, + renderingContext, + }, }; -export const ComboDataLabelsAutoCompactnessPropagatesFromTotals = Template.bind( - {}, -); -ComboDataLabelsAutoCompactnessPropagatesFromTotals.args = { - rawSeries: data.comboDataLabelsAutoCompactnessPropagatesFromTotals as any, - renderingContext, +export const ComboDataLabelsAutoCompactnessPropagatesFromTotals = { + render: Template, + + args: { + rawSeries: data.comboDataLabelsAutoCompactnessPropagatesFromTotals as any, + renderingContext, + }, }; -export const AreaChartSteppedNullsInterpolated = Template.bind({}); -AreaChartSteppedNullsInterpolated.args = { - rawSeries: data.areaChartSteppedNullsInterpolated as any, - renderingContext, +export const AreaChartSteppedNullsInterpolated = { + render: Template, + + args: { + rawSeries: data.areaChartSteppedNullsInterpolated as any, + renderingContext, + }, }; -export const AreaChartSteppedNullsSkipped = Template.bind({}); -AreaChartSteppedNullsSkipped.args = { - rawSeries: data.areaChartSteppedNullsSkipped as any, - renderingContext, +export const AreaChartSteppedNullsSkipped = { + render: Template, + + args: { + rawSeries: data.areaChartSteppedNullsSkipped as any, + renderingContext, + }, }; -export const SafariNonIanaTimezoneRepro44128 = Template.bind({}); -SafariNonIanaTimezoneRepro44128.args = { - rawSeries: data.safariNonIanaTimezoneRepro44128 as any, - renderingContext, +export const SafariNonIanaTimezoneRepro44128 = { + render: Template, + + args: { + rawSeries: data.safariNonIanaTimezoneRepro44128 as any, + renderingContext, + }, }; -export const CombinedWithInvalidSettings = Template.bind({}); -CombinedWithInvalidSettings.args = { - rawSeries: data.combinedWithInvalidSettings as any, - renderingContext, +export const CombinedWithInvalidSettings = { + render: Template, + + args: { + rawSeries: data.combinedWithInvalidSettings as any, + renderingContext, + }, }; -export const StackedChartCustomYAxisRange = Template.bind({}); -StackedChartCustomYAxisRange.args = { - rawSeries: data.stackedChartCustomYAxisRange as any, - renderingContext, +export const StackedChartCustomYAxisRange = { + render: Template, + + args: { + rawSeries: data.stackedChartCustomYAxisRange as any, + renderingContext, + }, }; -export const SeriesOrderSettingsDoNotMatchSeriesCount = Template.bind({}); -SeriesOrderSettingsDoNotMatchSeriesCount.args = { - rawSeries: data.seriesOrderSettingsDoNotMatchSeriesCount as any, - renderingContext, +export const SeriesOrderSettingsDoNotMatchSeriesCount = { + render: Template, + + args: { + rawSeries: data.seriesOrderSettingsDoNotMatchSeriesCount as any, + renderingContext, + }, }; -export const TrendGoalLinesWithScalingPowScaleCustomRange = Template.bind({}); -TrendGoalLinesWithScalingPowScaleCustomRange.args = { - rawSeries: data.trendGoalLinesWithScalingPowScaleCustomRange as any, - renderingContext, +export const TrendGoalLinesWithScalingPowScaleCustomRange = { + render: Template, + + args: { + rawSeries: data.trendGoalLinesWithScalingPowScaleCustomRange as any, + renderingContext, + }, }; -export const BarStackedAllLabelsTimeseriesWithGap45717 = Template.bind({}); -BarStackedAllLabelsTimeseriesWithGap45717.args = { - rawSeries: data.barStackedAllLabelsTimeseriesWithGap45717 as any, - renderingContext, +export const BarStackedAllLabelsTimeseriesWithGap45717 = { + render: Template, + + args: { + rawSeries: data.barStackedAllLabelsTimeseriesWithGap45717 as any, + renderingContext, + }, }; -export const Default = Template.bind({}); -Default.args = { - rawSeries: data.messedUpAxis as any, - renderingContext, +export const Default = { + render: Template, + + args: { + rawSeries: data.messedUpAxis as any, + renderingContext, + }, }; diff --git a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/incorrect-label-y-axis-split-41285.json b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/incorrect-label-y-axis-split-41285.json index 943fb8e6cba66312be09a5b122769c1c17bf2d19..0e85cfed04c072d961b8b1cc0b12e1176843006d 100644 --- a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/incorrect-label-y-axis-split-41285.json +++ b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/incorrect-label-y-axis-split-41285.json @@ -87,7 +87,7 @@ "description": "Your instance data. To customize these questions and dashboards, you can duplicate them and save them in the custom reports collection.", "archived": false, "slug": "metabase_analytics", - "name": "Metabase analytics", + "name": "Usage analytics", "personal_owner_id": null, "type": "instance-analytics", "id": 1362, diff --git a/frontend/src/metabase/static-viz/components/FunnelBarChart/FunnelBarChart.stories.tsx b/frontend/src/metabase/static-viz/components/FunnelBarChart/FunnelBarChart.stories.tsx index 1d3c0d83a21f010f41bea79377057e49aa6b8ae5..fb9dccb75498f7f0801da4620271c236cd035c3a 100644 --- a/frontend/src/metabase/static-viz/components/FunnelBarChart/FunnelBarChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/FunnelBarChart/FunnelBarChart.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; import { formatStaticValue } from "metabase/static-viz/lib/format"; @@ -9,6 +9,8 @@ import { import { DEFAULT_VISUALIZATION_THEME } from "metabase/visualizations/shared/utils/theme"; import type { RenderingContext } from "metabase/visualizations/types"; +import type { StaticChartProps } from "../StaticVisualization"; + import { FunnelBarChart } from "./FunnelBarChart"; import { data } from "./stories-data"; @@ -17,7 +19,7 @@ export default { component: FunnelBarChart, }; -const Template: ComponentStory<typeof FunnelBarChart> = args => { +const Template: StoryFn<StaticChartProps> = args => { return ( <div style={{ border: "1px solid black", display: "inline-block" }}> <FunnelBarChart {...args} isStorybook /> @@ -35,20 +37,29 @@ const renderingContext: RenderingContext = { theme: DEFAULT_VISUALIZATION_THEME, }; -export const Default = Template.bind({}); -Default.args = { - rawSeries: data.funnelBarCategorical as any, - renderingContext, +export const Default = { + render: Template, + + args: { + rawSeries: data.funnelBarCategorical as any, + renderingContext, + }, }; -export const FunnelBarOrderedRows = Template.bind({}); -FunnelBarOrderedRows.args = { - rawSeries: data.funnelBarOrderedRows as any, - renderingContext, +export const FunnelBarOrderedRows = { + render: Template, + + args: { + rawSeries: data.funnelBarOrderedRows as any, + renderingContext, + }, }; -export const FunnelBarUnorderedRows = Template.bind({}); -FunnelBarUnorderedRows.args = { - rawSeries: data.funnelBarUnorderedRows as any, - renderingContext, +export const FunnelBarUnorderedRows = { + render: Template, + + args: { + rawSeries: data.funnelBarUnorderedRows as any, + renderingContext, + }, }; diff --git a/frontend/src/metabase/static-viz/components/FunnelChart/FunnelChart.stories.tsx b/frontend/src/metabase/static-viz/components/FunnelChart/FunnelChart.stories.tsx index 00a15580561ef31b3b4fca27e8d3cdac70e001fb..abd66fc260a4fb7fe80fb1ff927907a352aec56e 100644 --- a/frontend/src/metabase/static-viz/components/FunnelChart/FunnelChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/FunnelChart/FunnelChart.stories.tsx @@ -1,23 +1,27 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { DEFAULT, DUPLICATED_STEPS, } from "metabase/static-viz/components/FunnelChart/stories-data"; -import FunnelChart from "./FunnelChart"; +import FunnelChart, { type FunnelProps } from "./FunnelChart"; export default { title: "static-viz/FunnelChart", component: FunnelChart, }; -const Template: ComponentStory<typeof FunnelChart> = args => { +const Template: StoryFn<FunnelProps> = args => { return <FunnelChart {...args} />; }; -export const Default = Template.bind({}); -Default.args = DEFAULT; +export const Default = { + render: Template, + args: DEFAULT, +}; -export const WithDuplicatedSteps = Template.bind({}); -WithDuplicatedSteps.args = DUPLICATED_STEPS; +export const WithDuplicatedSteps = { + render: Template, + args: DUPLICATED_STEPS, +}; diff --git a/frontend/src/metabase/static-viz/components/Gauge/Gauge.stories.tsx b/frontend/src/metabase/static-viz/components/Gauge/Gauge.stories.tsx index 687c02ca8ff189378bdae48a3bf16803696f7453..de31d8e6833df6bfcaad2369923e0a0ccd0b67da 100644 --- a/frontend/src/metabase/static-viz/components/Gauge/Gauge.stories.tsx +++ b/frontend/src/metabase/static-viz/components/Gauge/Gauge.stories.tsx @@ -1,7 +1,9 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; -import GaugeContainer from "metabase/static-viz/components/Gauge/GaugeContainer"; +import GaugeContainer, { + type GaugeContainerProps, +} from "metabase/static-viz/components/Gauge/GaugeContainer"; import { DEFAULT, TRUNCATED_LABELS, @@ -13,15 +15,21 @@ export default { component: GaugeContainer, }; -const Template: ComponentStory<typeof GaugeContainer> = args => { +const Template: StoryFn<GaugeContainerProps> = args => { return <GaugeContainer {...args} />; }; -export const Default = Template.bind({}); -Default.args = { ...DEFAULT, getColor: color }; +export const Default = { + render: Template, + args: { ...DEFAULT, getColor: color }, +}; -export const WithFormatting = Template.bind({}); -WithFormatting.args = { ...WITH_FORMATTING, getColor: color }; +export const WithFormatting = { + render: Template, + args: { ...WITH_FORMATTING, getColor: color }, +}; -export const TruncatedLabels = Template.bind({}); -TruncatedLabels.args = { ...TRUNCATED_LABELS, getColor: color }; +export const TruncatedLabels = { + render: Template, + args: { ...TRUNCATED_LABELS, getColor: color }, +}; diff --git a/frontend/src/metabase/static-viz/components/PieChart/PieChart.stories.tsx b/frontend/src/metabase/static-viz/components/PieChart/PieChart.stories.tsx index c35a40f1629e72493a582a7c68f15b8b85c11bd8..3719b3b3f0944b97a57ca3217ee3e963cda0bf44 100644 --- a/frontend/src/metabase/static-viz/components/PieChart/PieChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/PieChart/PieChart.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; import { formatStaticValue } from "metabase/static-viz/lib/format"; @@ -9,6 +9,8 @@ import { import { DEFAULT_VISUALIZATION_THEME } from "metabase/visualizations/shared/utils/theme"; import type { RenderingContext } from "metabase/visualizations/types"; +import type { StaticChartProps } from "../StaticVisualization"; + import { PieChart } from "./PieChart"; import { data } from "./stories-data"; @@ -17,7 +19,7 @@ export default { component: PieChart, }; -const Template: ComponentStory<typeof PieChart> = args => { +const Template: StoryFn<StaticChartProps> = args => { return ( <div style={{ border: "1px solid black", display: "inline-block" }}> <PieChart {...args} isStorybook /> @@ -35,248 +37,371 @@ const renderingContext: RenderingContext = { theme: DEFAULT_VISUALIZATION_THEME, }; -export const DefaultSettings = Template.bind({}); -DefaultSettings.args = { - rawSeries: data.defaultSettings as any, - renderingContext, +export const DefaultSettings = { + render: Template, + + args: { + rawSeries: data.defaultSettings as any, + renderingContext, + }, }; -export const AllSettings = Template.bind({}); -AllSettings.args = { - rawSeries: data.allSettings as any, - renderingContext, +export const AllSettings = { + render: Template, + + args: { + rawSeries: data.allSettings as any, + renderingContext, + }, }; -export const AutoCompactTotal = Template.bind({}); -AutoCompactTotal.args = { - rawSeries: data.autoCompactTotal as any, - renderingContext, +export const AutoCompactTotal = { + render: Template, + + args: { + rawSeries: data.autoCompactTotal as any, + renderingContext, + }, }; -export const Colors = Template.bind({}); -Colors.args = { - rawSeries: data.colors as any, - renderingContext, +export const Colors = { + render: Template, + + args: { + rawSeries: data.colors as any, + renderingContext, + }, }; -export const HideLegend = Template.bind({}); -HideLegend.args = { - rawSeries: data.hideLegend as any, - renderingContext, +export const HideLegend = { + render: Template, + + args: { + rawSeries: data.hideLegend as any, + renderingContext, + }, }; -export const HideTotal = Template.bind({}); -HideTotal.args = { - rawSeries: data.hideTotal as any, - renderingContext, +export const HideTotal = { + render: Template, + + args: { + rawSeries: data.hideTotal as any, + renderingContext, + }, }; -export const ReorderedRenamedSlices = Template.bind({}); -ReorderedRenamedSlices.args = { - rawSeries: data.reorderedRenamedSlices as any, - renderingContext, +export const ReorderedRenamedSlices = { + render: Template, + + args: { + rawSeries: data.reorderedRenamedSlices as any, + renderingContext, + }, }; -export const SmallMinimumSlicePercentage = Template.bind({}); -SmallMinimumSlicePercentage.args = { - rawSeries: data.smallMinimumSlicePercentage as any, - renderingContext, +export const SmallMinimumSlicePercentage = { + render: Template, + + args: { + rawSeries: data.smallMinimumSlicePercentage as any, + renderingContext, + }, }; -export const LargeMinimumSlicePercentage = Template.bind({}); -LargeMinimumSlicePercentage.args = { - rawSeries: data.largeMinimumSlicePercentage as any, - renderingContext, +export const LargeMinimumSlicePercentage = { + render: Template, + + args: { + rawSeries: data.largeMinimumSlicePercentage as any, + renderingContext, + }, }; -export const ZeroMinimumSlicePercentage = Template.bind({}); -ZeroMinimumSlicePercentage.args = { - rawSeries: data.zeroMinimumSlicePercentage as any, - renderingContext, +export const ZeroMinimumSlicePercentage = { + render: Template, + + args: { + rawSeries: data.zeroMinimumSlicePercentage as any, + renderingContext, + }, }; -export const ShowPercentagesOff = Template.bind({}); -ShowPercentagesOff.args = { - rawSeries: data.showPercentagesOff as any, - renderingContext, +export const ShowPercentagesOff = { + render: Template, + + args: { + rawSeries: data.showPercentagesOff as any, + renderingContext, + }, }; -export const ShowPercentagesOnChart = Template.bind({}); -ShowPercentagesOnChart.args = { - rawSeries: data.showPercentagesOnChart as any, - renderingContext, +export const ShowPercentagesOnChart = { + render: Template, + + args: { + rawSeries: data.showPercentagesOnChart as any, + renderingContext, + }, }; -export const ShowPercentagesOnChartDense = Template.bind({}); -ShowPercentagesOnChartDense.args = { - rawSeries: data.showPercentagesOnChartDense as any, - renderingContext, +export const ShowPercentagesOnChartDense = { + render: Template, + + args: { + rawSeries: data.showPercentagesOnChartDense as any, + renderingContext, + }, }; -export const AllNegative = Template.bind({}); -AllNegative.args = { - rawSeries: data.allNegative as any, - renderingContext, +export const AllNegative = { + render: Template, + + args: { + rawSeries: data.allNegative as any, + renderingContext, + }, }; -export const MixedPositiveNegative = Template.bind({}); -MixedPositiveNegative.args = { - rawSeries: data.mixedPostiiveNegative as any, - renderingContext, +export const MixedPositiveNegative = { + render: Template, + + args: { + rawSeries: data.mixedPostiiveNegative as any, + renderingContext, + }, }; -export const ColumnFormatting = Template.bind({}); -ColumnFormatting.args = { - rawSeries: data.columnFormatting as any, - renderingContext, +export const ColumnFormatting = { + render: Template, + + args: { + rawSeries: data.columnFormatting as any, + renderingContext, + }, }; -export const ColumnFormattingPercentagesOnChart = Template.bind({}); -ColumnFormattingPercentagesOnChart.args = { - rawSeries: data.columnFormattingPercentagesOnChart as any, - renderingContext, +export const ColumnFormattingPercentagesOnChart = { + render: Template, + + args: { + rawSeries: data.columnFormattingPercentagesOnChart as any, + renderingContext, + }, }; -export const BooleanDimension = Template.bind({}); -BooleanDimension.args = { - rawSeries: data.booleanDimension as any, - renderingContext, +export const BooleanDimension = { + render: Template, + + args: { + rawSeries: data.booleanDimension as any, + renderingContext, + }, }; -export const NumericDimension = Template.bind({}); -NumericDimension.args = { - rawSeries: data.numericDimension as any, - renderingContext, +export const NumericDimension = { + render: Template, + + args: { + rawSeries: data.numericDimension as any, + renderingContext, + }, }; -export const BinnedDimension = Template.bind({}); -BinnedDimension.args = { - rawSeries: data.binnedDimension as any, - renderingContext, +export const BinnedDimension = { + render: Template, + + args: { + rawSeries: data.binnedDimension as any, + renderingContext, + }, }; -export const DateDimension = Template.bind({}); -DateDimension.args = { - rawSeries: data.dateDimension as any, - renderingContext, +export const DateDimension = { + render: Template, + + args: { + rawSeries: data.dateDimension as any, + renderingContext, + }, }; -export const RelativeDateDimension = Template.bind({}); -RelativeDateDimension.args = { - rawSeries: data.relativeDateDimension as any, - renderingContext, +export const RelativeDateDimension = { + render: Template, + + args: { + rawSeries: data.relativeDateDimension as any, + renderingContext, + }, }; -export const ShowPercentagesBoth = Template.bind({}); -ShowPercentagesBoth.args = { - rawSeries: data.showPercentagesBoth as any, - renderingContext, +export const ShowPercentagesBoth = { + render: Template, + + args: { + rawSeries: data.showPercentagesBoth as any, + renderingContext, + }, }; -export const NullDimension = Template.bind({}); -NullDimension.args = { - rawSeries: data.nullDimension as any, - renderingContext, +export const NullDimension = { + render: Template, + + args: { + rawSeries: data.nullDimension as any, + renderingContext, + }, }; -export const NumDecimalPlacesChart = Template.bind({}); -NumDecimalPlacesChart.args = { - rawSeries: data.numDecimalPlacesChart as any, - renderingContext, +export const NumDecimalPlacesChart = { + render: Template, + + args: { + rawSeries: data.numDecimalPlacesChart as any, + renderingContext, + }, }; -export const NumDecimalPlacesLegend = Template.bind({}); -NumDecimalPlacesLegend.args = { - rawSeries: data.numDecimalPlacesLegend as any, - renderingContext, +export const NumDecimalPlacesLegend = { + render: Template, + + args: { + rawSeries: data.numDecimalPlacesLegend as any, + renderingContext, + }, }; -export const TruncatedTotal = Template.bind({}); -TruncatedTotal.args = { - rawSeries: data.truncatedTotal as any, - renderingContext, +export const TruncatedTotal = { + render: Template, + + args: { + rawSeries: data.truncatedTotal as any, + renderingContext, + }, }; -export const UnaggregatedDimension = Template.bind({}); -UnaggregatedDimension.args = { - rawSeries: data.unaggregatedDimension as any, - renderingContext, +export const UnaggregatedDimension = { + render: Template, + + args: { + rawSeries: data.unaggregatedDimension as any, + renderingContext, + }, }; -export const SingleDimension = Template.bind({}); -SingleDimension.args = { - rawSeries: data.singleDimension as any, - renderingContext, +export const SingleDimension = { + render: Template, + + args: { + rawSeries: data.singleDimension as any, + renderingContext, + }, }; -export const LongDimensionName = Template.bind({}); -LongDimensionName.args = { - rawSeries: data.longDimensionName as any, - renderingContext, +export const LongDimensionName = { + render: Template, + + args: { + rawSeries: data.longDimensionName as any, + renderingContext, + }, }; -export const TinySlicesDisappear43766 = Template.bind({}); -TinySlicesDisappear43766.args = { - rawSeries: data.tinySlicesDisappear43766 as any, - renderingContext, +export const TinySlicesDisappear43766 = { + render: Template, + + args: { + rawSeries: data.tinySlicesDisappear43766 as any, + renderingContext, + }, }; -export const MissingCurrencyFormatting44086 = Template.bind({}); -MissingCurrencyFormatting44086.args = { - rawSeries: data.missingCurrencyFormatting44086 as any, - renderingContext, +export const MissingCurrencyFormatting44086 = { + render: Template, + + args: { + rawSeries: data.missingCurrencyFormatting44086 as any, + renderingContext, + }, }; -export const MissingCurrencyFormatting2 = Template.bind({}); -MissingCurrencyFormatting2.args = { - rawSeries: data.missingCurrencyFormatting2 as any, - renderingContext, +export const MissingCurrencyFormatting2 = { + render: Template, + + args: { + rawSeries: data.missingCurrencyFormatting2 as any, + renderingContext, + }, }; -export const MissingCurrencyFormatting3 = Template.bind({}); -MissingCurrencyFormatting3.args = { - rawSeries: data.missingCurrencyFormatting3 as any, - renderingContext, +export const MissingCurrencyFormatting3 = { + render: Template, + + args: { + rawSeries: data.missingCurrencyFormatting3 as any, + renderingContext, + }, }; -export const MissingColors44087 = Template.bind({}); -MissingColors44087.args = { - rawSeries: data.missingColors44087 as any, - renderingContext, +export const MissingColors44087 = { + render: Template, + + args: { + rawSeries: data.missingColors44087 as any, + renderingContext, + }, }; -export const InvalidDimensionSetting44085 = Template.bind({}); -InvalidDimensionSetting44085.args = { - rawSeries: data.invalidDimensionSetting44085 as any, - renderingContext, +export const InvalidDimensionSetting44085 = { + render: Template, + + args: { + rawSeries: data.invalidDimensionSetting44085 as any, + renderingContext, + }, }; -export const PercentagesOnChartBooleanDimensionCrashes44085 = Template.bind({}); -PercentagesOnChartBooleanDimensionCrashes44085.args = { - rawSeries: data.percentagesOnChartBooleanDimensionCrashes44085 as any, - renderingContext, +export const PercentagesOnChartBooleanDimensionCrashes44085 = { + render: Template, + + args: { + rawSeries: data.percentagesOnChartBooleanDimensionCrashes44085 as any, + renderingContext, + }, }; -export const AllZeroMetric44847 = Template.bind({}); -AllZeroMetric44847.args = { - rawSeries: data.allZeroMetric44847 as any, - renderingContext, +export const AllZeroMetric44847 = { + render: Template, + + args: { + rawSeries: data.allZeroMetric44847 as any, + renderingContext, + }, }; -export const NoSingleColumnLegend45149 = Template.bind({}); -NoSingleColumnLegend45149.args = { - rawSeries: data.noSingleColumnLegend45149 as any, - renderingContext, +export const NoSingleColumnLegend45149 = { + render: Template, + + args: { + rawSeries: data.noSingleColumnLegend45149 as any, + renderingContext, + }, }; -export const NumericSQLColumnCrashes28568 = Template.bind({}); -NumericSQLColumnCrashes28568.args = { - rawSeries: data.numericSQLColumnCrashes28568 as any, - renderingContext, +export const NumericSQLColumnCrashes28568 = { + render: Template, + + args: { + rawSeries: data.numericSQLColumnCrashes28568 as any, + renderingContext, + }, }; -export const MissingLabelLargeSlice38424 = Template.bind({}); -MissingLabelLargeSlice38424.args = { - rawSeries: data.missingLabelLargeSlice38424 as any, - renderingContext, +export const MissingLabelLargeSlice38424 = { + render: Template, + + args: { + rawSeries: data.missingLabelLargeSlice38424 as any, + renderingContext, + }, }; diff --git a/frontend/src/metabase/static-viz/components/ProgressBar/ProgressBar.stories.tsx b/frontend/src/metabase/static-viz/components/ProgressBar/ProgressBar.stories.tsx index 2898083516fc07a7eaa79145f2e292568f0b1d56..4bc225dceb5214335c60d3044e10662c367abbbf 100644 --- a/frontend/src/metabase/static-viz/components/ProgressBar/ProgressBar.stories.tsx +++ b/frontend/src/metabase/static-viz/components/ProgressBar/ProgressBar.stories.tsx @@ -1,6 +1,6 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import ProgressBar from "./ProgressBar"; +import ProgressBar, { type ProgressBarProps } from "./ProgressBar"; import { BELOW_GOAL, EXCEEDS_GOAL, REACHED_GOAL, ZERO } from "./stories-data"; export default { @@ -8,18 +8,26 @@ export default { component: ProgressBar, }; -const Template: ComponentStory<typeof ProgressBar> = args => { +const Template: StoryFn<ProgressBarProps> = args => { return <ProgressBar {...args} />; }; -export const Default = Template.bind({}); -Default.args = ZERO; +export const Default = { + render: Template, + args: ZERO, +}; -export const BelowGoal = Template.bind({}); -BelowGoal.args = BELOW_GOAL; +export const BelowGoal = { + render: Template, + args: BELOW_GOAL, +}; -export const ReachedGoal = Template.bind({}); -ReachedGoal.args = REACHED_GOAL; +export const ReachedGoal = { + render: Template, + args: REACHED_GOAL, +}; -export const ExceedsGoal = Template.bind({}); -ExceedsGoal.args = EXCEEDS_GOAL; +export const ExceedsGoal = { + render: Template, + args: EXCEEDS_GOAL, +}; diff --git a/frontend/src/metabase/static-viz/components/ProgressBar/ProgressBar.tsx b/frontend/src/metabase/static-viz/components/ProgressBar/ProgressBar.tsx index e5492efbbc510d4c15bc462cb0f98b9fe2e700b2..9e036bd94197f95f5b42e57aac8c316c11228085 100644 --- a/frontend/src/metabase/static-viz/components/ProgressBar/ProgressBar.tsx +++ b/frontend/src/metabase/static-viz/components/ProgressBar/ProgressBar.tsx @@ -32,7 +32,7 @@ const layout = { fontSize: 13, }; -interface ProgressBarProps { +export interface ProgressBarProps { data: ProgressBarData; settings: { color: string; diff --git a/frontend/src/metabase/static-viz/components/RowChart/RowChart.stories.tsx b/frontend/src/metabase/static-viz/components/RowChart/RowChart.stories.tsx index 92df10aebd2d24e9a778478a16031a416e853735..8b12b0d8cea1dae9385ba58a16edb3ca5ec71851 100644 --- a/frontend/src/metabase/static-viz/components/RowChart/RowChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/RowChart/RowChart.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; import { @@ -6,22 +6,27 @@ import { MULTIPLE_SERIES, } from "metabase/static-viz/components/RowChart/stories-data"; -import RowChart from "./RowChart"; +import RowChart, { type StaticRowChartProps } from "./RowChart"; export default { title: "static-viz/RowChart", component: RowChart, }; -const Template: ComponentStory<typeof RowChart> = args => { +const Template: StoryFn<StaticRowChartProps> = args => { return <RowChart {...args} />; }; -export const Default = Template.bind({}); -Default.args = { ...MULTIPLE_SERIES, getColor: color }; +export const Default = { + render: Template, + args: { ...MULTIPLE_SERIES, getColor: color }, +}; + +export const MetricColumnWithScaling = { + render: Template, -export const MetricColumnWithScaling = Template.bind({}); -MetricColumnWithScaling.args = { - ...METRIC_COLUMN_WITH_SCALING, - getColor: color, + args: { + ...METRIC_COLUMN_WITH_SCALING, + getColor: color, + }, }; diff --git a/frontend/src/metabase/static-viz/components/ScalarChart/ScalarChart.stories.tsx b/frontend/src/metabase/static-viz/components/ScalarChart/ScalarChart.stories.tsx index 1c15f1e5d1a346c7dae8614e21bbf196c5eaf131..c474205ca8b72108746fd86c146e46fa0e586b5c 100644 --- a/frontend/src/metabase/static-viz/components/ScalarChart/ScalarChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/ScalarChart/ScalarChart.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; import { formatStaticValue } from "metabase/static-viz/lib/format"; @@ -9,6 +9,8 @@ import { import { DEFAULT_VISUALIZATION_THEME } from "metabase/visualizations/shared/utils/theme"; import type { RenderingContext } from "metabase/visualizations/types"; +import type { StaticChartProps } from "../StaticVisualization"; + import { ScalarChart } from "./ScalarChart"; import { data } from "./stories-data"; @@ -17,7 +19,7 @@ export default { component: ScalarChart, }; -const Template: ComponentStory<typeof ScalarChart> = args => { +const Template: StoryFn<StaticChartProps> = args => { return ( <div style={{ border: "1px solid black", display: "inline-block" }}> <ScalarChart {...args} isStorybook /> @@ -35,8 +37,11 @@ const renderingContext: RenderingContext = { theme: DEFAULT_VISUALIZATION_THEME, }; -export const Default = Template.bind({}); -Default.args = { - rawSeries: data.twoScalars as any, - renderingContext, +export const Default = { + render: Template, + + args: { + rawSeries: data.twoScalars as any, + renderingContext, + }, }; diff --git a/frontend/src/metabase/static-viz/components/ScatterPlot/ScatterPlot.stories.tsx b/frontend/src/metabase/static-viz/components/ScatterPlot/ScatterPlot.stories.tsx index 75047fb2ee8cda6d412da5ee6ea32baee9000fb6..3fdcd60df257b2ebbb5653cfeab33d93d46b3640 100644 --- a/frontend/src/metabase/static-viz/components/ScatterPlot/ScatterPlot.stories.tsx +++ b/frontend/src/metabase/static-viz/components/ScatterPlot/ScatterPlot.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; import { formatStaticValue } from "metabase/static-viz/lib/format"; @@ -9,6 +9,8 @@ import { import { DEFAULT_VISUALIZATION_THEME } from "metabase/visualizations/shared/utils/theme"; import type { RenderingContext } from "metabase/visualizations/types"; +import type { StaticChartProps } from "../StaticVisualization"; + import { ScatterPlot } from "./ScatterPlot"; import { data } from "./stories-data"; @@ -17,7 +19,7 @@ export default { component: ScatterPlot, }; -const Template: ComponentStory<typeof ScatterPlot> = args => { +const Template: StoryFn<StaticChartProps> = args => { return ( <div style={{ border: "1px solid black", display: "inline-block" }}> <ScatterPlot {...args} isStorybook /> @@ -35,104 +37,155 @@ const renderingContext: RenderingContext = { theme: DEFAULT_VISUALIZATION_THEME, }; -export const Default = Template.bind({}); -Default.args = { - rawSeries: data.default as any, - renderingContext, +export const Default = { + render: Template, + + args: { + rawSeries: data.default as any, + renderingContext, + }, }; -export const CustomYAxisRangeWithColumnScaling = Template.bind({}); -CustomYAxisRangeWithColumnScaling.args = { - rawSeries: data.customYAxisRangeWithColumnScaling as any, - renderingContext, +export const CustomYAxisRangeWithColumnScaling = { + render: Template, + + args: { + rawSeries: data.customYAxisRangeWithColumnScaling as any, + renderingContext, + }, }; -export const MultiMetricSeries = Template.bind({}); -MultiMetricSeries.args = { - rawSeries: data.multiMetricSeries as any, - renderingContext, +export const MultiMetricSeries = { + render: Template, + + args: { + rawSeries: data.multiMetricSeries as any, + renderingContext, + }, }; -export const MultiDimensionBreakout = Template.bind({}); -MultiDimensionBreakout.args = { - rawSeries: data.multiDimensionBreakout as any, - renderingContext, +export const MultiDimensionBreakout = { + render: Template, + + args: { + rawSeries: data.multiDimensionBreakout as any, + renderingContext, + }, }; -export const BubbleSize = Template.bind({}); -BubbleSize.args = { - rawSeries: data.bubbleSize as any, - renderingContext, +export const BubbleSize = { + render: Template, + + args: { + rawSeries: data.bubbleSize as any, + renderingContext, + }, }; -export const MultiDimensionBreakoutBubbleSize = Template.bind({}); -MultiDimensionBreakoutBubbleSize.args = { - rawSeries: data.multiDimensionBreakoutBubbleSize as any, - renderingContext, +export const MultiDimensionBreakoutBubbleSize = { + render: Template, + + args: { + rawSeries: data.multiDimensionBreakoutBubbleSize as any, + renderingContext, + }, }; -export const PowerXScale = Template.bind({}); -PowerXScale.args = { - rawSeries: data.powerXScale as any, - renderingContext, +export const PowerXScale = { + render: Template, + + args: { + rawSeries: data.powerXScale as any, + renderingContext, + }, }; -export const PowerXScaleMultiSeries = Template.bind({}); -PowerXScaleMultiSeries.args = { - rawSeries: data.powerXScaleMultiSeries as any, - renderingContext, +export const PowerXScaleMultiSeries = { + render: Template, + + args: { + rawSeries: data.powerXScaleMultiSeries as any, + renderingContext, + }, }; -export const LogXScale = Template.bind({}); -LogXScale.args = { - rawSeries: data.logXScale as any, - renderingContext, +export const LogXScale = { + render: Template, + + args: { + rawSeries: data.logXScale as any, + renderingContext, + }, }; -export const LogXScaleAtOne = Template.bind({}); -LogXScaleAtOne.args = { - rawSeries: data.logXScaleAtOne as any, - renderingContext, +export const LogXScaleAtOne = { + render: Template, + + args: { + rawSeries: data.logXScaleAtOne as any, + renderingContext, + }, }; -export const HistogramXScale = Template.bind({}); -HistogramXScale.args = { - rawSeries: data.histogramXScale as any, - renderingContext, +export const HistogramXScale = { + render: Template, + + args: { + rawSeries: data.histogramXScale as any, + renderingContext, + }, }; -export const OrdinalXScale = Template.bind({}); -OrdinalXScale.args = { - rawSeries: data.ordinalXScale as any, - renderingContext, +export const OrdinalXScale = { + render: Template, + + args: { + rawSeries: data.ordinalXScale as any, + renderingContext, + }, }; -export const TimeseriesXScale = Template.bind({}); -TimeseriesXScale.args = { - rawSeries: data.timeseriesXScale as any, - renderingContext, +export const TimeseriesXScale = { + render: Template, + + args: { + rawSeries: data.timeseriesXScale as any, + renderingContext, + }, }; -export const CustomYAxisRange = Template.bind({}); -CustomYAxisRange.args = { - rawSeries: data.customYAxisRange as any, - renderingContext, +export const CustomYAxisRange = { + render: Template, + + args: { + rawSeries: data.customYAxisRange as any, + renderingContext, + }, }; -export const AutoYAxisExcludeZeroWithGoal = Template.bind({}); -AutoYAxisExcludeZeroWithGoal.args = { - rawSeries: data.autoYAxisExcludeZeroWithGoal as any, - renderingContext, +export const AutoYAxisExcludeZeroWithGoal = { + render: Template, + + args: { + rawSeries: data.autoYAxisExcludeZeroWithGoal as any, + renderingContext, + }, }; -export const GoalLine = Template.bind({}); -GoalLine.args = { - rawSeries: data.goalLine as any, - renderingContext, +export const GoalLine = { + render: Template, + + args: { + rawSeries: data.goalLine as any, + renderingContext, + }, }; -export const PinToZero = Template.bind({}); -PinToZero.args = { - rawSeries: data.pinToZero as any, - renderingContext, +export const PinToZero = { + render: Template, + + args: { + rawSeries: data.pinToZero as any, + renderingContext, + }, }; diff --git a/frontend/src/metabase/static-viz/components/WaterfallChart/WaterfallChart.stories.tsx b/frontend/src/metabase/static-viz/components/WaterfallChart/WaterfallChart.stories.tsx index 94d2bce2ce37e8460aa3d0eb730c8fc05bb53275..583e396f521926a766ba7561c7672d78180c610a 100644 --- a/frontend/src/metabase/static-viz/components/WaterfallChart/WaterfallChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/WaterfallChart/WaterfallChart.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { color } from "metabase/lib/colors"; import { data } from "metabase/static-viz/components/WaterfallChart/stories-data"; @@ -10,6 +10,8 @@ import { import { DEFAULT_VISUALIZATION_THEME } from "metabase/visualizations/shared/utils/theme"; import type { RenderingContext } from "metabase/visualizations/types"; +import type { StaticChartProps } from "../StaticVisualization"; + import { WaterfallChart } from "./WaterfallChart"; export default { @@ -17,7 +19,7 @@ export default { component: WaterfallChart, }; -const Template: ComponentStory<typeof WaterfallChart> = args => { +const Template: StoryFn<StaticChartProps> = args => { return ( <div style={{ border: "1px solid black", display: "inline-block" }}> <WaterfallChart {...args} isStorybook /> @@ -35,242 +37,362 @@ const renderingContext: RenderingContext = { theme: DEFAULT_VISUALIZATION_THEME, }; -export const YAxisCompactWithoutDataLabels = Template.bind({}); -YAxisCompactWithoutDataLabels.args = { - rawSeries: data.yAxisCompactWithoutDataLabels as any, - renderingContext, +export const YAxisCompactWithoutDataLabels = { + render: Template, + + args: { + rawSeries: data.yAxisCompactWithoutDataLabels as any, + renderingContext, + }, }; -export const YAxisAutoCompactWithDataLabels = Template.bind({}); -YAxisAutoCompactWithDataLabels.args = { - rawSeries: data.yAxisAutoCompactWithDataLabels as any, - renderingContext, +export const YAxisAutoCompactWithDataLabels = { + render: Template, + + args: { + rawSeries: data.yAxisAutoCompactWithDataLabels as any, + renderingContext, + }, }; -export const YAxisFullWithDataLabels = Template.bind({}); -YAxisFullWithDataLabels.args = { - rawSeries: data.yAxisFullWithDataLabels as any, - renderingContext, +export const YAxisFullWithDataLabels = { + render: Template, + + args: { + rawSeries: data.yAxisFullWithDataLabels as any, + renderingContext, + }, }; -export const CustomYAxisRangeWithColumnScaling = Template.bind({}); -CustomYAxisRangeWithColumnScaling.args = { - rawSeries: data.customYAxisRangeWithColumnScaling as any, - renderingContext, +export const CustomYAxisRangeWithColumnScaling = { + render: Template, + + args: { + rawSeries: data.customYAxisRangeWithColumnScaling as any, + renderingContext, + }, }; -export const TimeseriesXScale = Template.bind({}); -TimeseriesXScale.args = { - rawSeries: data.timeseriesXScale as any, - renderingContext, +export const TimeseriesXScale = { + render: Template, + + args: { + rawSeries: data.timeseriesXScale as any, + renderingContext, + }, }; -export const TimeseriesXScaleUnsorted = Template.bind({}); -TimeseriesXScaleUnsorted.args = { - rawSeries: data.timeseriesXScaleUnsorted as any, - renderingContext, +export const TimeseriesXScaleUnsorted = { + render: Template, + + args: { + rawSeries: data.timeseriesXScaleUnsorted as any, + renderingContext, + }, }; -export const OrdinalXScale = Template.bind({}); -OrdinalXScale.args = { - rawSeries: data.ordinalXScale as any, - renderingContext, +export const OrdinalXScale = { + render: Template, + + args: { + rawSeries: data.ordinalXScale as any, + renderingContext, + }, }; -export const TimeSeriesDataAsOrdinalXScale = Template.bind({}); -TimeSeriesDataAsOrdinalXScale.args = { - rawSeries: data.timeSeriesDataAsOrdinalXScale as any, - renderingContext, +export const TimeSeriesDataAsOrdinalXScale = { + render: Template, + + args: { + rawSeries: data.timeSeriesDataAsOrdinalXScale as any, + renderingContext, + }, }; -export const UnaggregatedOrdinal = Template.bind({}); -UnaggregatedOrdinal.args = { - rawSeries: data.unaggregatedOrdinal as any, - renderingContext, +export const UnaggregatedOrdinal = { + render: Template, + + args: { + rawSeries: data.unaggregatedOrdinal as any, + renderingContext, + }, }; -export const UnaggregatedLinear = Template.bind({}); -UnaggregatedLinear.args = { - rawSeries: data.unaggregatedLinear as any, - renderingContext, +export const UnaggregatedLinear = { + render: Template, + + args: { + rawSeries: data.unaggregatedLinear as any, + renderingContext, + }, }; -export const UnaggregatedTimeseries = Template.bind({}); -UnaggregatedTimeseries.args = { - rawSeries: data.unaggregatedTimeseries as any, - renderingContext, +export const UnaggregatedTimeseries = { + render: Template, + + args: { + rawSeries: data.unaggregatedTimeseries as any, + renderingContext, + }, }; -export const MixedAboveZero = Template.bind({}); -MixedAboveZero.args = { - rawSeries: data.mixedAboveZero as any, - renderingContext, +export const MixedAboveZero = { + render: Template, + + args: { + rawSeries: data.mixedAboveZero as any, + renderingContext, + }, }; -export const MixedBelowZero = Template.bind({}); -MixedBelowZero.args = { - rawSeries: data.mixedBelowZero as any, - renderingContext, +export const MixedBelowZero = { + render: Template, + + args: { + rawSeries: data.mixedBelowZero as any, + renderingContext, + }, }; -export const NegativeOnly = Template.bind({}); -NegativeOnly.args = { - rawSeries: data.negativeOnly as any, - renderingContext, +export const NegativeOnly = { + render: Template, + + args: { + rawSeries: data.negativeOnly as any, + renderingContext, + }, }; -export const StartsAboveZeroEndsBelow = Template.bind({}); -StartsAboveZeroEndsBelow.args = { - rawSeries: data.startsAboveZeroEndsBelow as any, - renderingContext, +export const StartsAboveZeroEndsBelow = { + render: Template, + + args: { + rawSeries: data.startsAboveZeroEndsBelow as any, + renderingContext, + }, }; -export const StartsBelowZeroEndsAbove = Template.bind({}); -StartsBelowZeroEndsAbove.args = { - rawSeries: data.startsBelowZeroEndsAbove as any, - renderingContext, +export const StartsBelowZeroEndsAbove = { + render: Template, + + args: { + rawSeries: data.startsBelowZeroEndsAbove as any, + renderingContext, + }, }; -export const StartsAboveZeroCrossesEndsAbove = Template.bind({}); -StartsAboveZeroCrossesEndsAbove.args = { - rawSeries: data.startsAboveZeroCrossesEndsAbove as any, - renderingContext, +export const StartsAboveZeroCrossesEndsAbove = { + render: Template, + + args: { + rawSeries: data.startsAboveZeroCrossesEndsAbove as any, + renderingContext, + }, }; -export const StartsBelowZeroCrossesEndsBelow = Template.bind({}); -StartsBelowZeroCrossesEndsBelow.args = { - rawSeries: data.startsBelowZeroCrossesEndsBelow as any, - renderingContext, +export const StartsBelowZeroCrossesEndsBelow = { + render: Template, + + args: { + rawSeries: data.startsBelowZeroCrossesEndsBelow as any, + renderingContext, + }, }; -export const CustomColors = Template.bind({}); -CustomColors.args = { - rawSeries: data.customColors as any, - renderingContext, +export const CustomColors = { + render: Template, + + args: { + rawSeries: data.customColors as any, + renderingContext, + }, }; -export const NoTotalTimeseries = Template.bind({}); -NoTotalTimeseries.args = { - rawSeries: data.noTotalTimeseries as any, - renderingContext, +export const NoTotalTimeseries = { + render: Template, + + args: { + rawSeries: data.noTotalTimeseries as any, + renderingContext, + }, }; -export const NoTotalOrdinal = Template.bind({}); -NoTotalOrdinal.args = { - rawSeries: data.noTotalOrdinal as any, - renderingContext, +export const NoTotalOrdinal = { + render: Template, + + args: { + rawSeries: data.noTotalOrdinal as any, + renderingContext, + }, }; -export const DataLabels = Template.bind({}); -DataLabels.args = { - rawSeries: data.dataLabels as any, - renderingContext, +export const DataLabels = { + render: Template, + + args: { + rawSeries: data.dataLabels as any, + renderingContext, + }, }; -export const DataLabelsColumnFormatting = Template.bind({}); -DataLabelsColumnFormatting.args = { - rawSeries: data.dataLabelsColumnFormatting as any, - renderingContext, +export const DataLabelsColumnFormatting = { + render: Template, + + args: { + rawSeries: data.dataLabelsColumnFormatting as any, + renderingContext, + }, }; -export const DataLabelsTimeseries = Template.bind({}); -DataLabelsTimeseries.args = { - rawSeries: data.dataLabelsTimeseries as any, - renderingContext, +export const DataLabelsTimeseries = { + render: Template, + + args: { + rawSeries: data.dataLabelsTimeseries as any, + renderingContext, + }, }; -export const DataLabelsMixed = Template.bind({}); -DataLabelsMixed.args = { - rawSeries: data.dataLabelsMixed as any, - renderingContext, +export const DataLabelsMixed = { + render: Template, + + args: { + rawSeries: data.dataLabelsMixed as any, + renderingContext, + }, }; -export const PowYScale = Template.bind({}); -PowYScale.args = { - rawSeries: data.powYScale as any, - renderingContext, +export const PowYScale = { + render: Template, + + args: { + rawSeries: data.powYScale as any, + renderingContext, + }, }; -export const PowYScaleNegativeOnly = Template.bind({}); -PowYScaleNegativeOnly.args = { - rawSeries: data.powYScaleNegativeOnly as any, - renderingContext, +export const PowYScaleNegativeOnly = { + render: Template, + + args: { + rawSeries: data.powYScaleNegativeOnly as any, + renderingContext, + }, }; -export const PowYScaleMixed = Template.bind({}); -PowYScaleMixed.args = { - rawSeries: data.powYScaleMixed as any, - renderingContext, +export const PowYScaleMixed = { + render: Template, + + args: { + rawSeries: data.powYScaleMixed as any, + renderingContext, + }, }; -export const LogYScale = Template.bind({}); -LogYScale.args = { - rawSeries: data.logYScale as any, - renderingContext, +export const LogYScale = { + render: Template, + + args: { + rawSeries: data.logYScale as any, + renderingContext, + }, }; -export const LogYScaleNegative = Template.bind({}); -LogYScaleNegative.args = { - rawSeries: data.logYScaleNegative as any, - renderingContext, +export const LogYScaleNegative = { + render: Template, + + args: { + rawSeries: data.logYScaleNegative as any, + renderingContext, + }, }; -export const NativeTimeSeriesQuarter = Template.bind({}); -NativeTimeSeriesQuarter.args = { - rawSeries: data.nativeTimeSeriesQuarter as any, - renderingContext, +export const NativeTimeSeriesQuarter = { + render: Template, + + args: { + rawSeries: data.nativeTimeSeriesQuarter as any, + renderingContext, + }, }; -export const NativeTimeSeriesWithGaps = Template.bind({}); -NativeTimeSeriesWithGaps.args = { - rawSeries: data.nativeTimeSeriesWithGaps as any, - renderingContext, +export const NativeTimeSeriesWithGaps = { + render: Template, + + args: { + rawSeries: data.nativeTimeSeriesWithGaps as any, + renderingContext, + }, }; -export const StructuredTimeSeriesYear = Template.bind({}); -StructuredTimeSeriesYear.args = { - rawSeries: data.structuredTimeSeriesYear as any, - renderingContext, +export const StructuredTimeSeriesYear = { + render: Template, + + args: { + rawSeries: data.structuredTimeSeriesYear as any, + renderingContext, + }, }; -export const TimeXScaleTwoBarsWithoutTotal = Template.bind({}); -TimeXScaleTwoBarsWithoutTotal.args = { - rawSeries: data.timeXScaleTwoBarsWithoutTotal as any, - renderingContext, +export const TimeXScaleTwoBarsWithoutTotal = { + render: Template, + + args: { + rawSeries: data.timeXScaleTwoBarsWithoutTotal as any, + renderingContext, + }, }; -export const EnourmousDataset = Template.bind({}); -EnourmousDataset.args = { - rawSeries: data.enormousDataset as any, - renderingContext, +export const EnourmousDataset = { + render: Template, + + args: { + rawSeries: data.enormousDataset as any, + renderingContext, + }, }; -export const Nulls = Template.bind({}); -Nulls.args = { - rawSeries: data.nulls as any, - renderingContext, +export const Nulls = { + render: Template, + + args: { + rawSeries: data.nulls as any, + renderingContext, + }, }; -export const NullXAxisValue = Template.bind({}); -NullXAxisValue.args = { - rawSeries: data.nullXAxisValue as any, - renderingContext, +export const NullXAxisValue = { + render: Template, + + args: { + rawSeries: data.nullXAxisValue as any, + renderingContext, + }, }; -export const LinearNullDimension = Template.bind({}); -LinearNullDimension.args = { - rawSeries: data.linearNullDimension as any, - renderingContext, +export const LinearNullDimension = { + render: Template, + + args: { + rawSeries: data.linearNullDimension as any, + renderingContext, + }, }; -export const OrdinalNullDimension = Template.bind({}); -OrdinalNullDimension.args = { - rawSeries: data.ordinalNullDimension as any, - renderingContext, +export const OrdinalNullDimension = { + render: Template, + + args: { + rawSeries: data.ordinalNullDimension as any, + renderingContext, + }, }; -export const TwoBarsWithTotal = Template.bind({}); -TwoBarsWithTotal.args = { - rawSeries: data.twoBarsWithTotal as any, - renderingContext, +export const TwoBarsWithTotal = { + render: Template, + + args: { + rawSeries: data.twoBarsWithTotal as any, + renderingContext, + }, }; diff --git a/frontend/src/metabase/status/components/DownloadsStatusLarge/DownloadsStatusLarge.stories.tsx b/frontend/src/metabase/status/components/DownloadsStatusLarge/DownloadsStatusLarge.stories.tsx index d4c0a2468d166f10d97c360ceefbd305776138b8..dda0566460d1fd633592b1dde5836c09a58ca056 100644 --- a/frontend/src/metabase/status/components/DownloadsStatusLarge/DownloadsStatusLarge.stories.tsx +++ b/frontend/src/metabase/status/components/DownloadsStatusLarge/DownloadsStatusLarge.stories.tsx @@ -1,61 +1,73 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import { DownloadsStatusLarge } from "./DownloadsStatusLarge"; +import { + DownloadsStatusLarge, + type DownloadsStatusLargeProps, +} from "./DownloadsStatusLarge"; export default { title: "Status/DownloadsStatusLarge", component: DownloadsStatusLarge, }; -const Template: ComponentStory<typeof DownloadsStatusLarge> = args => { +const Template: StoryFn<DownloadsStatusLargeProps> = args => { return <DownloadsStatusLarge {...args} />; }; -export const Incomplete = Template.bind({}); -Incomplete.args = { - downloads: [ - { - id: 1, - title: "is-alex?.csv", - status: "in-progress", - }, - { - id: 2, - title: "top-secret.xlsx", - status: "in-progress", - }, - ], +export const Incomplete = { + render: Template, + + args: { + downloads: [ + { + id: 1, + title: "is-alex?.csv", + status: "in-progress", + }, + { + id: 2, + title: "top-secret.xlsx", + status: "in-progress", + }, + ], + }, }; -export const Complete = Template.bind({}); -Complete.args = { - downloads: [ - { - id: 1, - title: "is-alex?.csv", - status: "complete", - }, - { - id: 2, - title: "top-secret.xlsx", - status: "complete", - }, - ], +export const Complete = { + render: Template, + + args: { + downloads: [ + { + id: 1, + title: "is-alex?.csv", + status: "complete", + }, + { + id: 2, + title: "top-secret.xlsx", + status: "complete", + }, + ], + }, }; -export const Aborted = Template.bind({}); -Aborted.args = { - downloads: [ - { - id: 1, - title: "is-alex?.csv", - status: "error", - error: "Out of memory: too many people named Alex", - }, - { - id: 2, - title: "top-secret.xlsx", - status: "error", - }, - ], +export const Aborted = { + render: Template, + + args: { + downloads: [ + { + id: 1, + title: "is-alex?.csv", + status: "error", + error: "Out of memory: too many people named Alex", + }, + { + id: 2, + title: "top-secret.xlsx", + status: "error", + }, + ], + }, }; diff --git a/frontend/src/metabase/status/components/FileUploadStatusLarge/FileUploadStatusLarge.stories.tsx b/frontend/src/metabase/status/components/FileUploadStatusLarge/FileUploadStatusLarge.stories.tsx index 5e9b6502d647f0eb46ada55e716e1a45273b301d..ec6a57a771654a7c961d38b2bd102a8add4a45d7 100644 --- a/frontend/src/metabase/status/components/FileUploadStatusLarge/FileUploadStatusLarge.stories.tsx +++ b/frontend/src/metabase/status/components/FileUploadStatusLarge/FileUploadStatusLarge.stories.tsx @@ -1,57 +1,68 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { createMockCollection } from "metabase-types/api/mocks"; -import FileUploadStatusLarge from "./FileUploadStatusLarge"; +import FileUploadStatusLarge, { + type FileUploadLargeProps, +} from "./FileUploadStatusLarge"; export default { title: "Status/FileUploadStatusLarge", component: FileUploadStatusLarge, }; -const Template: ComponentStory<typeof FileUploadStatusLarge> = args => { +const Template: StoryFn<FileUploadLargeProps> = args => { return <FileUploadStatusLarge {...args} />; }; -export const Incomplete = Template.bind({}); -Incomplete.args = { - uploads: [ - { - id: 1, - name: "Marketing UTM Q4 2022", - status: "in-progress", - collectionId: "root", - }, - ], - uploadDestination: createMockCollection({ name: "Revenue" }), - isActive: true, +export const Incomplete = { + render: Template, + + args: { + uploads: [ + { + id: 1, + name: "Marketing UTM Q4 2022", + status: "in-progress", + collectionId: "root", + }, + ], + uploadDestination: createMockCollection({ name: "Revenue" }), + isActive: true, + }, }; -export const Complete = Template.bind({}); -Complete.args = { - uploads: [ - { - id: 1, - name: "Marketing UTM Q4 2022", - status: "complete", - collectionId: "root", - }, - ], - uploadDestination: createMockCollection({ name: "Revenue" }), - isActive: true, +export const Complete = { + render: Template, + + args: { + uploads: [ + { + id: 1, + name: "Marketing UTM Q4 2022", + status: "complete", + collectionId: "root", + }, + ], + uploadDestination: createMockCollection({ name: "Revenue" }), + isActive: true, + }, }; -export const Aborted = Template.bind({}); -Aborted.args = { - uploads: [ - { - id: 1, - name: "Marketing UTM Q4 2022", - status: "error", - collectionId: "root", - message: "It's dead Jim", - }, - ], - uploadDestination: createMockCollection({ name: "Revenue" }), - isActive: true, +export const Aborted = { + render: Template, + + args: { + uploads: [ + { + id: 1, + name: "Marketing UTM Q4 2022", + status: "error", + collectionId: "root", + message: "It's dead Jim", + }, + ], + uploadDestination: createMockCollection({ name: "Revenue" }), + isActive: true, + }, }; diff --git a/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.stories.tsx b/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.stories.tsx index 572724f4b09be36336976059873caee109b2a206..508320e013a842a8f1831cd0b79c0fb964b1d227 100644 --- a/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.stories.tsx +++ b/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { useState } from "react"; import type { Timeline } from "metabase-types/api"; @@ -7,41 +7,44 @@ import { createMockTimeline, } from "metabase-types/api/mocks"; -import TimelinePicker from "./TimelinePicker"; +import TimelinePicker, { type TimelinePickerProps } from "./TimelinePicker"; export default { title: "Timelines/TimelinePicker", component: TimelinePicker, }; -const Template: ComponentStory<typeof TimelinePicker> = args => { +const Template: StoryFn<TimelinePickerProps> = args => { const [value, setValue] = useState<Timeline>(); return <TimelinePicker {...args} value={value} onChange={setValue} />; }; -export const Default = Template.bind({}); -Default.args = { - options: [ - createMockTimeline({ - id: 1, - name: "Product communications", - collection: createMockCollection({ - name: "Our analytics", +export const Default = { + render: Template, + + args: { + options: [ + createMockTimeline({ + id: 1, + name: "Product communications", + collection: createMockCollection({ + name: "Our analytics", + }), }), - }), - createMockTimeline({ - id: 2, - name: "Releases", - collection: createMockCollection({ - name: "Our analytics", + createMockTimeline({ + id: 2, + name: "Releases", + collection: createMockCollection({ + name: "Our analytics", + }), }), - }), - createMockTimeline({ - id: 3, - name: "Our analytics events", - collection: createMockCollection({ - name: "Our analytics", + createMockTimeline({ + id: 3, + name: "Our analytics events", + collection: createMockCollection({ + name: "Our analytics", + }), }), - }), - ], + ], + }, }; diff --git a/frontend/src/metabase/ui/Intro.stories.mdx b/frontend/src/metabase/ui/Intro.mdx similarity index 84% rename from frontend/src/metabase/ui/Intro.stories.mdx rename to frontend/src/metabase/ui/Intro.mdx index 8274d011095629413ea7dd40f61dea6ff045b195..53d3bc0bf28720c4af430429b57c776914b02fff 100644 --- a/frontend/src/metabase/ui/Intro.stories.mdx +++ b/frontend/src/metabase/ui/Intro.mdx @@ -14,9 +14,9 @@ We're currently in the process of "migrating" our existing components to Mantine ## How we'll integrate Mantine -- Mantine based components will live in this directory inside of a categorical folder and be themed to match our design system using Mantine's theming system. -- We'll create a lightweight named export around the Mantine component to help make it clear which Mantine components have been vetted and documented by the design team. Nothing about the component's props or functionality should change. -- Each component will have an associated storybook story with links to Mantine's documentation for the version we're using as well as a link to the component file in our Figma component library. -- Once a component has been "released" we'll move the existing component to a `deprecated` folder and add a deprecation warning. +* Mantine based components will live in this directory inside of a categorical folder and be themed to match our design system using Mantine's theming system. +* We'll create a lightweight named export around the Mantine component to help make it clear which Mantine components have been vetted and documented by the design team. Nothing about the component's props or functionality should change. +* Each component will have an associated storybook story with links to Mantine's documentation for the version we're using as well as a link to the component file in our Figma component library. +* Once a component has been "released" we'll move the existing component to a `deprecated` folder and add a deprecation warning. Please reach out in the [#proj-ui-library](https://metaboat.slack.com/archives/C057WD5L0JG) slack channel, or talk to Kyle if you have any questions. diff --git a/frontend/src/metabase/ui/components/buttons/ActionIcon/ActionIcon.styled.tsx b/frontend/src/metabase/ui/components/buttons/ActionIcon/ActionIcon.styled.tsx index 022b7d8ed3eedf8795c94448e01812007864f41a..beb59d704f44ec717d834561a035b9090d22de76 100644 --- a/frontend/src/metabase/ui/components/buttons/ActionIcon/ActionIcon.styled.tsx +++ b/frontend/src/metabase/ui/components/buttons/ActionIcon/ActionIcon.styled.tsx @@ -22,19 +22,6 @@ export const getActionIconOverrides = }, }, }), - filled: (theme, params) => ({ - root: { - color: theme.fn.themeColor("white"), - backgroundColor: theme.fn.themeColor(params.color), - border: `1px solid ${theme.fn.themeColor(params.color)}`, - transition: "background 300ms linear, border 300ms linear", - "&:hover": { - backgroundColor: theme.fn.themeColor("white"), - border: `1px solid ${theme.fn.themeColor(params.color)}`, - color: theme.fn.themeColor(params.color), - }, - }, - }), viewHeader: theme => ({ root: { color: theme.fn.themeColor("text-dark"), diff --git a/frontend/src/metabase/ui/components/buttons/Button/Button.mdx b/frontend/src/metabase/ui/components/buttons/Button/Button.mdx new file mode 100644 index 0000000000000000000000000000000000000000..e5155eeba1acab3cbc5a60c641fe1c3e97a90136 --- /dev/null +++ b/frontend/src/metabase/ui/components/buttons/Button/Button.mdx @@ -0,0 +1,139 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Icon } from "metabase/ui"; +import { Group, Stack } from "metabase/ui"; +import { Button } from "./"; +import * as ButtonStories from "./Button.stories"; + +<Meta of={ButtonStories} /> + +# Button + +Our themed wrapper around [Mantine Button](https://v6.mantine.dev/core/button/). + +## When to use Button + +Use button in the following cases: + +- Buttons are widely used as actions that users can take. They are typically placed on the UI such as dialogs, forms, toolbars, config pages/panels, headers etc. +- Use primary button for primarily intended actions. +- Use icon in primary button for improved visual affordance. +- Primary buttons could be colorized with our brand colors for differentiating actions and states. + +Not to use: + +- Avoid using multiple primary buttons on a form section, or a dialog for competing primarily intended actions. +- If there is no primarily intended action for a form, use default (non-primary) button or other variations. That is, you don’t have to have a primary button on the form or dialog. +- Not to use links to replace subtle buttons. Links are meant to navigate to another page. Subtle buttons share the characteristics of button and are mostly used in compact or inline situations. + +## Docs + +- [Figma File](https://www.figma.com/file/Ey1rOyIxRHpmRvE9XrGyop/Buttons-%2F-Button?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) +- [Mantine Button Docs](https://v6.mantine.dev/core/button/) + +## Caveats + +- Use verb, or verb+noun for button labels. E.g., Save, Filter, Summarize, Convert this question to SQL. +- Use sentence casing for long button labels. + +## Usage guidelines + +- Although button labels can be as long as you wish, keep it simple and concise. +- In general, use sentence casing for menu item labels. +- Use icon+button label when applicable for better visual affordance. E.g., + New + +## Examples + +<Canvas> + <Story of={ButtonStories.Default} /> +</Canvas> + +### Button.Group + +<Canvas> + <Story of={ButtonStories.ButtonGroup} /> +</Canvas> + +### Default size + +<Canvas> + <Story of={ButtonStories.DefaultSize} /> +</Canvas> + +#### Custom color + +<Canvas> + <Story of={ButtonStories.DefaultSizeCustomColor} /> +</Canvas> + +#### Disabled state + +<Canvas> + <Story of={ButtonStories.DefaultSizeDisabled} /> +</Canvas> + +#### Loading state + +<Canvas> + <Story of={ButtonStories.DefaultSizeLoading} /> +</Canvas> + +### Default size & full width + +<Canvas> + <Story of={ButtonStories.DefaultSizeFullWidth} /> +</Canvas> + +#### Disabled state + +<Canvas> + <Story of={ButtonStories.DefaultSizeFullWidthDisabled} /> +</Canvas> + +#### Loading state + +<Canvas> + <Story of={ButtonStories.DefaultSizeFullWidthLoading} /> +</Canvas> + +### Compact size + +<Canvas> + <Story of={ButtonStories.CompactSize} /> +</Canvas> + +#### Custom color + +<Canvas> + <Story of={ButtonStories.CompactSizeCustomColor} /> +</Canvas> + +#### Disabled state + +<Canvas> + <Story of={ButtonStories.CompactSizeDisabled} /> +</Canvas> + +#### Loading state + +<Canvas> + <Story of={ButtonStories.CompactSizeLoading} /> +</Canvas> + +### Compact size & full width + +<Canvas> + <Story of={ButtonStories.CompactSizeFullWidth} /> +</Canvas> + +#### Disabled state + +<Canvas> + <Story of={ButtonStories.CompactSizeFullWidthDisabled} /> +</Canvas> + +#### Loading state + +<Canvas> + <Story of={ButtonStories.CompactSizeFullWidthLoading} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/buttons/Button/Button.stories.mdx b/frontend/src/metabase/ui/components/buttons/Button/Button.stories.mdx deleted file mode 100644 index 0ac4183eb3db862ad6cfd5d2a5bf802bb0a7a7b8..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/buttons/Button/Button.stories.mdx +++ /dev/null @@ -1,341 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Icon } from "metabase/ui"; -import { Group, Stack } from "metabase/ui"; -import { Button } from "./"; - -export const args = { - variant: "default", - color: undefined, - compact: false, - disabled: false, - fullWidth: false, - radius: "md", - loading: false, - loaderPosition: "left", -}; - -export const argTypes = { - variant: { - options: ["default", "filled", "outline", "subtle"], - control: { type: "inline-radio" }, - }, - color: { - options: { default: undefined, success: "success", error: "error" }, - control: { type: "inline-radio" }, - }, - compact: { - control: { type: "boolean" }, - }, - disabled: { - control: { type: "boolean" }, - }, - fullWidth: { - control: { type: "boolean" }, - }, - radius: { - options: ["md", "xl"], - control: { type: "inline-radio" }, - }, - loading: { - control: { type: "boolean" }, - }, - loaderPosition: { - options: ["left", "right"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Buttons/Button" - component={Button} - args={args} - argTypes={argTypes} -/> - -# Button - -Our themed wrapper around [Mantine Button](https://v6.mantine.dev/core/button/). - -## When to use Button - -Use button in the following cases: - -- Buttons are widely used as actions that users can take. They are typically placed on the UI such as dialogs, forms, toolbars, config pages/panels, headers etc. -- Use primary button for primarily intended actions. -- Use icon in primary button for improved visual affordance. -- Primary buttons could be colorized with our brand colors for differentiating actions and states. - -Not to use: - -- Avoid using multiple primary buttons on a form section, or a dialog for competing primarily intended actions. -- If there is no primarily intended action for a form, use default (non-primary) button or other variations. That is, you don’t have to have a primary button on the form or dialog. -- Not to use links to replace subtle buttons. Links are meant to navigate to another page. Subtle buttons share the characteristics of button and are mostly used in compact or inline situations. - -## Docs - -- [Figma File](https://www.figma.com/file/Ey1rOyIxRHpmRvE9XrGyop/Buttons-%2F-Button?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) -- [Mantine Button Docs](https://v6.mantine.dev/core/button/) - -## Caveats - -- Use verb, or verb+noun for button labels. E.g., Save, Filter, Summarize, Convert this question to SQL. -- Use sentence casing for long button labels. - -## Usage guidelines - -- Although button labels can be as long as you wish, keep it simple and concise. -- In general, use sentence casing for menu item labels. -- Use icon+button label when applicable for better visual affordance. E.g., + New - -## Examples - -export const DefaultTemplate = args => <Button {...args}>Button</Button>; - -export const ButtonGroupTemplate = args => ( - <Button.Group> - <Button {...args}>One</Button> - <Button {...args}>Two</Button> - <Button {...args}>Three</Button> - </Button.Group> -); - -export const GridRow = args => ( - <Group noWrap> - <Button {...args}>Save</Button> - <Button {...args} leftIcon={<Icon name="add" />}> - New - </Button> - <Button {...args} rightIcon={<Icon name="chevrondown" />}> - Category - </Button> - <Button {...args} leftIcon={<Icon name="play" />} /> - </Group> -); - -export const GridRowGroup = args => ( - <Fragment> - <GridRow {...args} /> - <GridRow {...args} radius="xl" /> - </Fragment> -); - -export const GridTemplate = args => ( - <Stack> - <GridRowGroup {...args} variant="filled" /> - <GridRowGroup {...args} variant="outline" /> - <GridRowGroup {...args} variant="default" /> - <GridRow {...args} variant="subtle" /> - </Stack> -); - -export const LoadingGridRow = args => ( - <Group noWrap> - <Button {...args} loaderPosition="left"> - Save - </Button> - <Button {...args} loaderPosition="right"> - Save - </Button> - <Button {...args} leftIcon={<Icon name="play" />} /> - </Group> -); - -export const LoadingGridRowGroup = args => ( - <Fragment> - <LoadingGridRow {...args} /> - <LoadingGridRow {...args} radius="xl" /> - </Fragment> -); - -export const LoadingGridTemplate = args => ( - <Stack> - <LoadingGridRowGroup {...args} variant="filled" /> - <LoadingGridRowGroup {...args} variant="outline" /> - <LoadingGridRowGroup {...args} variant="default" /> - <LoadingGridRow {...args} variant="subtle" /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Button.Group - -export const ButtonGroup = ButtonGroupTemplate.bind({}); - -<Canvas> - <Story name="Button group">{ButtonGroup}</Story> -</Canvas> - -### Default size - -export const DefaultGrid = GridTemplate.bind({}); - -<Canvas> - <Story name="Default size">{DefaultGrid}</Story> -</Canvas> - -#### Custom color - -export const CustomColorGrid = GridTemplate.bind({}); -CustomColorGrid.args = { - color: "error", -}; - -<Canvas> - <Story name="Default size, custom color">{CustomColorGrid}</Story> -</Canvas> - -#### Disabled state - -export const DefaultDisabledGrid = GridTemplate.bind({}); -DefaultDisabledGrid.args = { - disabled: true, -}; - -<Canvas> - <Story name="Default size, disabled">{DefaultDisabledGrid}</Story> -</Canvas> - -#### Loading state - -export const DefaultLoadingGrid = LoadingGridTemplate.bind({}); -DefaultLoadingGrid.args = { - loading: true, -}; - -<Canvas> - <Story name="Default size, loading">{DefaultLoadingGrid}</Story> -</Canvas> - -### Default size & full width - -export const DefaultFullWidthGrid = GridTemplate.bind({}); -DefaultFullWidthGrid.args = { - fullWidth: true, -}; - -<Canvas> - <Story name="Default size, full width">{DefaultFullWidthGrid}</Story> -</Canvas> - -#### Disabled state - -export const DefaultDisabledFullWidthGrid = GridTemplate.bind({}); -DefaultDisabledFullWidthGrid.args = { - disabled: true, - fullWidth: true, -}; - -<Canvas> - <Story name="Default size, full width, disabled"> - {DefaultDisabledFullWidthGrid} - </Story> -</Canvas> - -#### Loading state - -export const DefaultLoadingFullWidthGrid = LoadingGridTemplate.bind({}); -DefaultLoadingFullWidthGrid.args = { - loading: true, - fullWidth: true, -}; - -<Canvas> - <Story name="Default size, full width, loading"> - {DefaultLoadingFullWidthGrid} - </Story> -</Canvas> - -### Compact size - -export const CompactGrid = GridTemplate.bind({}); -CompactGrid.args = { - compact: true, -}; - -<Canvas> - <Story name="Compact size">{CompactGrid}</Story> -</Canvas> - -#### Custom color - -export const CompactCustomColorGrid = GridTemplate.bind({}); -CompactCustomColorGrid.args = { - color: "error", - compact: true, -}; - -<Canvas> - <Story name="Compact size, custom color">{CompactCustomColorGrid}</Story> -</Canvas> - -#### Disabled state - -export const CompactDisabledGrid = GridTemplate.bind({}); -CompactDisabledGrid.args = { - compact: true, - disabled: true, -}; - -<Canvas> - <Story name="Compact size, disabled">{CompactDisabledGrid}</Story> -</Canvas> - -#### Loading state - -export const CompactLoadingGrid = LoadingGridTemplate.bind({}); -CompactLoadingGrid.args = { - compact: true, - loading: true, -}; - -<Canvas> - <Story name="Compact size, loading">{CompactLoadingGrid}</Story> -</Canvas> - -### Compact size & full width - -export const CompactFullWidthGrid = GridTemplate.bind({}); -CompactFullWidthGrid.args = { - compact: true, - fullWidth: true, -}; - -<Canvas> - <Story name="Compact size, full width">{CompactFullWidthGrid}</Story> -</Canvas> - -#### Disabled state - -export const CompactDisabledFullWidthGrid = GridTemplate.bind({}); -CompactDisabledFullWidthGrid.args = { - compact: true, - disabled: true, - fullWidth: true, -}; - -<Canvas> - <Story name="Compact size, full width, disabled"> - {CompactDisabledFullWidthGrid} - </Story> -</Canvas> - -#### Loading state - -export const CompactLoadingFullWidthGrid = LoadingGridTemplate.bind({}); -CompactLoadingFullWidthGrid.args = { - compact: true, - loading: true, - fullWidth: true, -}; - -<Canvas> - <Story name="Compact size, full width, loading"> - {CompactLoadingFullWidthGrid} - </Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/buttons/Button/Button.stories.tsx b/frontend/src/metabase/ui/components/buttons/Button/Button.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a4efcb60483abeb30d3fb39035f5e3ac0a8bc88 --- /dev/null +++ b/frontend/src/metabase/ui/components/buttons/Button/Button.stories.tsx @@ -0,0 +1,252 @@ +import { Fragment } from "react"; + +import { Group, Icon, Stack } from "metabase/ui"; + +import { Button, type ButtonProps } from "./"; + +const args = { + variant: "default", + color: undefined, + compact: false, + disabled: false, + fullWidth: false, + radius: "md", + loading: false, + loaderPosition: "left", +}; + +const argTypes = { + variant: { + options: ["default", "filled", "outline", "subtle"], + control: { type: "inline-radio" }, + }, + color: { + options: { default: undefined, success: "success", error: "error" }, + control: { type: "inline-radio" }, + }, + compact: { + control: { type: "boolean" }, + }, + disabled: { + control: { type: "boolean" }, + }, + fullWidth: { + control: { type: "boolean" }, + }, + radius: { + options: ["md", "xl"], + control: { type: "inline-radio" }, + }, + loading: { + control: { type: "boolean" }, + }, + loaderPosition: { + options: ["left", "right"], + control: { type: "inline-radio" }, + }, +}; + +const DefaultTemplate = (args: ButtonProps) => ( + <Button {...args}>Button</Button> +); + +const ButtonGroupTemplate = (args: ButtonProps) => ( + <Button.Group> + <Button {...args}>One</Button> + <Button {...args}>Two</Button> + <Button {...args}>Three</Button> + </Button.Group> +); + +const GridRow = (args: ButtonProps) => ( + <Group noWrap> + <Button {...args}>Save</Button> + <Button {...args} leftIcon={<Icon name="add" />}> + New + </Button> + <Button {...args} rightIcon={<Icon name="chevrondown" />}> + Category + </Button> + <Button {...args} leftIcon={<Icon name="play" />} /> + </Group> +); + +const GridRowGroup = (args: ButtonProps) => ( + <Fragment> + <GridRow {...args} /> + <GridRow {...args} radius="xl" /> + </Fragment> +); + +const GridTemplate = (args: ButtonProps) => ( + <Stack> + <GridRowGroup {...args} variant="filled" /> + <GridRowGroup {...args} variant="outline" /> + <GridRowGroup {...args} variant="default" /> + <GridRow {...args} variant="subtle" /> + </Stack> +); + +const LoadingGridRow = (args: ButtonProps) => ( + <Group noWrap> + <Button {...args} loaderPosition="left"> + Save + </Button> + <Button {...args} loaderPosition="right"> + Save + </Button> + <Button {...args} leftIcon={<Icon name="play" />} /> + </Group> +); + +const LoadingGridRowGroup = (args: ButtonProps) => ( + <Fragment> + <LoadingGridRow {...args} /> + <LoadingGridRow {...args} radius="xl" /> + </Fragment> +); + +const LoadingGridTemplate = (args: ButtonProps) => ( + <Stack> + <LoadingGridRowGroup {...args} variant="filled" /> + <LoadingGridRowGroup {...args} variant="outline" /> + <LoadingGridRowGroup {...args} variant="default" /> + <LoadingGridRow {...args} variant="subtle" /> + </Stack> +); + +export default { + title: "Buttons/Button", + component: Button, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const ButtonGroup = { + render: ButtonGroupTemplate, + name: "Button group", +}; + +export const DefaultSize = { + render: GridTemplate, + name: "Default size", +}; + +export const DefaultSizeCustomColor = { + render: ButtonGroupTemplate, + name: "Default size, custom color", + args: { + color: "error", + }, +}; + +export const DefaultSizeDisabled = { + render: GridTemplate, + name: "Default size, disabled", + args: { + disabled: true, + }, +}; + +export const DefaultSizeLoading = { + render: LoadingGridTemplate, + name: "Default size, loading", + args: { + loading: true, + }, +}; + +export const DefaultSizeFullWidth = { + render: GridTemplate, + name: "Default size, full width", + args: { + fullWidth: true, + }, +}; + +export const DefaultSizeFullWidthDisabled = { + render: GridTemplate, + name: "Default size, full width, disabled", + args: { + disabled: true, + fullWidth: true, + }, +}; + +export const DefaultSizeFullWidthLoading = { + render: LoadingGridTemplate, + name: "Default size, full width, loading", + args: { + loading: true, + fullWidth: true, + }, +}; + +export const CompactSize = { + render: GridTemplate, + name: "Compact size", + args: { + compact: true, + }, +}; + +export const CompactSizeCustomColor = { + render: GridTemplate, + name: "Compact size, custom color", + args: { + color: "error", + compact: true, + }, +}; + +export const CompactSizeDisabled = { + render: GridTemplate, + name: "Compact size, disabled", + args: { + compact: true, + disabled: true, + }, +}; + +export const CompactSizeLoading = { + render: LoadingGridTemplate, + name: "Compact size, loading", + args: { + compact: true, + loading: true, + }, +}; + +export const CompactSizeFullWidth = { + render: GridTemplate, + name: "Compact size, full width", + args: { + compact: true, + fullWidth: true, + }, +}; + +export const CompactSizeFullWidthDisabled = { + render: GridTemplate, + name: "Compact size, full width, disabled", + args: { + compact: true, + disabled: true, + fullWidth: true, + }, +}; + +export const CompactSizeFullWidthLoading = { + render: LoadingGridTemplate, + name: "Compact size, full width, loading", + args: { + compact: true, + loading: true, + fullWidth: true, + }, +}; diff --git a/frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.mdx b/frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.mdx new file mode 100644 index 0000000000000000000000000000000000000000..5cc8f9de52004e79f8333d3d696ce14940835212 --- /dev/null +++ b/frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.mdx @@ -0,0 +1,14 @@ +import { useState } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Button, Flex, Menu, PopoverBackButton } from "metabase/ui"; +import * as PopoverBackButtonStories from "./PopoverBackButton.stories"; + +<Meta of={PopoverBackButtonStories} /> + +# PopoverBackButton + +## Examples + +<Canvas> + <Story of={PopoverBackButtonStories.Default} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.stories.mdx b/frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.stories.tsx similarity index 65% rename from frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.stories.mdx rename to frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.stories.tsx index f8ff17dbcd19a19ad48859a23bf44014d7bf8021..670ed008c261f952fafffba6a851284974bdd659 100644 --- a/frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.stories.mdx +++ b/frontend/src/metabase/ui/components/buttons/PopoverBackButton/PopoverBackButton.stories.tsx @@ -1,27 +1,23 @@ import { useState } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Button, Flex, Menu, PopoverBackButton } from "metabase/ui"; -export const args = { +import { + Box, + Button, + Flex, + Menu, + PopoverBackButton, + type PopoverBackButtonProps, +} from "metabase/ui"; + +const args = { children: "Back", }; -export const argTypes = { +const argTypes = { children: { type: "string" }, }; -<Meta - title="Buttons/PopoverBackButton" - component={PopoverBackButton} - args={args} - argTypes={argTypes} -/> - -# PopoverBackButton - -## Examples - -export const DefaultTemplate = args => { +const DefaultTemplate = (args: PopoverBackButtonProps) => { const [isNestedPopoverOpen, setIsNestedPopoverOpen] = useState(false); return ( <Flex justify="center"> @@ -54,8 +50,17 @@ export const DefaultTemplate = args => { ); }; -export const Default = DefaultTemplate.bind(args); +export default { + title: "Buttons/PopoverBackButton", + component: PopoverBackButton, + args, + argTypes, +}; -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> +export const Default = { + render: DefaultTemplate, + name: "Default", + args: { + children: "Back", + }, +}; diff --git a/frontend/src/metabase/ui/components/data-display/Card/Card.mdx b/frontend/src/metabase/ui/components/data-display/Card/Card.mdx new file mode 100644 index 0000000000000000000000000000000000000000..3445e5c2515240f39818232060f70ad61defe120 --- /dev/null +++ b/frontend/src/metabase/ui/components/data-display/Card/Card.mdx @@ -0,0 +1,38 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Card, Stack, Text } from "metabase/ui"; +import * as CardStories from "./Card.stories"; + +<Meta of={CardStories} /> + +# Card + +Our themed wrapper around [Mantine Card](https://v6.mantine.dev/core/card/). + +## Docs + +- [Mantine Card Docs](https://v6.mantine.dev/core/card/) + +## Examples + +<Canvas> + <Story of={CardStories.Default} /> +</Canvas> + +### Card border + +<Canvas> + <Story of={CardStories.Border} /> +</Canvas> + +### Card.Section + +<Canvas> + <Story of={CardStories.CardSection} /> +</Canvas> + +### Card.Section border + +<Canvas> + <Story of={CardStories.CardSectionBorder} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/data-display/Card/Card.stories.mdx b/frontend/src/metabase/ui/components/data-display/Card/Card.stories.mdx deleted file mode 100644 index a9fdf091257a6f51f44884b0c534bf50686f739f..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/data-display/Card/Card.stories.mdx +++ /dev/null @@ -1,108 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Card, Stack, Text } from "metabase/ui"; - -export const args = { - p: "md", - radius: "md", - withBorder: false, -}; - -export const sampleArgs = { - title: "Peace", - description: - "The elm tree planted by Eleanor Bold, the judge’s daughter, fell last night.", -}; - -export const argTypes = { - p: { - options: ["xs", "sm", "md", "lg", "xl"], - control: { type: "inline-radio" }, - }, - radius: { - options: ["xs", "sm", "md"], - control: { type: "inline-radio" }, - }, - withBorder: { - control: { type: "boolean" }, - }, -}; - -<Meta - title="Data display/Card" - component={Card} - args={args} - argTypes={argTypes} -/> - -# Card - -Our themed wrapper around [Mantine Card](https://v6.mantine.dev/core/card/). - -## Docs - -- [Mantine Card Docs](https://v6.mantine.dev/core/card/) - -## Examples - -export const DefaultTemplate = args => ( - <Box maw="20rem"> - <Card {...args}> - <Stack spacing="sm"> - <Text fw="bold">{sampleArgs.title}</Text> - <Text>{sampleArgs.description}</Text> - </Stack> - </Card> - </Box> -); - -export const CardSectionTemplate = ({ withSectionBorder, ...args }) => ( - <Box maw="20rem"> - <Card {...args}> - <Card.Section withBorder={withSectionBorder}> - <Box bg="bg" h="10rem" /> - </Card.Section> - <Stack mt="md" spacing="sm"> - <Text fw="bold">{sampleArgs.title}</Text> - <Text>{sampleArgs.description}</Text> - </Stack> - </Card> - </Box> -); - -export const CardSectionBorderTemplate = args => ( - <CardSectionTemplate {...args} withSectionBorder /> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Card border - -export const Border = DefaultTemplate.bind({}); -Border.args = { - withBorder: true, -}; - -<Canvas> - <Story name="Border">{Border}</Story> -</Canvas> - -### Card.Section - -export const CardSection = CardSectionTemplate.bind({}); - -<Canvas> - <Story name="Card section">{CardSection}</Story> -</Canvas> - -### Card.Section border - -export const CardSectionBorder = CardSectionBorderTemplate.bind({}); - -<Canvas> - <Story name="Card section, border">{CardSectionBorder}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/data-display/Card/Card.stories.tsx b/frontend/src/metabase/ui/components/data-display/Card/Card.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e778a8f6e8f9e15672d5a2f350fef13de65077c --- /dev/null +++ b/frontend/src/metabase/ui/components/data-display/Card/Card.stories.tsx @@ -0,0 +1,89 @@ +import { Box, Card, type CardProps, Stack, Text } from "metabase/ui"; + +const args = { + p: "md", + radius: "md", + withBorder: false, +}; + +const sampleArgs = { + title: "Peace", + description: + "The elm tree planted by Eleanor Bold, the judge’s daughter, fell last night.", +}; + +const argTypes = { + p: { + options: ["xs", "sm", "md", "lg", "xl"], + control: { type: "inline-radio" }, + }, + radius: { + options: ["xs", "sm", "md"], + control: { type: "inline-radio" }, + }, + withBorder: { + control: { type: "boolean" }, + }, +}; + +const DefaultTemplate = (args: CardProps) => ( + <Box maw="20rem"> + <Card {...args}> + <Stack spacing="sm"> + <Text fw="bold">{sampleArgs.title}</Text> + <Text>{sampleArgs.description}</Text> + </Stack> + </Card> + </Box> +); + +const CardSectionTemplate = ({ + withSectionBorder, + ...args +}: CardProps & { withSectionBorder: boolean }) => ( + <Box maw="20rem"> + <Card {...args}> + <Card.Section withBorder={withSectionBorder}> + <Box bg="bg" h="10rem" /> + </Card.Section> + <Stack mt="md" spacing="sm"> + <Text fw="bold">{sampleArgs.title}</Text> + <Text>{sampleArgs.description}</Text> + </Stack> + </Card> + </Box> +); + +const CardSectionBorderTemplate = ( + args: CardProps & { withSectionBorder: boolean }, +) => <CardSectionTemplate {...args} withSectionBorder />; + +export default { + title: "Data display/Card", + component: Card, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const Border = { + render: DefaultTemplate, + name: "Border", + args: { + withBorder: true, + }, +}; + +export const CardSection = { + render: CardSectionTemplate, + name: "Card section", +}; + +export const CardSectionBorder = { + render: CardSectionBorderTemplate, + name: "Card section, border", +}; diff --git a/frontend/src/metabase/ui/components/data-display/Image/Image.mdx b/frontend/src/metabase/ui/components/data-display/Image/Image.mdx new file mode 100644 index 0000000000000000000000000000000000000000..96c64840747f10910d2c189723b1cee20d513d8e --- /dev/null +++ b/frontend/src/metabase/ui/components/data-display/Image/Image.mdx @@ -0,0 +1,27 @@ +import noResultsSource from "assets/img/no_results.svg"; +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Image, Stack, Text } from "metabase/ui"; +import * as ImageStories from "./Image.stories"; + +<Meta of={ImageStories} /> + +# Image + +Our themed wrapper around [Mantine Image](https://v6.mantine.dev/core/image/). + +## Docs + +- [Mantine Image Docs](https://v6.mantine.dev/core/image/) + +## Examples + +<Canvas> + <Story of={ImageStories.Default} /> +</Canvas> + +### Image position + +<Canvas> + <Story of={ImageStories.BackgroundPosition} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/data-display/Image/Image.stories.mdx b/frontend/src/metabase/ui/components/data-display/Image/Image.stories.mdx deleted file mode 100644 index 670c40d36132f2bb93f761abd27b6f19cb99f2ba..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/data-display/Image/Image.stories.mdx +++ /dev/null @@ -1,72 +0,0 @@ -import noResultsSource from "assets/img/no_results.svg"; -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Image, Stack, Text } from "metabase/ui"; - -export const args = { - width: 120, - height: 120, - fit: "cover", - position: "center", - src: noResultsSource, - alt: "No search results", -}; - -export const argTypes = { - fit: { - control: { - type: "inline-radio", - options: ["cover", "contain", "fill", "none", "scale-down"], - }, - }, - position: { - control: { - type: "inline-radio", - options: ["top left", "top", "center", "bottom", "bottom right"], - }, - }, - src: { - control: { type: "file", accept: "image/jpeg,image/png,image/svg+xml" }, - }, -}; - -<Meta - title="Data display/Image" - component={Image} - args={args} - argTypes={argTypes} -/> - -# Image - -Our themed wrapper around [Mantine Image](https://v6.mantine.dev/core/image/). - -## Docs - -- [Mantine Image Docs](https://v6.mantine.dev/core/image/) - -## Examples - -export const DefaultTemplate = args => ( - <Box maw="20rem"> - <Image {...args} /> - </Box> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Image position - -export const BackgroundPosition = DefaultTemplate.bind({}); -BackgroundPosition.args = { - width: 80, - position: "top left", -}; - -<Canvas> - <Story name="BackgroundPosition">{BackgroundPosition}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/data-display/Image/Image.stories.tsx b/frontend/src/metabase/ui/components/data-display/Image/Image.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c13a7018e6db76abc74741c61fd363185b2f1c9b --- /dev/null +++ b/frontend/src/metabase/ui/components/data-display/Image/Image.stories.tsx @@ -0,0 +1,56 @@ +import noResultsSource from "assets/img/no_results.svg"; +import { Box, Image, type ImageProps } from "metabase/ui"; + +const args = { + width: 120, + height: 120, + fit: "cover", + position: "center", + src: noResultsSource, + alt: "No search results", +}; + +const argTypes = { + fit: { + control: { + type: "inline-radio", + options: ["cover", "contain", "fill", "none", "scale-down"], + }, + }, + position: { + control: { + type: "inline-radio", + options: ["top left", "top", "center", "bottom", "bottom right"], + }, + }, + src: { + control: { type: "file", accept: "image/jpeg,image/png,image/svg+xml" }, + }, +}; + +const DefaultTemplate = (args: ImageProps) => ( + <Box maw="20rem"> + <Image {...args} /> + </Box> +); + +export default { + title: "Data display/Image", + component: Image, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const BackgroundPosition = { + render: DefaultTemplate, + name: "BackgroundPosition", + args: { + width: 80, + position: "top left", + }, +}; diff --git a/frontend/src/metabase/ui/components/feedback/Alert/Alert.mdx b/frontend/src/metabase/ui/components/feedback/Alert/Alert.mdx new file mode 100644 index 0000000000000000000000000000000000000000..52ea092facfedd30abd06e26132ea52a532ac846 --- /dev/null +++ b/frontend/src/metabase/ui/components/feedback/Alert/Alert.mdx @@ -0,0 +1,16 @@ +import { useState } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Alert, Icon, Stack, Text, TextInput } from "metabase/ui"; +import * as AlertStories from "./Alert.stories"; + +<Meta of={AlertStories} /> + +# Alert + +Show helpful alerts when things go sideways (or maybe it's a happy alert. It happens) + +## Examples + +<Canvas> + <Story of={AlertStories.Default} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.mdx b/frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.tsx similarity index 53% rename from frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.mdx rename to frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.tsx index 0a786dee893f2ba36b179bdc72ff023fae14ad62..39ee7f64fdc711f5a440d0e173fb361e30f19198 100644 --- a/frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.mdx +++ b/frontend/src/metabase/ui/components/feedback/Alert/Alert.stories.tsx @@ -1,14 +1,12 @@ -import { useState } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Alert, Icon, Stack, Text, TextInput } from "metabase/ui"; +import { Alert, type AlertProps, Icon, Text } from "metabase/ui"; -export const args = { +const args = { icon: <Icon name="warning" />, title: "Bummer!", withCloseButton: false, }; -export const argTypes = { +const argTypes = { color: { control: { type: "text" }, }, @@ -20,23 +18,10 @@ export const argTypes = { }, }; -<Meta - title="Feedback/Alert" - component={Alert} - args={args} - argTypes={argTypes} -/> - -# Alert - -Show helpful alerts when things go sideways (or maybe it's a happy alert. It happens) - -## Examples - -export const DefaultTemplate = args => { +const DefaultTemplate = (args: AlertProps) => { return ( <Alert {...args}> - <Text>The No self-service access level for View data is going away.</Text> + <Text>The No self-service access level for View data is going away.</Text> <Text> In a future release, if a group’s View data access for a database (or any of its schemas or tables) is still set to No self-service @@ -49,8 +34,14 @@ export const DefaultTemplate = args => { ); }; -export const Default = DefaultTemplate.bind({}); +export default { + title: "Feedback/Alert", + component: Alert, + args, + argTypes, +}; -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> +export const Default = { + render: DefaultTemplate, + name: "Default", +}; diff --git a/frontend/src/metabase/ui/components/feedback/Loader/Loader.mdx b/frontend/src/metabase/ui/components/feedback/Loader/Loader.mdx new file mode 100644 index 0000000000000000000000000000000000000000..6903a51af1de1fc4b0cf6484259fcaeabb251589 --- /dev/null +++ b/frontend/src/metabase/ui/components/feedback/Loader/Loader.mdx @@ -0,0 +1,33 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Grid, Text } from "metabase/ui"; +import { Loader } from "./"; +import * as LoaderStories from "./Loader.stories"; + +<Meta of={LoaderStories} /> + +# Loader + +Our themed wrapper around [Mantine Loader](https://v6.mantine.dev/core/loader/). + +## Docs + +- [Figma File](https://www.figma.com/file/NUXRUa9Ot3HvgC1WwIA4UH/Loader?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) +- [Mantine Loader Docs](https://v6.mantine.dev/core/loader/) + +## Caveats + +- Only use the `oval` variant unless otherwise called out +- Prefer usage of loader sizes less than `xl` unless specifically called out + +## Examples + +<Canvas> + <Story of={LoaderStories.Default} /> +</Canvas> + +### Sizes + +<Canvas> + <Story of={LoaderStories.Sizes} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/feedback/Loader/Loader.stories.mdx b/frontend/src/metabase/ui/components/feedback/Loader/Loader.stories.mdx deleted file mode 100644 index 057ce29a101483ec2d1e82b93ef7337d7e51ff40..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/feedback/Loader/Loader.stories.mdx +++ /dev/null @@ -1,69 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Grid, Text } from "metabase/ui"; -import { Loader } from "./"; - -export const args = { - size: "md", -}; - -export const argTypes = { - size: { - options: ["xs", "sm", "md", "lg", "xl"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Feedback/Loader" - component={Loader} - args={args} - argTypes={argTypes} -/> - -# Loader - -Our themed wrapper around [Mantine Loader](https://v6.mantine.dev/core/loader/). - -## Docs - -- [Figma File](https://www.figma.com/file/NUXRUa9Ot3HvgC1WwIA4UH/Loader?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) -- [Mantine Loader Docs](https://v6.mantine.dev/core/loader/) - -## Caveats - -- Only use the `oval` variant unless otherwise called out -- Prefer usage of loader sizes less than `xl` unless specifically called out - -## Examples - -export const DefaultTemplate = args => <Loader {...args} />; - -export const SizeTemplate = args => ( - <Grid w="10rem" columns={2} align="center"> - {argTypes.size.options.map(size => ( - <Fragment key={size}> - <Grid.Col span={1} align="center"> - <Text weight="bold">{size}</Text> - </Grid.Col> - <Grid.Col span={1} align="center"> - <Loader size={size} /> - </Grid.Col> - </Fragment> - ))} - </Grid> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Sizes - -export const Sizes = SizeTemplate.bind({}); - -<Canvas> - <Story name="Sizes">{Sizes}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/feedback/Loader/Loader.stories.tsx b/frontend/src/metabase/ui/components/feedback/Loader/Loader.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..534813a18e3b58fdd478fd46e00424bc27ca258c --- /dev/null +++ b/frontend/src/metabase/ui/components/feedback/Loader/Loader.stories.tsx @@ -0,0 +1,47 @@ +import { Fragment } from "react"; + +import { Grid, Text } from "metabase/ui"; + +import { Loader } from "./"; + +const args = { + size: "md", +}; + +const argTypes = { + size: { + options: ["xs", "sm", "md", "lg", "xl"], + control: { type: "inline-radio" }, + }, +}; + +const SizeTemplate = () => ( + <Grid w="10rem" columns={2} align="center"> + {argTypes.size.options.map(size => ( + <Fragment key={size}> + <Grid.Col span={1}> + <Text weight="bold">{size}</Text> + </Grid.Col> + <Grid.Col span={1}> + <Loader size={size} /> + </Grid.Col> + </Fragment> + ))} + </Grid> +); + +export default { + title: "Feedback/Loader", + component: Loader, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const Sizes = { + render: SizeTemplate, + name: "Sizes", +}; diff --git a/frontend/src/metabase/ui/components/feedback/Progress/Progress.mdx b/frontend/src/metabase/ui/components/feedback/Progress/Progress.mdx new file mode 100644 index 0000000000000000000000000000000000000000..754c44e4d5cd9771f44e46b821540047c7457348 --- /dev/null +++ b/frontend/src/metabase/ui/components/feedback/Progress/Progress.mdx @@ -0,0 +1,20 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Progress } from "./"; +import * as ProgressStories from "./Progress.stories"; + +<Meta of={ProgressStories} /> + +# Loader + +Our themed wrapper around [Mantine Progress](https://v6.mantine.dev/core/progress/). + +## Docs + +- [Mantine Progress Docs](https://v6.mantine.dev/core/progress/) + +## Examples + +<Canvas> + <Story of={ProgressStories.Default} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/feedback/Progress/Progress.stories.mdx b/frontend/src/metabase/ui/components/feedback/Progress/Progress.stories.mdx deleted file mode 100644 index 97e130d19402fbc9f95fab32615c5330b6071e9a..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/feedback/Progress/Progress.stories.mdx +++ /dev/null @@ -1,40 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Progress } from "./"; - -export const args = { - value: 40, -}; - -export const argTypes = { - size: { - options: ["xs", "sm", "md", "lg", "xl"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Feedback/Progress" - component={Progress} - args={args} - argTypes={argTypes} -/> - -# Loader - -Our themed wrapper around [Mantine Progress](https://v6.mantine.dev/core/progress/). - -## Docs - -- [Mantine Progress Docs](https://v6.mantine.dev/core/progress/) - - -## Examples - -export const DefaultTemplate = args => <Progress {...args} />; - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/feedback/Progress/Progress.stories.tsx b/frontend/src/metabase/ui/components/feedback/Progress/Progress.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..461dbe0228c1a6109ac8532d00a4c10eccbc372c --- /dev/null +++ b/frontend/src/metabase/ui/components/feedback/Progress/Progress.stories.tsx @@ -0,0 +1,23 @@ +import { Progress } from "./"; + +const args = { + value: 40, +}; + +const argTypes = { + size: { + options: ["xs", "sm", "md", "lg", "xl"], + control: { type: "inline-radio" }, + }, +}; + +export default { + title: "Feedback/Progress", + component: Progress, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; diff --git a/frontend/src/metabase/ui/components/icons/Icon/Icon.mdx b/frontend/src/metabase/ui/components/icons/Icon/Icon.mdx new file mode 100644 index 0000000000000000000000000000000000000000..4a50cc19549054c8a0b35f8ad9732f7c3e7226af --- /dev/null +++ b/frontend/src/metabase/ui/components/icons/Icon/Icon.mdx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Icon, Stack, Text, TextInput } from "metabase/ui"; +import { iconNames } from "./icons"; +import * as IconStories from "./Icon.stories"; + +<Meta of={IconStories} /> + +# Icon + +Our component wrapper around SVG icons. + +## Examples + +<Canvas> + <Story of={IconStories.Default} /> +</Canvas> + +### List + +<Canvas> + <Story of={IconStories.List} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/icons/Icon/Icon.stories.mdx b/frontend/src/metabase/ui/components/icons/Icon/Icon.stories.mdx deleted file mode 100644 index 5c3817051aa88366b7828377eca26d96a7e3b872..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/icons/Icon/Icon.stories.mdx +++ /dev/null @@ -1,62 +0,0 @@ -import { useState } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Icon, Stack, Text, TextInput } from "metabase/ui"; -import { iconNames } from "./icons"; - -export const args = { - name: "star", - size: undefined, - tooltip: undefined, -}; - -export const argTypes = { - name: { - control: { type: "select" }, - options: iconNames, - }, - size: { - control: { type: "number" }, - }, - tooltip: { - control: { type: "text" }, - }, -}; - -<Meta title="Icons/Icon" component={Icon} args={args} argTypes={argTypes} /> - -# Icon - -Our component wrapper around SVG icons. - -## Examples - -export const DefaultTemplate = args => { - return <Icon {...args} />; -}; - -export const ListTemplate = () => { - return ( - <Box> - {iconNames.map(icon => ( - <Box key={icon} display="inline-block" w="100px" m="20px" ta="center"> - <p>{icon}</p> - <Icon name={icon} /> - </Box> - ))} - </Box> - ); -}; - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### List - -export const List = ListTemplate.bind({}); - -<Canvas> - <Story name="List">{List}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/icons/Icon/Icon.stories.tsx b/frontend/src/metabase/ui/components/icons/Icon/Icon.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f1128f4713d46de457abab6f4b059d17d0b94fce --- /dev/null +++ b/frontend/src/metabase/ui/components/icons/Icon/Icon.stories.tsx @@ -0,0 +1,51 @@ +import { Box, Icon } from "metabase/ui"; + +import { iconNames } from "./icons"; + +const args = { + name: "star", + size: undefined, + tooltip: undefined, +}; + +const argTypes = { + name: { + control: { type: "select" }, + options: iconNames, + }, + size: { + control: { type: "number" }, + }, + tooltip: { + control: { type: "text" }, + }, +}; + +const ListTemplate = () => { + return ( + <Box> + {iconNames.map(icon => ( + <Box key={icon} display="inline-block" w="100px" m="20px" ta="center"> + <p>{icon}</p> + <Icon name={icon} /> + </Box> + ))} + </Box> + ); +}; + +export default { + title: "Icons/Icon", + component: Icon, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const List = { + render: ListTemplate, + name: "List", +}; diff --git a/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.mdx b/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.mdx new file mode 100644 index 0000000000000000000000000000000000000000..c90557864e40b53d80304d6ba86c1ec9a39a975a --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.mdx @@ -0,0 +1,127 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Autocomplete, Stack } from "metabase/ui"; +import * as AutocompleteStories from "./Autocomplete.stories"; + +<Meta of={AutocompleteStories} /> + +# Autocomplete + +Our themed wrapper around [Mantine Autocomplete](https://v6.mantine.dev/core/autocomplete/). + +## Docs + +- [Mantine Autocomplete Docs](https://v6.mantine.dev/core/autocomplete/) + +## Examples + +<Canvas> + <Story of={AutocompleteStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={AutocompleteStories.EmptyMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={AutocompleteStories.AsteriskMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={AutocompleteStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={AutocompleteStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={AutocompleteStories.ErrorMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={AutocompleteStories.ReadOnlyMd} /> +</Canvas> + +#### Icons + +<Canvas> + <Story of={AutocompleteStories.IconsMd} /> +</Canvas> + +#### Groups + +<Canvas> + <Story of={AutocompleteStories.GroupsMd} /> +</Canvas> + +#### Large sets + +<Canvas> + <Story of={AutocompleteStories.LargeSetsMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={AutocompleteStories.EmptyXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={AutocompleteStories.AsteriskXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={AutocompleteStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={AutocompleteStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={AutocompleteStories.ErrorXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={AutocompleteStories.ReadOnlyXs} /> +</Canvas> + +#### Icons + +<Canvas> + <Story of={AutocompleteStories.IconsXs} /> +</Canvas> + +#### Groups + +<Canvas> + <Story of={AutocompleteStories.GroupsXs} /> +</Canvas> + +#### Large sets + +<Canvas> + <Story of={AutocompleteStories.LargeSetsXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.stories.mdx b/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.stories.mdx deleted file mode 100644 index 02231f7528cf552d030e8e7e9f9fbe99809b0891..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.stories.mdx +++ /dev/null @@ -1,340 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Autocomplete, Stack } from "metabase/ui"; - -export const dataWithGroupsLarge = [ - { value: "Entity key", icon: "label", group: "Overall row" }, - { value: "Entity name", icon: "string", group: "Overall row" }, - { value: "Foreign key", icon: "connections", group: "Overall row" }, - { value: "Category", icon: "string", group: "Common" }, - { value: "Comment", icon: "string", group: "Common" }, - { value: "Description", icon: "string", group: "Common" }, - { value: "Title", icon: "string", group: "Common" }, - { value: "City", icon: "location", group: "Location" }, - { value: "Country", icon: "location", group: "Location" }, - { value: "Latitude", icon: "location", group: "Location" }, - { value: "Longitude", icon: "location", group: "Location" }, - { value: "Longitude", icon: "location", group: "Location" }, - { value: "State", icon: "location", group: "Location" }, - { value: "Zip code", icon: "location", group: "Location" }, -]; - -export const dataWithGroups = dataWithGroupsLarge.slice(0, 6); - -export const dataWithIcons = dataWithGroups.map(item => ({ - ...item, - group: undefined, -})); - -export const dataWithLabels = dataWithIcons.map(item => ({ - ...item, - icon: undefined, -})); - -export const args = { - data: dataWithLabels, - size: "md", - label: "Field type", - description: undefined, - error: undefined, - placeholder: "No semantic type", - limit: undefined, - disabled: false, - readOnly: false, - withAsterisk: false, - dropdownPosition: "flip", -}; - -export const sampleArgs = { - value: dataWithLabels[0].value, - description: "Determines how Metabase displays the field", - error: "required", -}; - -export const argTypes = { - data: { - control: { type: "json" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - limit: { - control: { type: "number" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, - dropdownPosition: { - options: ["bottom", "top", "flip"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Inputs/Autocomplete" - component={Autocomplete} - args={args} - argTypes={argTypes} -/> - -# Autocomplete - -Our themed wrapper around [Mantine Autocomplete](https://v6.mantine.dev/core/autocomplete/). - -## Docs - -- [Mantine Autocomplete Docs](https://v6.mantine.dev/core/autocomplete/) - -## Examples - -export const DefaultTemplate = args => <Autocomplete {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <Autocomplete {...args} /> - <Autocomplete {...args} variant="unstyled" /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - description: sampleArgs.description, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - description: sampleArgs.description, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = VariantTemplate.bind({}); -ReadOnlyMd.args = { - defaultValue: sampleArgs.value, - description: sampleArgs.description, - readOnly: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -#### Icons - -export const IconsMd = VariantTemplate.bind({}); -IconsMd.args = { - data: dataWithIcons, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Icons, md">{IconsMd}</Story> -</Canvas> - -#### Groups - -export const GroupsMd = VariantTemplate.bind({}); -GroupsMd.args = { - data: dataWithGroups, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Groups, md">{GroupsMd}</Story> -</Canvas> - -#### Large sets - -export const LargeSetsMd = VariantTemplate.bind({}); -LargeSetsMd.args = { - data: dataWithGroupsLarge, - description: sampleArgs.description, - limit: 5, - withAsterisk: true, -}; - -<Canvas> - <Story name="Large sets, md">{LargeSetsMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = VariantTemplate.bind({}); -ReadOnlyXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> - -#### Icons - -export const IconsXs = VariantTemplate.bind({}); -IconsXs.args = { - ...IconsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Icons, xs">{IconsXs}</Story> -</Canvas> - -#### Groups - -export const GroupsXs = VariantTemplate.bind({}); -GroupsXs.args = { - ...GroupsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Groups, xs">{GroupsXs}</Story> -</Canvas> - -#### Large sets - -export const LargeSetsXs = VariantTemplate.bind({}); -LargeSetsXs.args = { - ...LargeSetsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Large sets, xs">{LargeSetsXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.stories.tsx b/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad037f3d07f28a10fb5a7f8c41c5a34ffbca8c2f --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,270 @@ +import { Autocomplete, type AutocompleteProps, Stack } from "metabase/ui"; + +const dataWithGroupsLarge = [ + { value: "Entity key", icon: "label", group: "Overall row" }, + { value: "Entity name", icon: "string", group: "Overall row" }, + { value: "Foreign key", icon: "connections", group: "Overall row" }, + { value: "Category", icon: "string", group: "Common" }, + { value: "Comment", icon: "string", group: "Common" }, + { value: "Description", icon: "string", group: "Common" }, + { value: "Title", icon: "string", group: "Common" }, + { value: "City", icon: "location", group: "Location" }, + { value: "Country", icon: "location", group: "Location" }, + { value: "Latitude", icon: "location", group: "Location" }, + { value: "Longitude", icon: "location", group: "Location" }, + { value: "Longitude", icon: "location", group: "Location" }, + { value: "State", icon: "location", group: "Location" }, + { value: "Zip code", icon: "location", group: "Location" }, +]; + +const dataWithGroups = dataWithGroupsLarge.slice(0, 6); + +const dataWithIcons = dataWithGroups.map(item => ({ + ...item, + group: undefined, +})); + +const dataWithLabels = dataWithIcons.map(item => ({ + ...item, + icon: undefined, +})); + +const args = { + data: dataWithLabels, + size: "md", + label: "Field type", + description: undefined, + error: undefined, + placeholder: "No semantic type", + limit: undefined, + disabled: false, + readOnly: false, + withAsterisk: false, + dropdownPosition: "flip", +}; + +const sampleArgs = { + value: dataWithLabels[0].value, + description: "Determines how Metabase displays the field", + error: "required", +}; + +const argTypes = { + data: { + control: { type: "json" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + limit: { + control: { type: "number" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, + dropdownPosition: { + options: ["bottom", "top", "flip"], + control: { type: "inline-radio" }, + }, +}; + +const VariantTemplate = (args: AutocompleteProps) => ( + <Stack> + <Autocomplete {...args} /> + <Autocomplete {...args} variant="unstyled" /> + </Stack> +); + +export default { + title: "Inputs/Autocomplete", + component: Autocomplete, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + description: sampleArgs.description, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + description: sampleArgs.description, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: VariantTemplate, + name: "Read only, md", + args: { + defaultValue: sampleArgs.value, + description: sampleArgs.description, + readOnly: true, + withAsterisk: true, + }, +}; + +export const IconsMd = { + render: VariantTemplate, + name: "Icons, md", + args: { + data: dataWithIcons, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const GroupsMd = { + render: VariantTemplate, + name: "Groups, md", + args: { + data: dataWithGroups, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const LargeSetsMd = { + render: VariantTemplate, + name: "Large sets, md", + args: { + data: dataWithGroupsLarge, + description: sampleArgs.description, + limit: 5, + withAsterisk: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: VariantTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; + +export const IconsXs = { + render: VariantTemplate, + name: "Icons, xs", + args: { + ...IconsMd.args, + size: "xs", + }, +}; + +export const GroupsXs = { + render: VariantTemplate, + name: "Groups, xs", + args: { + ...GroupsMd.args, + size: "xs", + }, +}; + +export const LargeSetsXs = { + render: VariantTemplate, + name: "Large sets, xs", + args: { + ...LargeSetsMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.mdx b/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.mdx new file mode 100644 index 0000000000000000000000000000000000000000..98ad75ce6a4c3a24e8450c6549f6cb15661bfa65 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.mdx @@ -0,0 +1,71 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Checkbox, Stack } from "metabase/ui"; +import * as CheckboxStories from "./Checkbox.stories"; + +<Meta of={CheckboxStories} /> + +# Checkbox + +Our themed wrapper around [Mantine Checkbox](https://v6.mantine.dev/core/checkbox/). + +## When to use Checkbox + +Checkbox buttons allow users to select a single option from a list of mutually exclusive options. All possible options are exposed up front for users to compare. + +## Docs + +- [Figma File](https://www.figma.com/file/sF1qSHk6yVqO1rFgmH0nzT/Input-%2F-Checkbox?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) +- [Mantine Checkbox Docs](https://v6.mantine.dev/core/checkbox/) + +## Usage guidelines + +- **Use this component if there are more than 5 options**. If there are fewer options, feel free to check out Checkbox or Select. +- For option ordering, try to use your best judgement on a sensible order. For example, Yes should come before No. Alphabetical ordering is usually a good fallback if there's no inherent order in your set of choices. +- In almost all circumstances you'll want to use `<Checkbox.Group>` to provide a set of options and help with defaultValues and state management between them. + +## Examples + +<Canvas> + <Story of={CheckboxStories.Default} /> +</Canvas> + +### Checkbox.Group + +<Canvas> + <Story of={CheckboxStories.CheckboxGroup} /> +</Canvas> + +### Label + +<Canvas> + <Story of={CheckboxStories.Label} /> +</Canvas> + +#### Left label position + +<Canvas> + <Story of={CheckboxStories.LabelLeftPosition} /> +</Canvas> + +### Description + +<Canvas> + <Story of={CheckboxStories.Description} /> +</Canvas> + +#### Left label position + +<Canvas> + <Story of={CheckboxStories.DescriptionLeftPosition} /> +</Canvas> + +#### Stacked Variant + +<Canvas> + <Story of={CheckboxStories.Stacked} /> +</Canvas> + +## Related components + +- Radio +- Select diff --git a/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.stories.mdx b/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.stories.mdx deleted file mode 100644 index d43132f01bf3ce03b6ecb3f17042288d759a09e9..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.stories.mdx +++ /dev/null @@ -1,175 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Checkbox, Stack } from "metabase/ui"; - -export const args = { - label: "Label", - description: undefined, - disabled: false, - labelPosition: "right", - size: "md", -}; - -export const argTypes = { - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - disabled: { - control: { type: "boolean" }, - }, - labelPosition: { - options: ["left", "right"], - control: { type: "inline-radio" }, - }, - size: { - options: ["xs", "md", "lg", "xl"], - control: { type: "inline-radio" }, - }, - variant: { - options: ["default", "stacked"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Inputs/Checkbox" - component={Checkbox} - args={args} - argTypes={argTypes} -/> - -# Checkbox - -Our themed wrapper around [Mantine Checkbox](https://v6.mantine.dev/core/checkbox/). - -## When to use Checkbox - -Checkbox buttons allow users to select a single option from a list of mutually exclusive options. All possible options are exposed up front for users to compare. - -## Docs - -- [Figma File](https://www.figma.com/file/sF1qSHk6yVqO1rFgmH0nzT/Input-%2F-Checkbox?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) -- [Mantine Checkbox Docs](https://v6.mantine.dev/core/checkbox/) - -## Usage guidelines - -- **Use this component if there are more than 5 options**. If there are fewer options, feel free to check out Checkbox or Select. -- For option ordering, try to use your best judgement on a sensible order. For example, Yes should come before No. Alphabetical ordering is usually a good fallback if there's no inherent order in your set of choices. -- In almost all circumstances you'll want to use `<Checkbox.Group>` to provide a set of options and help with defaultValues and state management between them. - -## Examples - -export const DefaultTemplate = args => <Checkbox {...args} />; - -export const CheckboxGroupTemplate = args => ( - <Checkbox.Group - defaultValue={["react"]} - label="An array of good frameworks" - description="But which one to use?" - > - <Stack mt="md"> - <Checkbox {...args} value="react" label="React" /> - <Checkbox {...args} value="svelte" label="Svelte" /> - <Checkbox {...args} value="ng" label="Angular" /> - <Checkbox {...args} value="vue" label="Vue" /> - </Stack> - </Checkbox.Group> -); - -export const StateTemplate = args => ( - <Stack> - <Checkbox {...args} label="Default checkbox" /> - <Checkbox {...args} label="Indeterminate checkbox" indeterminate /> - <Checkbox - {...args} - label="Indeterminate checked checkbox" - defaultChecked - indeterminate - /> - <Checkbox {...args} label="Checked checkbox" defaultChecked /> - <Checkbox {...args} label="Disabled checkbox" disabled /> - <Checkbox - {...args} - label="Disabled indeterminate checked checkbox" - disabled - defaultChecked - indeterminate - /> - <Checkbox - {...args} - label="Disabled checked checkbox" - disabled - defaultChecked - /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Checkbox.Group - -export const CheckboxGroup = CheckboxGroupTemplate.bind({}); - -<Canvas> - <Story name="Checkbox group">{CheckboxGroup}</Story> -</Canvas> - -### Label - -export const Label = StateTemplate.bind({}); - -<Canvas> - <Story name="Label">{Label}</Story> -</Canvas> - -#### Left label position - -export const LabelLeft = StateTemplate.bind({}); -LabelLeft.args = { - labelPosition: "left", -}; - -<Canvas> - <Story name="Label, left position">{LabelLeft}</Story> -</Canvas> - -### Description - -export const Description = StateTemplate.bind({}); - -<Canvas> - <Story name="Description">{Description}</Story> -</Canvas> - -#### Left label position - -export const DescriptionLeft = StateTemplate.bind({}); -DescriptionLeft.args = { - labelPosition: "left", -}; - -<Canvas> - <Story name="Description, left position">{DescriptionLeft}</Story> -</Canvas> - -#### Stacked Variant - -export const Stacked = StateTemplate.bind({}); -Stacked.args = { - variant: "stacked", -}; - -<Canvas> - <Story name="Stacked">{Stacked}</Story> -</Canvas> - -## Related components - -- Radio -- Select diff --git a/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.stories.tsx b/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2228b96b191e3839a7dfb24c674b111a30f66669 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,126 @@ +import { Checkbox, type CheckboxProps, Stack } from "metabase/ui"; + +const args = { + label: "Label", + description: undefined, + disabled: false, + labelPosition: "right", + size: "md", +}; + +const argTypes = { + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + labelPosition: { + options: ["left", "right"], + control: { type: "inline-radio" }, + }, + size: { + options: ["xs", "md", "lg", "xl"], + control: { type: "inline-radio" }, + }, + variant: { + options: ["default", "stacked"], + control: { type: "inline-radio" }, + }, +}; + +const CheckboxGroupTemplate = (args: CheckboxProps) => ( + <Checkbox.Group + defaultValue={["react"]} + label="An array of good frameworks" + description="But which one to use?" + > + <Stack mt="md"> + <Checkbox {...args} value="react" label="React" /> + <Checkbox {...args} value="svelte" label="Svelte" /> + <Checkbox {...args} value="ng" label="Angular" /> + <Checkbox {...args} value="vue" label="Vue" /> + </Stack> + </Checkbox.Group> +); + +const StateTemplate = (args: CheckboxProps) => ( + <Stack> + <Checkbox {...args} label="Default checkbox" /> + <Checkbox {...args} label="Indeterminate checkbox" indeterminate /> + <Checkbox + {...args} + label="Indeterminate checked checkbox" + defaultChecked + indeterminate + /> + <Checkbox {...args} label="Checked checkbox" defaultChecked /> + <Checkbox {...args} label="Disabled checkbox" disabled /> + <Checkbox + {...args} + label="Disabled indeterminate checked checkbox" + disabled + defaultChecked + indeterminate + /> + <Checkbox + {...args} + label="Disabled checked checkbox" + disabled + defaultChecked + /> + </Stack> +); + +export default { + title: "Inputs/Checkbox", + component: Checkbox, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const CheckboxGroup = { + render: CheckboxGroupTemplate, + name: "Checkbox group", +}; + +export const Label = { + render: StateTemplate, + name: "Label", +}; + +export const LabelLeftPosition = { + render: StateTemplate, + name: "Label, left position", + args: { + labelPosition: "left", + }, +}; + +export const Description = { + render: StateTemplate, + name: "Description", +}; + +export const DescriptionLeftPosition = { + render: StateTemplate, + name: "Description, left position", + args: { + labelPosition: "left", + }, +}; + +export const Stacked = { + render: StateTemplate, + name: "Stacked", + args: { + variant: "stacked", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.mdx b/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.mdx new file mode 100644 index 0000000000000000000000000000000000000000..e2fa9ac6cffd4926effb35cb6da51e0ad6d65609 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.mdx @@ -0,0 +1,129 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Icon } from "metabase/ui"; +import { DateInput, Stack } from "metabase/ui"; +import * as DateInputStories from "./DateInput.stories"; + +<Meta of={DateInputStories} /> + +# DateInput + +Our themed wrapper around [Mantine DateInput](https://v6.mantine.dev/dates/date-input/). + +## Docs + +- [Figma File](https://www.figma.com/file/oIZhYS5OoRA7twd4KqN4Eu/Input-%2F-Text?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) +- [Mantine DateInput Docs](https://v6.mantine.dev/dates/date-input/) + +## Examples + +<Canvas> + <Story of={DateInputStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={DateInputStories.EmptyMd} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={DateInputStories.FilledMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={DateInputStories.AsteriskMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={DateInputStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={DateInputStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={DateInputStories.ErrorMd} /> +</Canvas> + +#### Icon + +<Canvas> + <Story of={DateInputStories.IconMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={DateInputStories.ReadOnlyMd} /> +</Canvas> + +#### No popover + +<Canvas> + <Story of={DateInputStories.NoPopoverMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={DateInputStories.EmptyXs} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={DateInputStories.FilledXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={DateInputStories.AsteriskXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={DateInputStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={DateInputStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={DateInputStories.ErrorXs} /> +</Canvas> + +#### Icon + +<Canvas> + <Story of={DateInputStories.IconXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={DateInputStories.ReadOnlyXs} /> +</Canvas> + +#### No popover + +<Canvas> + <Story of={DateInputStories.NoPopoverXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.stories.mdx b/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.stories.mdx deleted file mode 100644 index d6dc283dd9a30e0beaf074070f5b5974a02bb10b..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.stories.mdx +++ /dev/null @@ -1,322 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Icon } from "metabase/ui"; -import { DateInput, Stack } from "metabase/ui"; - -export const args = { - variant: "default", - size: "md", - label: "Label", - description: undefined, - error: undefined, - placeholder: "Placeholder", - disabled: false, - readOnly: false, - withAsterisk: false, -}; - -export const sampleArgs = { - value: new Date(2023, 9, 8), - label: "Event date", - description: - "The event is visible if the date falls within the chart’s time range", - placeholder: "Enter date", - error: "required", -}; - -export const argTypes = { - variant: { - options: ["default", "unstyled"], - control: { type: "inline-radio" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, -}; - -<Meta - title="Inputs/DateInput" - component={DateInput} - args={args} - argTypes={argTypes} -/> - -# DateInput - -Our themed wrapper around [Mantine DateInput](https://v6.mantine.dev/dates/date-input/). - -## Docs - -- [Figma File](https://www.figma.com/file/oIZhYS5OoRA7twd4KqN4Eu/Input-%2F-Text?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) -- [Mantine DateInput Docs](https://v6.mantine.dev/dates/date-input/) - -## Examples - -export const DefaultTemplate = args => <DateInput {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <DateInput {...args} variant="default" /> - <DateInput {...args} variant="unstyled" /> - </Stack> -); - -export const IconTemplate = args => ( - <VariantTemplate {...args} icon={<Icon name="calendar" />} /> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Filled - -export const FilledMd = VariantTemplate.bind({}); -FilledMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Filled, md">{FilledMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Icon - -export const IconMd = IconTemplate.bind({}); -IconMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Icon, md">{IconMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = VariantTemplate.bind({}); -ReadOnlyMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - readOnly: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -#### No popover - -export const NoPopoverMd = VariantTemplate.bind({}); -NoPopoverMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - popoverProps: { opened: false }, -}; - -<Canvas> - <Story name="No popover, md">{NoPopoverMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Filled - -export const FilledXs = VariantTemplate.bind({}); -FilledXs.args = { - ...FilledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Filled, xs">{FilledXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Icon - -export const IconXs = IconTemplate.bind({}); -IconXs.args = { - ...IconMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Icon, xs">{IconXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = VariantTemplate.bind({}); -ReadOnlyXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> - -#### No popover - -export const NoPopoverXs = VariantTemplate.bind({}); -NoPopoverXs.args = { - ...NoPopoverMd.args, - size: "xs", -}; - -<Canvas> - <Story name="No popover, xs">{NoPopoverXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.stories.tsx b/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..24a65a0d05defce08aa276bd516947b446df2974 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/DateInput/DateInput.stories.tsx @@ -0,0 +1,250 @@ +import { DateInput, type DateInputProps, Icon, Stack } from "metabase/ui"; + +const args = { + variant: "default", + size: "md", + label: "Label", + description: undefined, + error: undefined, + placeholder: "Placeholder", + disabled: false, + readOnly: false, + withAsterisk: false, +}; + +const sampleArgs = { + value: new Date(2023, 9, 8), + label: "Event date", + description: + "The event is visible if the date falls within the chart’s time range", + placeholder: "Enter date", + error: "required", +}; + +const argTypes = { + variant: { + options: ["default", "unstyled"], + control: { type: "inline-radio" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, +}; + +const VariantTemplate = (args: DateInputProps) => ( + <Stack> + <DateInput {...args} variant="default" /> + <DateInput {...args} variant="unstyled" /> + </Stack> +); + +const IconTemplate = (args: DateInputProps) => ( + <VariantTemplate {...args} icon={<Icon name="calendar" />} /> +); + +export default { + title: "Inputs/DateInput", + component: DateInput, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", +}; + +export const FilledMd = { + render: VariantTemplate, + name: "Filled, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + }, +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const IconMd = { + render: IconTemplate, + name: "Icon, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: VariantTemplate, + name: "Read only, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + readOnly: true, + }, +}; + +export const NoPopoverMd = { + render: VariantTemplate, + name: "No popover, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + popoverProps: { opened: false }, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + size: "xs", + }, +}; + +export const FilledXs = { + render: VariantTemplate, + name: "Filled, xs", + args: { + ...FilledMd.args, + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const IconXs = { + render: IconTemplate, + name: "Icon, xs", + args: { + ...IconMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: VariantTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; + +export const NoPopoverXs = { + render: VariantTemplate, + name: "No popover, xs", + args: { + ...NoPopoverMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.mdx b/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.mdx new file mode 100644 index 0000000000000000000000000000000000000000..057a79b8d10b04f4459e34195cb4db700849fb46 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.mdx @@ -0,0 +1,58 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { DatePicker, Box } from "metabase/ui"; +import { SdkVisualizationWrapper } from "__support__/storybook"; +import * as DatePickerStories from "./DatePicker.stories"; + +<Meta of={DatePickerStories} /> + +# DatePicker + +Our themed wrapper around [Mantine DatePicker](https://v6.mantine.dev/dates/date-picker/). + +## Docs + +- [Figma File](https://www.figma.com/file/cncRbkG7XJQs144EG9r9hM/Date-Picker?type=design&node-id=0-1&mode=design&t=v00tLhbD9OFEgy25-0) +- [Mantine DatePicker Docs](https://v6.mantine.dev/dates/date-picker/) + +## Examples + +<Canvas> + <Story of={DatePickerStories.Default} /> +</Canvas> + +### Allow deselect + +<Canvas> + <Story of={DatePickerStories.AllowDeselect} /> +</Canvas> + +### Multiple dates + +<Canvas> + <Story of={DatePickerStories.MultipleDates} /> +</Canvas> + +### Dates range + +<Canvas> + <Story of={DatePickerStories.DatesRange} /> +</Canvas> + +### Dates range (SDK theme) + +<Canvas> + <Story of={DatePickerStories.DatesRangeSdk} /> +</Canvas> + +### Single date in range + +<Canvas> + <Story of={DatePickerStories.SingleDateInRange} /> +</Canvas> + +### Number of columns + +<Canvas> + <Story of={DatePickerStories.NumberOfColumns} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.stories.mdx b/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.stories.mdx deleted file mode 100644 index af83ea84c23850739f8f47cabc15c911e8917bdf..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.stories.mdx +++ /dev/null @@ -1,163 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { DatePicker, Box } from "metabase/ui"; -import { SdkVisualizationWrapper } from "__support__/storybook"; - -export const args = { - type: "default", - allowDeselect: undefined, - allowSingleDateInRange: undefined, - numberOfColumns: undefined, -}; - -export const sampleArgs = { - date1: new Date(2023, 9, 8), - date2: new Date(2023, 9, 24), - date3: new Date(2023, 9, 16), -}; - -export const argTypes = { - type: { - options: ["default", "multiple", "range"], - control: { type: "inline-radio" }, - }, - allowDeselect: { - control: { type: "boolean" }, - }, - allowSingleDateInRange: { - control: { type: "boolean" }, - }, - numberOfColumns: { - control: { type: "number" }, - }, -}; - -<Meta - title="Inputs/DatePicker" - component={DatePicker} - args={args} - argTypes={argTypes} -/> - -# DatePicker - -Our themed wrapper around [Mantine DatePicker](https://v6.mantine.dev/dates/date-picker/). - -## Docs - -- [Figma File](https://www.figma.com/file/cncRbkG7XJQs144EG9r9hM/Date-Picker?type=design&node-id=0-1&mode=design&t=v00tLhbD9OFEgy25-0) -- [Mantine DatePicker Docs](https://v6.mantine.dev/dates/date-picker/) - -## Examples - -export const DefaultTemplate = args => { - return <DatePicker {...args} />; -}; - -export const Default = DefaultTemplate.bind({}); -Default.args = { - defaultDate: sampleArgs.date1, -}; - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Allow deselect - -export const AllowDeselect = DefaultTemplate.bind({}); -AllowDeselect.args = { - allowDeselect: true, - defaultDate: sampleArgs.date1, - defaultValue: sampleArgs.date1, -}; - -<Canvas> - <Story name="Allow deselect">{AllowDeselect}</Story> -</Canvas> - -### Multiple dates - -export const MultipleDates = DefaultTemplate.bind({}); -MultipleDates.args = { - type: "multiple", - defaultValue: [sampleArgs.date1, sampleArgs.date2], - defaultDate: sampleArgs.date1, -}; - -<Canvas> - <Story name="Multiple dates">{MultipleDates}</Story> -</Canvas> - -### Dates range - -export const DatesRange = DefaultTemplate.bind({}); -DatesRange.args = { - type: "range", - defaultValue: [sampleArgs.date1, sampleArgs.date2], - defaultDate: sampleArgs.date1, -}; - -<Canvas> - <Story name="Dates range">{DatesRange}</Story> -</Canvas> - -### Dates range (SDK theme) - -export const DatesRangeSdk = DefaultTemplate.bind({}); -DatesRangeSdk.args = { - type: "range", - defaultValue: [sampleArgs.date1, sampleArgs.date2], - defaultDate: sampleArgs.date1, -}; - -export const theme = { - colors: { - brand: "#DF75E9", - filter: "#7ABBF9", - "text-primary": "#E3E7E4", - "text-secondary": "#E3E7E4", - "text-tertiary": "#E3E7E4", - border: "#3B3F3F", - background: "#151C20", - "background-hover": "#4C4A48", - }, -}; - -<Canvas> - <Story name="Dates range SDK"> - <SdkVisualizationWrapper theme={theme}> - <Box bg="background"> - <DatesRangeSdk {...DatesRangeSdk.args} /> - </Box> - </SdkVisualizationWrapper> - </Story> -</Canvas> - -### Single date in range - -export const SingleDateInRange = DefaultTemplate.bind({}); -SingleDateInRange.args = { - type: "range", - defaultValue: [sampleArgs.date1, sampleArgs.date1], - defaultDate: sampleArgs.date1, - allowSingleDateInRange: true, -}; - -<Canvas> - <Story name="Single date in range">{SingleDateInRange}</Story> -</Canvas> - -### Number of columns - -export const NumberOfColumns = DefaultTemplate.bind({}); -NumberOfColumns.args = { - type: "range", - defaultValue: [sampleArgs.date1, sampleArgs.date3], - defaultDate: sampleArgs.date1, - numberOfColumns: 2, -}; - -<Canvas> - <Story name="Number of columns">{NumberOfColumns}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.stories.tsx b/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c73ded20458bfbd50e2ac66d64b5447bc80f827c --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,117 @@ +import { SdkVisualizationWrapper } from "__support__/storybook"; +import { Box, DatePicker, type DatePickerProps } from "metabase/ui"; + +const args = { + type: "default", + allowDeselect: undefined, + allowSingleDateInRange: undefined, + numberOfColumns: undefined, +}; + +const sampleArgs = { + date1: new Date(2023, 9, 8), + date2: new Date(2023, 9, 24), + date3: new Date(2023, 9, 16), +}; + +const argTypes = { + type: { + options: ["default", "multiple", "range"], + control: { type: "inline-radio" }, + }, + allowDeselect: { + control: { type: "boolean" }, + }, + allowSingleDateInRange: { + control: { type: "boolean" }, + }, + numberOfColumns: { + control: { type: "number" }, + }, +}; + +const theme = { + colors: { + brand: "#DF75E9", + filter: "#7ABBF9", + "text-primary": "#E3E7E4", + "text-secondary": "#E3E7E4", + "text-tertiary": "#E3E7E4", + border: "#3B3F3F", + background: "#151C20", + "background-hover": "#4C4A48", + }, +}; + +export default { + title: "Inputs/DatePicker", + component: DatePicker, + args, + argTypes, +}; + +export const Default = { + name: "Default", + args: { + defaultDate: sampleArgs.date1, + }, +}; + +export const AllowDeselect = { + name: "Allow deselect", + args: { + allowDeselect: true, + defaultDate: sampleArgs.date1, + defaultValue: sampleArgs.date1, + }, +}; + +export const MultipleDates = { + name: "Multiple dates", + args: { + type: "multiple", + defaultValue: [sampleArgs.date1, sampleArgs.date2], + defaultDate: sampleArgs.date1, + }, +}; + +export const DatesRange = { + name: "Dates range", + args: { + type: "range" as DatePickerProps["type"], + defaultValue: [sampleArgs.date1, sampleArgs.date2], + defaultDate: sampleArgs.date1, + }, +}; + +export const DatesRangeSdk = { + render: () => ( + <SdkVisualizationWrapper theme={theme}> + <Box bg="background"> + <DatePicker {...DatesRange.args} /> + </Box> + </SdkVisualizationWrapper> + ), + + name: "Dates range SDK", +}; + +export const SingleDateInRange = { + name: "Single date in range", + args: { + type: "range", + defaultValue: [sampleArgs.date1, sampleArgs.date1], + defaultDate: sampleArgs.date1, + allowSingleDateInRange: true, + }, +}; + +export const NumberOfColumns = { + name: "Number of columns", + args: { + type: "range", + defaultValue: [sampleArgs.date1, sampleArgs.date3], + defaultDate: sampleArgs.date1, + numberOfColumns: 2, + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.mdx b/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.mdx new file mode 100644 index 0000000000000000000000000000000000000000..6d851b574d4f23ce835eb5b52c177c681c2bab01 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.mdx @@ -0,0 +1,92 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Icon } from "metabase/ui"; +import { Stack, FileInput } from "metabase/ui"; +import * as FileInputStories from "./FileInput.stories"; + +<Meta of={FileInputStories} /> + +# FileInput + +Our themed wrapper around [Mantine FileInput](https://v6.mantine.dev/core/file-input/). + +## Docs + +- [Mantine FileInput Docs](https://v6.mantine.dev/core/file-input/) + +## Examples + +<Canvas> + <Story of={FileInputStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={FileInputStories.EmptyMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={FileInputStories.AsteriskMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={FileInputStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={FileInputStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={FileInputStories.ErrorMd} /> +</Canvas> + +#### Icon + +<Canvas> + <Story of={FileInputStories.IconMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={FileInputStories.EmptyXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={FileInputStories.AsteriskXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={FileInputStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={FileInputStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={FileInputStories.ErrorXs} /> +</Canvas> + +#### Icon + +<Canvas> + <Story of={FileInputStories.IconXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.stories.mdx b/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.stories.mdx deleted file mode 100644 index 3417c57f2f745d422ed0a269177f5f07590b97fe..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.stories.mdx +++ /dev/null @@ -1,237 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Icon } from "metabase/ui"; -import { Stack, FileInput } from "metabase/ui"; - -export const args = { - variant: "default", - size: "md", - label: "Label", - description: undefined, - error: undefined, - placeholder: "Placeholder", - disabled: false, - readOnly: false, - withAsterisk: false, -}; - -export const sampleArgs = { - label: "SSL Client Certificate (PEM)", - description: "A certificate to authenticate the instance to the server", - placeholder: "Select a file", - error: "required", -}; - -export const argTypes = { - variant: { - options: ["default", "unstyled"], - control: { type: "inline-radio" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - disabled: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, -}; - -<Meta - title="Inputs/FileInput" - component={FileInput} - args={args} - argTypes={argTypes} -/> - -# FileInput - -Our themed wrapper around [Mantine FileInput](https://v6.mantine.dev/core/file-input/). - -## Docs - -- [Mantine FileInput Docs](https://v6.mantine.dev/core/file-input/) - -## Examples - -export const DefaultTemplate = args => <FileInput {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <FileInput {...args} variant="default" /> - <FileInput {...args} variant="unstyled" /> - </Stack> -); - -export const IconTemplate = args => ( - <VariantTemplate {...args} icon={<Icon name="attachment" />} /> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Icon - -export const IconMd = IconTemplate.bind({}); -IconMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Icon, md">{IconMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Icon - -export const IconXs = IconTemplate.bind({}); -IconXs.args = { - ...IconMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Icon, xs">{IconXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.stories.tsx b/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eae31dc92a1b02695e7bf39dfcf91ed57a7a70b1 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/FileInput/FileInput.stories.tsx @@ -0,0 +1,184 @@ +import { FileInput, type FileInputProps, Icon, Stack } from "metabase/ui"; + +const args = { + variant: "default", + size: "md", + label: "Label", + description: undefined, + error: undefined, + placeholder: "Placeholder", + disabled: false, + readOnly: false, + withAsterisk: false, +}; + +const sampleArgs = { + label: "SSL Client Certificate (PEM)", + description: "A certificate to authenticate the instance to the server", + placeholder: "Select a file", + error: "required", +}; + +const argTypes = { + variant: { + options: ["default", "unstyled"], + control: { type: "inline-radio" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, +}; + +const VariantTemplate = (args: FileInputProps) => ( + <Stack> + <FileInput {...args} variant="default" /> + <FileInput {...args} variant="unstyled" /> + </Stack> +); + +const IconTemplate = (args: FileInputProps) => ( + <VariantTemplate {...args} icon={<Icon name="attachment" />} /> +); + +export default { + title: "Inputs/FileInput", + component: FileInput, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const IconMd = { + render: IconTemplate, + name: "Icon, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const IconXs = { + render: IconTemplate, + name: "Icon, xs", + args: { + ...IconMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.mdx b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.mdx new file mode 100644 index 0000000000000000000000000000000000000000..8eb982c32e0d43afe6c797bbc282e5d96a805e6a --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.mdx @@ -0,0 +1,104 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { MultiAutocomplete, Stack } from "metabase/ui"; +import * as MultiAutocompleteStories from "./MultiAutocomplete.stories"; + +<Meta of={MultiAutocompleteStories} /> + +# MultiAutocomplete + +Our themed wrapper around [Mantine MultiSelect](https://v6.mantine.dev/core/multi-select/) with autocomplete features. + +## Docs + +- [Figma File](https://www.figma.com/file/21uCY0czmCfb6QBjRce0I8/Input-%2F-MultiSelect?type=design&node-id=1-96&mode=design&t=Mk8RP6HsneuWzHtr-0) +- [Mantine MultiSelect Docs](https://v6.mantine.dev/core/multi-select/) + +## Examples + +<Canvas> + <Story of={MultiAutocompleteStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={MultiAutocompleteStories.EmptyMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={MultiAutocompleteStories.AsteriskMd} /> +</Canvas> + +#### Clearable + +<Canvas> + <Story of={MultiAutocompleteStories.ClearableMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={MultiAutocompleteStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={MultiAutocompleteStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={MultiAutocompleteStories.ErrorMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={MultiAutocompleteStories.ReadOnlyMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={MultiAutocompleteStories.EmptyXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={MultiAutocompleteStories.AsteriskXs} /> +</Canvas> + +#### Clearable + +<Canvas> + <Story of={MultiAutocompleteStories.ClearableXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={MultiAutocompleteStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={MultiAutocompleteStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={MultiAutocompleteStories.ErrorXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={MultiAutocompleteStories.ReadOnlyXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.stories.mdx b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.stories.mdx deleted file mode 100644 index a2625e43d3c48a6fbd493e0811371e41314558f1..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.stories.mdx +++ /dev/null @@ -1,268 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { MultiAutocomplete, Stack } from "metabase/ui"; - -export const args = { - data: [], - size: "md", - label: "Field type", - description: undefined, - error: undefined, - placeholder: "No semantic type", - disabled: false, - readOnly: false, - withAsterisk: false, - dropdownPosition: "flip", -}; - -export const sampleArgs = { - data: ["Doohickey", "Gadget", "Gizmo", "Widget"], - value: ["Gadget"], - description: "Determines how Metabase displays the field", - error: "required", -}; - -export const argTypes = { - data: { - control: { type: "json" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, - dropdownPosition: { - options: ["bottom", "top", "flip"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Inputs/MultiAutocomplete" - component={MultiAutocomplete} - args={args} - argTypes={argTypes} -/> - -# MultiAutocomplete - -Our themed wrapper around [Mantine MultiSelect](https://v6.mantine.dev/core/multi-select/) with autocomplete features. - -## Docs - -- [Figma File](https://www.figma.com/file/21uCY0czmCfb6QBjRce0I8/Input-%2F-MultiSelect?type=design&node-id=1-96&mode=design&t=Mk8RP6HsneuWzHtr-0) -- [Mantine MultiSelect Docs](https://v6.mantine.dev/core/multi-select/) - -## Examples - -export const DefaultTemplate = args => ( - <MultiAutocomplete {...args} shouldCreate={query => query.length > 0} /> -); - -export const VariantTemplate = args => ( - <Stack> - <DefaultTemplate {...args} /> - <DefaultTemplate {...args} variant="unstyled" /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); -Default.args = { - data: sampleArgs.data, -}; - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Clearable - -export const ClearableMd = VariantTemplate.bind({}); -ClearableMd.args = { - data: sampleArgs.data, - defaultValue: sampleArgs.value, - clearable: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Clearable, md">{ClearableMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - data: sampleArgs.data, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - data: sampleArgs.data, - description: sampleArgs.description, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - data: sampleArgs.data, - description: sampleArgs.description, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = VariantTemplate.bind({}); -ReadOnlyMd.args = { - data: sampleArgs.data, - defaultValue: sampleArgs.value, - description: sampleArgs.description, - readOnly: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Clearable - -export const ClearableXs = VariantTemplate.bind({}); -ClearableXs.args = { - ...ClearableMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Clearable, xs">{ClearableXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = VariantTemplate.bind({}); -ReadOnlyXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.stories.tsx b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6404fda3efe9b3e12256599d0f5b4f8b3773c3b0 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.stories.tsx @@ -0,0 +1,216 @@ +import { + MultiAutocomplete, + type MultiAutocompleteProps, + Stack, +} from "metabase/ui"; + +const args = { + data: [], + size: "md", + label: "Field type", + description: undefined, + error: undefined, + placeholder: "No semantic type", + disabled: false, + readOnly: false, + withAsterisk: false, + dropdownPosition: "flip", +}; + +const sampleArgs = { + data: ["Doohickey", "Gadget", "Gizmo", "Widget"], + value: ["Gadget"], + description: "Determines how Metabase displays the field", + error: "required", +}; + +const argTypes = { + data: { + control: { type: "json" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, + dropdownPosition: { + options: ["bottom", "top", "flip"], + control: { type: "inline-radio" }, + }, +}; + +const DefaultTemplate = (args: MultiAutocompleteProps) => ( + <MultiAutocomplete {...args} shouldCreate={query => query.length > 0} /> +); + +const VariantTemplate = (args: MultiAutocompleteProps) => ( + <Stack> + <DefaultTemplate {...args} /> + <DefaultTemplate {...args} variant="unstyled" /> + </Stack> +); + +export default { + title: "Inputs/MultiAutocomplete", + component: MultiAutocomplete, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", + args: { + data: sampleArgs.data, + }, +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + withAsterisk: true, + }, +}; + +export const ClearableMd = { + render: VariantTemplate, + name: "Clearable, md", + args: { + data: sampleArgs.data, + defaultValue: sampleArgs.value, + clearable: true, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + data: sampleArgs.data, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + data: sampleArgs.data, + description: sampleArgs.description, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + data: sampleArgs.data, + description: sampleArgs.description, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: VariantTemplate, + name: "Read only, md", + args: { + data: sampleArgs.data, + defaultValue: sampleArgs.value, + description: sampleArgs.description, + readOnly: true, + withAsterisk: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const ClearableXs = { + render: VariantTemplate, + name: "Clearable, xs", + args: { + ...ClearableMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: VariantTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.mdx b/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.mdx new file mode 100644 index 0000000000000000000000000000000000000000..978aa5d193881aff2b9b1d125f14c1e15a0b07bf --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.mdx @@ -0,0 +1,164 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { MultiSelect, Stack } from "metabase/ui"; +import * as MultiSelectStories from "./MultiSelect.stories"; + +<Meta of={MultiSelectStories} /> + +# MultiSelect + +Our themed wrapper around [Mantine MultiSelect](https://v6.mantine.dev/core/multi-select/). + +## Docs + +- [Figma File](https://www.figma.com/file/21uCY0czmCfb6QBjRce0I8/Input-%2F-MultiSelect?type=design&node-id=1-96&mode=design&t=Mk8RP6HsneuWzHtr-0) +- [Mantine MultiSelect Docs](https://v6.mantine.dev/core/multiselect/) + +## Examples + +<Canvas> + <Story of={MultiSelectStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={MultiSelectStories.EmptyMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={MultiSelectStories.AsteriskMd} /> +</Canvas> + +#### Clearable + +<Canvas> + <Story of={MultiSelectStories.ClearableMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={MultiSelectStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={MultiSelectStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={MultiSelectStories.ErrorMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={MultiSelectStories.ReadOnlyMd} /> +</Canvas> + +#### Icons + +<Canvas> + <Story of={MultiSelectStories.IconsMd} /> +</Canvas> + +#### Groups + +<Canvas> + <Story of={MultiSelectStories.GroupsMd} /> +</Canvas> + +#### Large sets + +<Canvas> + <Story of={MultiSelectStories.LargeSetsMd} /> +</Canvas> + +#### Searchable + +<Canvas> + <Story of={MultiSelectStories.SearchableMd} /> +</Canvas> + +#### Creatable + +<Canvas> + <Story of={MultiSelectStories.CreatableMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={MultiSelectStories.EmptyXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={MultiSelectStories.AsteriskXs} /> +</Canvas> + +#### Clearable + +<Canvas> + <Story of={MultiSelectStories.ClearableXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={MultiSelectStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={MultiSelectStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={MultiSelectStories.ErrorXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={MultiSelectStories.ReadOnlyXs} /> +</Canvas> + +#### Icons + +<Canvas> + <Story of={MultiSelectStories.IconsXs} /> +</Canvas> + +#### Groups + +<Canvas> + <Story of={MultiSelectStories.GroupsXs} /> +</Canvas> + +#### Large sets + +<Canvas> + <Story of={MultiSelectStories.LargeSetsXs} /> +</Canvas> + +#### Searchable + +<Canvas> + <Story of={MultiSelectStories.SearchableXs} /> +</Canvas> + +#### Creatable + +<Canvas> + <Story of={MultiSelectStories.CreatableXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.stories.mdx b/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.stories.mdx deleted file mode 100644 index 4b6aa651708284ebeb4e694e42752f237ca89340..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.stories.mdx +++ /dev/null @@ -1,434 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { MultiSelect, Stack } from "metabase/ui"; - -export const dataWithGroupsLarge = [ - { value: "10", label: "Entity key", icon: "label", group: "Overall row" }, - { value: "11", label: "Entity name", icon: "string", group: "Overall row" }, - { - value: "12", - label: "Foreign key", - icon: "connections", - group: "Overall row", - }, - { value: "13", label: "Category", icon: "string", group: "Common" }, - { - value: "14", - label: "Comment", - icon: "string", - group: "Common", - disabled: true, - }, - { value: "15", label: "Description", icon: "string", group: "Common" }, - { value: "16", label: "Title", icon: "string", group: "Common" }, - { value: "17", label: "City", icon: "location", group: "Location" }, - { value: "18", label: "Country", icon: "location", group: "Location" }, - { value: "19", label: "Latitude", icon: "location", group: "Location" }, - { value: "20", label: "Longitude", icon: "location", group: "Location" }, - { value: "21", label: "Longitude", icon: "location", group: "Location" }, - { value: "22", label: "State", icon: "location", group: "Location" }, - { value: "23", label: "Zip code", icon: "location", group: "Location" }, -]; - -export const dataWithGroups = dataWithGroupsLarge.slice(0, 6); - -export const dataWithIcons = dataWithGroups.map(item => ({ - ...item, - group: undefined, -})); - -export const dataWithLabels = dataWithIcons.map(item => ({ - ...item, - icon: undefined, -})); - -export const args = { - data: dataWithLabels, - size: "md", - label: "Field type", - description: undefined, - error: undefined, - placeholder: "No semantic type", - searchable: false, - creatable: false, - disabled: false, - readOnly: false, - withAsterisk: false, - dropdownPosition: "flip", -}; - -export const sampleArgs = { - value: [dataWithLabels[0].value], - description: "Determines how Metabase displays the field", - error: "required", -}; - -export const argTypes = { - data: { - control: { type: "json" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - searchable: { - control: { type: "boolean" }, - }, - creatable: { - control: { type: "boolean" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, - dropdownPosition: { - options: ["bottom", "top", "flip"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Inputs/MultiSelect" - component={MultiSelect} - args={args} - argTypes={argTypes} -/> - -# MultiSelect - -Our themed wrapper around [Mantine MultiSelect](https://v6.mantine.dev/core/multi-select/). - -## Docs - -- [Figma File](https://www.figma.com/file/21uCY0czmCfb6QBjRce0I8/Input-%2F-MultiSelect?type=design&node-id=1-96&mode=design&t=Mk8RP6HsneuWzHtr-0) -- [Mantine MultiSelect Docs](https://v6.mantine.dev/core/multiselect/) - -## Examples - -export const DefaultTemplate = args => <MultiSelect {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <MultiSelect {...args} /> - <MultiSelect {...args} variant="unstyled" /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Clearable - -export const ClearableMd = VariantTemplate.bind({}); -ClearableMd.args = { - defaultValue: sampleArgs.value, - clearable: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Clearable, md">{ClearableMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - description: sampleArgs.description, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - description: sampleArgs.description, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = VariantTemplate.bind({}); -ReadOnlyMd.args = { - defaultValue: sampleArgs.value, - description: sampleArgs.description, - readOnly: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -#### Icons - -export const IconsMd = VariantTemplate.bind({}); -IconsMd.args = { - data: dataWithIcons, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Icons, md">{IconsMd}</Story> -</Canvas> - -#### Groups - -export const GroupsMd = VariantTemplate.bind({}); -GroupsMd.args = { - data: dataWithGroups, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Groups, md">{GroupsMd}</Story> -</Canvas> - -#### Large sets - -export const LargeSetsMd = VariantTemplate.bind({}); -LargeSetsMd.args = { - data: dataWithGroupsLarge, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Large sets, md">{LargeSetsMd}</Story> -</Canvas> - -#### Searchable - -export const SearchableMd = VariantTemplate.bind({}); -SearchableMd.args = { - data: dataWithGroupsLarge, - description: sampleArgs.description, - searchable: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Searchable, md">{SearchableMd}</Story> -</Canvas> - -#### Creatable - -export const CreatableMd = VariantTemplate.bind({}); -CreatableMd.args = { - data: dataWithGroupsLarge, - description: sampleArgs.description, - getCreateLabel: query => `New ${query}`, - creatable: true, - searchable: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Creatable, md">{CreatableMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Clearable - -export const ClearableXs = VariantTemplate.bind({}); -ClearableXs.args = { - ...ClearableMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Clearable, xs">{ClearableXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = VariantTemplate.bind({}); -ReadOnlyXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> - -#### Icons - -export const IconsXs = VariantTemplate.bind({}); -IconsXs.args = { - ...IconsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Icons, xs">{IconsXs}</Story> -</Canvas> - -#### Groups - -export const GroupsXs = VariantTemplate.bind({}); -GroupsXs.args = { - ...GroupsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Groups, xs">{GroupsXs}</Story> -</Canvas> - -#### Large sets - -export const LargeSetsXs = VariantTemplate.bind({}); -LargeSetsXs.args = { - ...LargeSetsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Large sets, xs">{LargeSetsXs}</Story> -</Canvas> - -#### Searchable - -export const SearchableXs = VariantTemplate.bind({}); -SearchableXs.args = { - ...SearchableMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Searchable, xs">{SearchableXs}</Story> -</Canvas> - -#### Creatable - -export const CreatableXs = VariantTemplate.bind({}); -CreatableXs.args = { - ...CreatableMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Creatable, xs">{CreatableXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.stories.tsx b/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a0a2467d5cf2913ecf021b0cad2dedfa12404f4a --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,345 @@ +import { MultiSelect, type MultiSelectProps, Stack } from "metabase/ui"; + +const dataWithGroupsLarge = [ + { value: "10", label: "Entity key", icon: "label", group: "Overall row" }, + { value: "11", label: "Entity name", icon: "string", group: "Overall row" }, + { + value: "12", + label: "Foreign key", + icon: "connections", + group: "Overall row", + }, + { value: "13", label: "Category", icon: "string", group: "Common" }, + { + value: "14", + label: "Comment", + icon: "string", + group: "Common", + disabled: true, + }, + { value: "15", label: "Description", icon: "string", group: "Common" }, + { value: "16", label: "Title", icon: "string", group: "Common" }, + { value: "17", label: "City", icon: "location", group: "Location" }, + { value: "18", label: "Country", icon: "location", group: "Location" }, + { value: "19", label: "Latitude", icon: "location", group: "Location" }, + { value: "20", label: "Longitude", icon: "location", group: "Location" }, + { value: "21", label: "Longitude", icon: "location", group: "Location" }, + { value: "22", label: "State", icon: "location", group: "Location" }, + { value: "23", label: "Zip code", icon: "location", group: "Location" }, +]; + +const dataWithGroups = dataWithGroupsLarge.slice(0, 6); + +const dataWithIcons = dataWithGroups.map(item => ({ + ...item, + group: undefined, +})); + +const dataWithLabels = dataWithIcons.map(item => ({ + ...item, + icon: undefined, +})); + +const args = { + data: dataWithLabels, + size: "md", + label: "Field type", + description: undefined, + error: undefined, + placeholder: "No semantic type", + searchable: false, + creatable: false, + disabled: false, + readOnly: false, + withAsterisk: false, + dropdownPosition: "flip", +}; + +const sampleArgs = { + value: [dataWithLabels[0].value], + description: "Determines how Metabase displays the field", + error: "required", +}; + +const argTypes = { + data: { + control: { type: "json" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + searchable: { + control: { type: "boolean" }, + }, + creatable: { + control: { type: "boolean" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, + dropdownPosition: { + options: ["bottom", "top", "flip"], + control: { type: "inline-radio" }, + }, +}; + +const VariantTemplate = (args: MultiSelectProps) => ( + <Stack> + <MultiSelect {...args} /> + <MultiSelect {...args} variant="unstyled" /> + </Stack> +); + +export default { + title: "Inputs/MultiSelect", + component: MultiSelect, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + withAsterisk: true, + }, +}; + +export const ClearableMd = { + render: VariantTemplate, + name: "Clearable, md", + args: { + defaultValue: sampleArgs.value, + clearable: true, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + description: sampleArgs.description, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + description: sampleArgs.description, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: VariantTemplate, + name: "Read only, md", + args: { + defaultValue: sampleArgs.value, + description: sampleArgs.description, + readOnly: true, + withAsterisk: true, + }, +}; + +export const IconsMd = { + render: VariantTemplate, + name: "Icons, md", + args: { + data: dataWithIcons, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const GroupsMd = { + render: VariantTemplate, + name: "Groups, md", + args: { + data: dataWithGroups, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const LargeSetsMd = { + render: VariantTemplate, + name: "Large sets, md", + args: { + data: dataWithGroupsLarge, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const SearchableMd = { + render: VariantTemplate, + name: "Searchable, md", + args: { + data: dataWithGroupsLarge, + description: sampleArgs.description, + searchable: true, + withAsterisk: true, + }, +}; + +export const CreatableMd = { + render: VariantTemplate, + name: "Creatable, md", + args: { + data: dataWithGroupsLarge, + description: sampleArgs.description, + getCreateLabel: (query: string) => `New ${query}`, + creatable: true, + searchable: true, + withAsterisk: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const ClearableXs = { + render: VariantTemplate, + name: "Clearable, xs", + args: { + ...ClearableMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: VariantTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; + +export const IconsXs = { + render: VariantTemplate, + name: "Icons, xs", + args: { + ...IconsMd.args, + size: "xs", + }, +}; + +export const GroupsXs = { + render: VariantTemplate, + name: "Groups, xs", + args: { + ...GroupsMd.args, + size: "xs", + }, +}; + +export const LargeSetsXs = { + render: VariantTemplate, + name: "Large sets, xs", + args: { + ...LargeSetsMd.args, + size: "xs", + }, +}; + +export const SearchableXs = { + render: VariantTemplate, + name: "Searchable, xs", + args: { + ...SearchableMd.args, + size: "xs", + }, +}; + +export const CreatableXs = { + render: VariantTemplate, + name: "Creatable, xs", + args: { + ...CreatableMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.mdx b/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.mdx new file mode 100644 index 0000000000000000000000000000000000000000..2a005a32e7a0bb064606be007e6dee48f9e706ad --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.mdx @@ -0,0 +1,118 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Icon } from "metabase/ui"; +import { Stack } from "metabase/ui"; +import { NumberInput } from "./"; +import * as NumberInputStories from "./NumberInput.stories"; + +<Meta of={NumberInputStories} /> + +# NumberInput + +Our themed wrapper around [Mantine NumberInput](https://v6.mantine.dev/core/number-input/). + +## Docs + +- [Figma File](https://www.figma.com/file/YWyb541aUHtYXJVPzyYSbg/Input-%2F-Numbers?type=design&node-id=1-96&mode=design&t=1bDfUrJc5alZmVpx-0) +- [Mantine NumberInput Docs](https://v6.mantine.dev/core/number-input/) + +## Examples + +<Canvas> + <Story of={NumberInputStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={NumberInputStories.EmptyMd} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={NumberInputStories.FilledMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={NumberInputStories.AsteriskMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={NumberInputStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={NumberInputStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={NumberInputStories.ErrorMd} /> +</Canvas> + +#### Icon + +<Canvas> + <Story of={NumberInputStories.IconMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={NumberInputStories.ReadOnlyMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={NumberInputStories.EmptyXs} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={NumberInputStories.FilledXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={NumberInputStories.AsteriskXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={NumberInputStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={NumberInputStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={NumberInputStories.ErrorXs} /> +</Canvas> + +#### Icon + +<Canvas> + <Story of={NumberInputStories.IconXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={NumberInputStories.ReadOnlyXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.stories.mdx b/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.stories.mdx deleted file mode 100644 index b08bf827d601b8683043700740f97826fb87b13d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.stories.mdx +++ /dev/null @@ -1,299 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Icon } from "metabase/ui"; -import { Stack } from "metabase/ui"; -import { NumberInput } from "./"; - -export const args = { - variant: "default", - size: "md", - label: "Label", - description: undefined, - error: undefined, - placeholder: "Placeholder", - disabled: false, - readOnly: false, - withAsterisk: false, -}; - -export const sampleArgs = { - value: 1234, - label: "Goal value", - description: "Constant line added as a marker to the chart", - placeholder: "0", - error: "required", -}; - -export const argTypes = { - variant: { - options: ["default", "unstyled"], - control: { type: "inline-radio" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "number" }, - }, - description: { - control: { type: "number" }, - }, - placeholder: { - control: { type: "number" }, - }, - error: { - control: { type: "number" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, -}; - -<Meta - title="Inputs/NumberInput" - component={NumberInput} - args={args} - argTypes={argTypes} -/> - -# NumberInput - -Our themed wrapper around [Mantine NumberInput](https://v6.mantine.dev/core/number-input/). - -## Docs - -- [Figma File](https://www.figma.com/file/YWyb541aUHtYXJVPzyYSbg/Input-%2F-Numbers?type=design&node-id=1-96&mode=design&t=1bDfUrJc5alZmVpx-0) -- [Mantine NumberInput Docs](https://v6.mantine.dev/core/number-input/) - -## Examples - -export const DefaultTemplate = args => <NumberInput {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <NumberInput {...args} variant="default" /> - <NumberInput {...args} variant="unstyled" /> - </Stack> -); - -export const IconTemplate = args => ( - <VariantTemplate {...args} icon={<Icon name="int" />} /> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); -EmptyMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Filled - -export const FilledMd = VariantTemplate.bind({}); -FilledMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Filled, md">{FilledMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Icon - -export const IconMd = IconTemplate.bind({}); -IconMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Icon, md">{IconMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = IconTemplate.bind({}); -ReadOnlyMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - readOnly: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Filled - -export const FilledXs = VariantTemplate.bind({}); -FilledXs.args = { - ...FilledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Filled, xs">{FilledXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Icon - -export const IconXs = VariantTemplate.bind({}); -IconXs.args = { - ...IconMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Icon, xs">{IconXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = VariantTemplate.bind({}); -ReadOnlyXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.stories.tsx b/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20ad74e4eaa2fab97bfe5af923eafb812f0b2e5c --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/NumberInput/NumberInput.stories.tsx @@ -0,0 +1,235 @@ +import { Icon, Stack } from "metabase/ui"; + +import { NumberInput, type NumberInputProps } from "./"; + +const args = { + variant: "default", + size: "md", + label: "Label", + description: undefined, + error: undefined, + placeholder: "Placeholder", + disabled: false, + readOnly: false, + withAsterisk: false, +}; + +const sampleArgs = { + value: 1234, + label: "Goal value", + description: "Constant line added as a marker to the chart", + placeholder: "0", + error: "required", +}; + +const argTypes = { + variant: { + options: ["default", "unstyled"], + control: { type: "inline-radio" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "number" }, + }, + description: { + control: { type: "number" }, + }, + placeholder: { + control: { type: "number" }, + }, + error: { + control: { type: "number" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, +}; + +const VariantTemplate = (args: NumberInputProps) => ( + <Stack> + <NumberInput {...args} variant="default" /> + <NumberInput {...args} variant="unstyled" /> + </Stack> +); + +const IconTemplate = (args: NumberInputProps) => ( + <VariantTemplate {...args} icon={<Icon name="int" />} /> +); + +export default { + title: "Inputs/NumberInput", + component: NumberInput, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + }, +}; + +export const FilledMd = { + render: VariantTemplate, + name: "Filled, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + }, +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const IconMd = { + render: IconTemplate, + name: "Icon, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: IconTemplate, + name: "Read only, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + readOnly: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + ...EmptyMd.args, + size: "xs", + }, +}; + +export const FilledXs = { + render: VariantTemplate, + name: "Filled, xs", + args: { + ...FilledMd.args, + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const IconXs = { + render: IconTemplate, + name: "Icon, xs", + args: { + ...IconMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: VariantTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/Radio/Radio.mdx b/frontend/src/metabase/ui/components/inputs/Radio/Radio.mdx new file mode 100644 index 0000000000000000000000000000000000000000..4564b5b7877f5fc3205e38188d24965403fd2b49 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Radio/Radio.mdx @@ -0,0 +1,65 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Radio, Stack } from "metabase/ui"; +import * as RadioStories from "./Radio.stories"; + +<Meta of={RadioStories} /> + +# Radio + +Our themed wrapper around [Mantine Radio](https://v6.mantine.dev/core/radio/). + +## When to use Radio + +Radio buttons allow users to select a single option from a list of mutually exclusive options. All possible options are exposed up front for users to compare. + +## Docs + +- [Figma File](https://www.figma.com/file/7LCGPhkbJdrhdIaeiU1O9c/Input-%2F-Radio?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) +- [Mantine Radio Docs](https://v6.mantine.dev/core/radio/) + +## Usage guidelines + +- **Use this component if there are more than 5 options**. If there are fewer options, feel free to check out Radio or Select. +- For option ordering, try to use your best judgement on a sensible order. For example, Yes should come before No. Alphabetical ordering is usually a good fallback if there's no inherent order in your set of choices. +- In almost all circumstances you'll want to use `<Radio.Group>` to provide a set of options and help with defaultValues and state management between them. + +## Examples + +<Canvas> + <Story of={RadioStories.Default} /> +</Canvas> + +### Radio.Group + +<Canvas> + <Story of={RadioStories.RadioGroup} /> +</Canvas> + +### Label + +<Canvas> + <Story of={RadioStories.Label} /> +</Canvas> + +#### Left label position + +<Canvas> + <Story of={RadioStories.LabelLeftPosition} /> +</Canvas> + +### Description + +<Canvas> + <Story of={RadioStories.Description} /> +</Canvas> + +#### Left label position + +<Canvas> + <Story of={RadioStories.DescriptionLeftPosition} /> +</Canvas> + +## Related components + +- Checkbox +- Select diff --git a/frontend/src/metabase/ui/components/inputs/Radio/Radio.stories.mdx b/frontend/src/metabase/ui/components/inputs/Radio/Radio.stories.mdx deleted file mode 100644 index dc9bdbb4376b5abbc4a45e706511771fb84ec346..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/Radio/Radio.stories.mdx +++ /dev/null @@ -1,135 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Radio, Stack } from "metabase/ui"; - -export const args = { - label: "Label", - description: "", - disabled: false, - labelPosition: "right", -}; - -export const argTypes = { - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - disabled: { - control: { type: "boolean" }, - }, - labelPosition: { - options: ["left", "right"], - control: { type: "inline-radio" }, - }, -}; - -<Meta title="Inputs/Radio" component={Radio} args={args} argTypes={argTypes} /> - -# Radio - -Our themed wrapper around [Mantine Radio](https://v6.mantine.dev/core/radio/). - -## When to use Radio - -Radio buttons allow users to select a single option from a list of mutually exclusive options. All possible options are exposed up front for users to compare. - -## Docs - -- [Figma File](https://www.figma.com/file/7LCGPhkbJdrhdIaeiU1O9c/Input-%2F-Radio?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) -- [Mantine Radio Docs](https://v6.mantine.dev/core/radio/) - -## Usage guidelines - -- **Use this component if there are more than 5 options**. If there are fewer options, feel free to check out Radio or Select. -- For option ordering, try to use your best judgement on a sensible order. For example, Yes should come before No. Alphabetical ordering is usually a good fallback if there's no inherent order in your set of choices. -- In almost all circumstances you'll want to use `<Radio.Group>` to provide a set of options and help with defaultValues and state management between them. - -## Examples - -export const DefaultTemplate = args => <Radio {...args} />; - -export const RadioGroupTemplate = args => ( - <Radio.Group - defaultValue={"react"} - label="An array of good frameworks" - description="But which one to use?" - > - <Stack mt="md"> - <Radio value="react" label="React" /> - <Radio value="svelte" label="Svelte" /> - <Radio value="ng" label="Angular" /> - <Radio value="vue" label="Vue" /> - </Stack> - </Radio.Group> -); - -export const StateTemplate = args => ( - <Stack> - <Radio {...args} label="Default radio" /> - <Radio {...args} label="Checked radio" defaultChecked /> - <Radio {...args} label="Disabled radio" disabled /> - <Radio {...args} label="Disabled checked radio" disabled defaultChecked /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Radio.Group - -export const RadioGroup = RadioGroupTemplate.bind({}); - -<Canvas> - <Story name="Radio group">{RadioGroup}</Story> -</Canvas> - -### Label - -export const Label = StateTemplate.bind({}); - -<Canvas> - <Story name="Label">{Label}</Story> -</Canvas> - -#### Left label position - -export const LabelLeft = StateTemplate.bind({}); -LabelLeft.args = { - labelPosition: "left", -}; - -<Canvas> - <Story name="Label, left position">{LabelLeft}</Story> -</Canvas> - -### Description - -export const Description = StateTemplate.bind({}); -Description.args = { - description: "Description", -}; - -<Canvas> - <Story name="Description">{Description}</Story> -</Canvas> - -export const DescriptionLeft = StateTemplate.bind({}); -DescriptionLeft.args = { - description: "Description", - labelPosition: "left", -}; - -#### Left label position - -<Canvas> - <Story name="Description, left position">{DescriptionLeft}</Story> -</Canvas> - -## Related components - -- Checkbox -- Select diff --git a/frontend/src/metabase/ui/components/inputs/Radio/Radio.stories.tsx b/frontend/src/metabase/ui/components/inputs/Radio/Radio.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b6bd77d74ea802b14ee947cd5999fb4dfd0e2691 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Radio/Radio.stories.tsx @@ -0,0 +1,94 @@ +import { Radio, type RadioProps, Stack } from "metabase/ui"; + +const args = { + label: "Label", + description: "", + disabled: false, + labelPosition: "right", +}; + +const argTypes = { + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + labelPosition: { + options: ["left", "right"], + control: { type: "inline-radio" }, + }, +}; + +const RadioGroupTemplate = () => ( + <Radio.Group + defaultValue={"react"} + label="An array of good frameworks" + description="But which one to use?" + > + <Stack mt="md"> + <Radio value="react" label="React" /> + <Radio value="svelte" label="Svelte" /> + <Radio value="ng" label="Angular" /> + <Radio value="vue" label="Vue" /> + </Stack> + </Radio.Group> +); + +const StateTemplate = (args: RadioProps) => ( + <Stack> + <Radio {...args} label="Default radio" /> + <Radio {...args} label="Checked radio" defaultChecked /> + <Radio {...args} label="Disabled radio" disabled /> + <Radio {...args} label="Disabled checked radio" disabled defaultChecked /> + </Stack> +); + +export default { + title: "Inputs/Radio", + component: Radio, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const RadioGroup = { + render: RadioGroupTemplate, + name: "Radio group", +}; + +export const Label = { + render: StateTemplate, + name: "Label", +}; + +export const LabelLeftPosition = { + render: StateTemplate, + name: "Label, left position", + args: { + labelPosition: "left", + }, +}; + +export const Description = { + render: StateTemplate, + name: "Description", + args: { + description: "Description", + }, +}; + +export const DescriptionLeftPosition = { + render: StateTemplate, + name: "Description, left position", + args: { + description: "Description", + labelPosition: "left", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.mdx b/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.mdx new file mode 100644 index 0000000000000000000000000000000000000000..cf6df8deb63cf728ecc20ff1c7f29ebc2ab3a230 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.mdx @@ -0,0 +1,26 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { SegmentedControl, Box, Center } from "metabase/ui"; +import { Icon } from "metabase/ui"; +import * as SegmentedControlStories from "./SegmentedControl.stories"; + +<Meta of={SegmentedControlStories} /> + +# SegmentedControl + +Our themed wrapper around [Mantine SegmentedControl](https://mantine.dev/core/segmented-control/). + +## Docs + +- [Mantine SegmentedControl Docs](https://mantine.dev/core/segmented-control/) + +## Examples + +<Canvas> + <Story of={SegmentedControlStories.Default} /> +</Canvas> + +### Full width + +<Canvas> + <Story of={SegmentedControlStories.FullWidth} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.stories.mdx b/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.stories.mdx deleted file mode 100644 index 1f1dd605fb3585120951fabb6612676f20513083..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.stories.mdx +++ /dev/null @@ -1,76 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { SegmentedControl, Box, Center } from "metabase/ui"; -import { Icon } from "metabase/ui"; - -export const args = { - data: [ - { - label: ( - <Center> - <Icon name="embed" /> - <Box ml="0.5rem">Code</Box> - </Center> - ), - value: "code", - }, - { - label: ( - <Center> - <Icon name="eye_filled" /> - <Box ml="0.5rem">Preview</Box> - </Center> - ), - value: "preview", - }, - ], - fullWidth: false, -}; - -<Meta - title="Inputs/SegmentedControl" - component={SegmentedControl} - args={args} -/> - -# SegmentedControl - -Our themed wrapper around [Mantine SegmentedControl](https://mantine.dev/core/segmented-control/). - -## Docs - -- [Mantine SegmentedControl Docs](https://mantine.dev/core/segmented-control/) - -## Examples - -export const DefaultTemplate = args => <SegmentedControl {...args} />; - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Full width - -export const FullWidth = DefaultTemplate.bind({}); -FullWidth.args = { - data: [ - { - label: "Light", - value: "light", - }, - { - label: "Dark", - value: "dark", - }, - { - label: "Transparent", - value: "transparent", - }, - ], - fullWidth: true, -}; - -<Canvas> - <Story name="Full width">{FullWidth}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.stories.tsx b/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f96a7ab870422d2231d6652afb718f87e35cc9ea --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/SegmentedControl/SegmentedControl.stories.tsx @@ -0,0 +1,56 @@ +import { Box, Center, Icon, SegmentedControl } from "metabase/ui"; + +const args = { + data: [ + { + label: ( + <Center> + <Icon name="embed" /> + <Box ml="0.5rem">Code</Box> + </Center> + ), + value: "code", + }, + { + label: ( + <Center> + <Icon name="eye_filled" /> + <Box ml="0.5rem">Preview</Box> + </Center> + ), + value: "preview", + }, + ], + fullWidth: false, +}; + +export default { + title: "Inputs/SegmentedControl", + component: SegmentedControl, + args, +}; + +export const Default = { + name: "Default", +}; + +export const FullWidth = { + name: "Full width", + args: { + data: [ + { + label: "Light", + value: "light", + }, + { + label: "Dark", + value: "dark", + }, + { + label: "Transparent", + value: "transparent", + }, + ], + fullWidth: true, + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/Select/Select.mdx b/frontend/src/metabase/ui/components/inputs/Select/Select.mdx new file mode 100644 index 0000000000000000000000000000000000000000..3cfc63b70b949c952de193b0312d2eb622a618e2 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Select/Select.mdx @@ -0,0 +1,152 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Select, Stack } from "metabase/ui"; +import * as SelectStories from "./Select.stories"; + +<Meta of={SelectStories} /> + +# Select + +Our themed wrapper around [Mantine Select](https://v6.mantine.dev/core/select/). + +## Docs + +- [Figma File](https://www.figma.com/file/21uCY0czmCfb6QBjRce0I8/Input-%2F-Select?type=design&node-id=1-96&mode=design&t=Mk8RP6HsneuWzHtr-0) +- [Mantine Select Docs](https://v6.mantine.dev/core/select/) + +## Examples + +<Canvas> + <Story of={SelectStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={SelectStories.EmptyMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={SelectStories.AsteriskMd} /> +</Canvas> + +#### Clearable + +<Canvas> + <Story of={SelectStories.ClearableMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={SelectStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={SelectStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={SelectStories.ErrorMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={SelectStories.ReadOnlyMd} /> +</Canvas> + +#### Icons + +<Canvas> + <Story of={SelectStories.IconsMd} /> +</Canvas> + +#### Groups + +<Canvas> + <Story of={SelectStories.GroupsMd} /> +</Canvas> + +#### Large sets + +<Canvas> + <Story of={SelectStories.LargeSetsMd} /> +</Canvas> + +#### Searchable + +<Canvas> + <Story of={SelectStories.SearchableMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={SelectStories.EmptyXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={SelectStories.AsteriskXs} /> +</Canvas> + +#### Clearable + +<Canvas> + <Story of={SelectStories.ClearableXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={SelectStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={SelectStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={SelectStories.ErrorXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={SelectStories.ReadOnlyXs} /> +</Canvas> + +#### Icons + +<Canvas> + <Story of={SelectStories.IconsXs} /> +</Canvas> + +#### Groups + +<Canvas> + <Story of={SelectStories.GroupsXs} /> +</Canvas> + +#### Large sets + +<Canvas> + <Story of={SelectStories.LargeSetsXs} /> +</Canvas> + +#### Searchable + +<Canvas> + <Story of={SelectStories.SearchableXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/Select/Select.stories.mdx b/frontend/src/metabase/ui/components/inputs/Select/Select.stories.mdx deleted file mode 100644 index d70d995d08686e8fa8513174be6922d40f5fd5cd..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/Select/Select.stories.mdx +++ /dev/null @@ -1,402 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Select, Stack } from "metabase/ui"; - -export const dataWithGroupsLarge = [ - { value: "10", label: "Entity key", icon: "label", group: "Overall row" }, - { value: "11", label: "Entity name", icon: "string", group: "Overall row" }, - { - value: "12", - label: "Foreign key", - icon: "connections", - group: "Overall row", - }, - { value: "13", label: "Category", icon: "string", group: "Common" }, - { - value: "14", - label: "Comment", - icon: "string", - group: "Common", - disabled: true, - }, - { value: "15", label: "Description", icon: "string", group: "Common" }, - { value: "16", label: "Title", icon: "string", group: "Common" }, - { value: "17", label: "City", icon: "location", group: "Location" }, - { value: "18", label: "Country", icon: "location", group: "Location" }, - { value: "19", label: "Latitude", icon: "location", group: "Location" }, - { value: "20", label: "Longitude", icon: "location", group: "Location" }, - { value: "21", label: "Longitude", icon: "location", group: "Location" }, - { value: "22", label: "State", icon: "location", group: "Location" }, - { value: "23", label: "Zip code", icon: "location", group: "Location" }, -]; - -export const dataWithGroups = dataWithGroupsLarge.slice(0, 6); - -export const dataWithIcons = dataWithGroups.map(item => ({ - ...item, - group: undefined, -})); - -export const dataWithLabels = dataWithIcons.map(item => ({ - ...item, - icon: undefined, -})); - -export const args = { - data: dataWithLabels, - size: "md", - label: "Field type", - description: undefined, - error: undefined, - placeholder: "No semantic type", - searchable: false, - disabled: false, - readOnly: false, - withAsterisk: false, - dropdownPosition: "flip", -}; - -export const sampleArgs = { - value: dataWithLabels[0].value, - description: "Determines how Metabase displays the field", - error: "required", -}; - -export const argTypes = { - data: { - control: { type: "json" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - searchable: { - control: { type: "boolean" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, - dropdownPosition: { - options: ["bottom", "top", "flip"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Inputs/Select" - component={Select} - args={args} - argTypes={argTypes} -/> - -# Select - -Our themed wrapper around [Mantine Select](https://v6.mantine.dev/core/select/). - -## Docs - -- [Figma File](https://www.figma.com/file/21uCY0czmCfb6QBjRce0I8/Input-%2F-Select?type=design&node-id=1-96&mode=design&t=Mk8RP6HsneuWzHtr-0) -- [Mantine Select Docs](https://v6.mantine.dev/core/select/) - -## Examples - -export const DefaultTemplate = args => <Select {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <Select {...args} /> - <Select {...args} variant="unstyled" /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Clearable - -export const ClearableMd = VariantTemplate.bind({}); -ClearableMd.args = { - defaultValue: sampleArgs.value, - clearable: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Clearable, md">{ClearableMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - description: sampleArgs.description, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - description: sampleArgs.description, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = VariantTemplate.bind({}); -ReadOnlyMd.args = { - defaultValue: sampleArgs.value, - description: sampleArgs.description, - readOnly: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -#### Icons - -export const IconsMd = VariantTemplate.bind({}); -IconsMd.args = { - data: dataWithIcons, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Icons, md">{IconsMd}</Story> -</Canvas> - -#### Groups - -export const GroupsMd = VariantTemplate.bind({}); -GroupsMd.args = { - data: dataWithGroups, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Groups, md">{GroupsMd}</Story> -</Canvas> - -#### Large sets - -export const LargeSetsMd = VariantTemplate.bind({}); -LargeSetsMd.args = { - data: dataWithGroupsLarge, - description: sampleArgs.description, - withAsterisk: true, -}; - -<Canvas> - <Story name="Large sets, md">{LargeSetsMd}</Story> -</Canvas> - -#### Searchable - -export const SearchableMd = VariantTemplate.bind({}); -SearchableMd.args = { - data: dataWithGroupsLarge, - description: sampleArgs.description, - searchable: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Searchable, md">{SearchableMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Clearable - -export const ClearableXs = VariantTemplate.bind({}); -ClearableXs.args = { - ...ClearableMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Clearable, xs">{ClearableXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = VariantTemplate.bind({}); -ReadOnlyXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> - -#### Icons - -export const IconsXs = VariantTemplate.bind({}); -IconsXs.args = { - ...IconsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Icons, xs">{IconsXs}</Story> -</Canvas> - -#### Groups - -export const GroupsXs = VariantTemplate.bind({}); -GroupsXs.args = { - ...GroupsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Groups, xs">{GroupsXs}</Story> -</Canvas> - -#### Large sets - -export const LargeSetsXs = VariantTemplate.bind({}); -LargeSetsXs.args = { - ...LargeSetsMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Large sets, xs">{LargeSetsXs}</Story> -</Canvas> - -#### Searchable - -export const SearchableXs = VariantTemplate.bind({}); -SearchableXs.args = { - ...SearchableMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Searchable, xs">{SearchableXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/Select/Select.stories.tsx b/frontend/src/metabase/ui/components/inputs/Select/Select.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed40b0740300adce6c45980355b8ab2776b19f3c --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Select/Select.stories.tsx @@ -0,0 +1,319 @@ +import { Select, type SelectProps, Stack } from "metabase/ui"; + +const dataWithGroupsLarge = [ + { value: "10", label: "Entity key", icon: "label", group: "Overall row" }, + { value: "11", label: "Entity name", icon: "string", group: "Overall row" }, + { + value: "12", + label: "Foreign key", + icon: "connections", + group: "Overall row", + }, + { value: "13", label: "Category", icon: "string", group: "Common" }, + { + value: "14", + label: "Comment", + icon: "string", + group: "Common", + disabled: true, + }, + { value: "15", label: "Description", icon: "string", group: "Common" }, + { value: "16", label: "Title", icon: "string", group: "Common" }, + { value: "17", label: "City", icon: "location", group: "Location" }, + { value: "18", label: "Country", icon: "location", group: "Location" }, + { value: "19", label: "Latitude", icon: "location", group: "Location" }, + { value: "20", label: "Longitude", icon: "location", group: "Location" }, + { value: "21", label: "Longitude", icon: "location", group: "Location" }, + { value: "22", label: "State", icon: "location", group: "Location" }, + { value: "23", label: "Zip code", icon: "location", group: "Location" }, +]; + +const dataWithGroups = dataWithGroupsLarge.slice(0, 6); + +const dataWithIcons = dataWithGroups.map(item => ({ + ...item, + group: undefined, +})); + +const dataWithLabels = dataWithIcons.map(item => ({ + ...item, + icon: undefined, +})); + +const args = { + data: dataWithLabels, + size: "md", + label: "Field type", + description: undefined, + error: undefined, + placeholder: "No semantic type", + searchable: false, + disabled: false, + readOnly: false, + withAsterisk: false, + dropdownPosition: "flip", +}; + +const sampleArgs = { + value: dataWithLabels[0].value, + description: "Determines how Metabase displays the field", + error: "required", +}; + +const argTypes = { + data: { + control: { type: "json" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + searchable: { + control: { type: "boolean" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, + dropdownPosition: { + options: ["bottom", "top", "flip"], + control: { type: "inline-radio" }, + }, +}; + +const VariantTemplate = (args: SelectProps) => ( + <Stack> + <Select {...args} /> + <Select {...args} variant="unstyled" /> + </Stack> +); + +export default { + title: "Inputs/Select", + component: Select, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + withAsterisk: true, + }, +}; + +export const ClearableMd = { + render: VariantTemplate, + name: "Clearable, md", + args: { + defaultValue: sampleArgs.value, + clearable: true, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + description: sampleArgs.description, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + description: sampleArgs.description, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: VariantTemplate, + name: "Read only, md", + args: { + defaultValue: sampleArgs.value, + description: sampleArgs.description, + readOnly: true, + withAsterisk: true, + }, +}; + +export const IconsMd = { + render: VariantTemplate, + name: "Icons, md", + args: { + data: dataWithIcons, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const GroupsMd = { + render: VariantTemplate, + name: "Groups, md", + args: { + data: dataWithGroups, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const LargeSetsMd = { + render: VariantTemplate, + name: "Large sets, md", + args: { + data: dataWithGroupsLarge, + description: sampleArgs.description, + withAsterisk: true, + }, +}; + +export const SearchableMd = { + render: VariantTemplate, + name: "Searchable, md", + args: { + data: dataWithGroupsLarge, + description: sampleArgs.description, + searchable: true, + withAsterisk: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const ClearableXs = { + render: VariantTemplate, + name: "Clearable, xs", + args: { + ...ClearableMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: VariantTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; + +export const IconsXs = { + render: VariantTemplate, + name: "Icons, xs", + args: { + ...IconsMd.args, + size: "xs", + }, +}; + +export const GroupsXs = { + render: VariantTemplate, + name: "Groups, xs", + args: { + ...GroupsMd.args, + size: "xs", + }, +}; + +export const LargeSetsXs = { + render: VariantTemplate, + name: "Large sets, xs", + args: { + ...LargeSetsMd.args, + size: "xs", + }, +}; + +export const SearchableXs = { + render: VariantTemplate, + name: "Searchable, xs", + args: { + ...SearchableMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/Switch/Switch.mdx b/frontend/src/metabase/ui/components/inputs/Switch/Switch.mdx new file mode 100644 index 0000000000000000000000000000000000000000..816f2c28bf4a157f9e5d818e6fee746ab4e8d180 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Switch/Switch.mdx @@ -0,0 +1,53 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import * as SwitchStories from "./Switch.stories"; + +import { Switch, Stack } from "metabase/ui"; + +<Meta of={SwitchStories} /> + +# Switch + +Our themed wrapper around [Mantine Switch](https://mantine.dev/core/switch/). + +## When to use Switch + +Use Switch when you have a setting that can be only either on or off, or enabled/disabled. If there are multiple related options in a list that could be selected or chosen, use a checkbox group instead. + +## Docs + +- [Figma File](https://www.figma.com/file/xdNKMROC99J6Z4Sqg6V3JI/Input-%2F-Switch?type=design&node-id=1-96&mode=design&t=0K7GSP6rqj8M2muz-0) +- [Mantine Checkbox Docs](https://mantine.dev/core/switch/) + +## Usage guidelines + +In most situations you should use the `md` size variant, with the label on the left and the toggle on the right. (This allows users to read what the setting or option is first.) + +## Examples + +<Canvas> + <Story of={SwitchStories.Default} /> +</Canvas> + +### Label + +<Canvas> + <Story of={SwitchStories.Label} /> +</Canvas> + +### Left label position + +<Canvas> + <Story of={SwitchStories.LabelLeftPosition} /> +</Canvas> + +### Description + +<Canvas> + <Story of={SwitchStories.Description} /> +</Canvas> + +### Left description position + +<Canvas> + <Story of={SwitchStories.DescriptionLeftPosition} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/Switch/Switch.stories.mdx b/frontend/src/metabase/ui/components/inputs/Switch/Switch.stories.mdx deleted file mode 100644 index 70e98cd8579915f57717b1355c4aea2bc3dd784e..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/Switch/Switch.stories.mdx +++ /dev/null @@ -1,122 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; - -import { Switch, Stack } from "metabase/ui"; - -export const args = { - labelPosition: "right", - label: "Eat all the cheese", - description: undefined, - size: "md", - disabled: false, -}; - -export const argTypes = { - labelPosition: { - control: { type: "inline-radio" }, - options: ["left", "right"], - }, - variant: { - control: { type: "inline-radio" }, - options: ["default", "stretch"], - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - size: { - control: { type: "inline-radio" }, - options: ["xs", "sm", "md"], - }, - disabled: { - control: { type: "boolean" }, - }, -}; - -<Meta - title="Inputs/Switch" - component={Switch} - args={args} - argTypes={argTypes} -/> - -# Switch - -Our themed wrapper around [Mantine Switch](https://mantine.dev/core/switch/). - -## When to use Switch - -Use Switch when you have a setting that can be only either on or off, or enabled/disabled. If there are multiple related options in a list that could be selected or chosen, use a checkbox group instead. - -## Docs - -- [Figma File](https://www.figma.com/file/xdNKMROC99J6Z4Sqg6V3JI/Input-%2F-Switch?type=design&node-id=1-96&mode=design&t=0K7GSP6rqj8M2muz-0) -- [Mantine Checkbox Docs](https://mantine.dev/core/switch/) - -## Usage guidelines - -In most situations you should use the `md` size variant, with the label on the left and the toggle on the right. (This allows users to read what the setting or option is first.) - -## Examples - -export const DefaultTemplate = args => <Switch {...args} />; - -export const StateTemplate = args => ( - <Stack> - <Switch {...args} label="Unchecked switch" checked={false} /> - <Switch {...args} label="Checked switch" checked /> - <Switch {...args} label="Disabled unchecked switch" disabled /> - <Switch {...args} label="Disabled checked switch" disabled checked /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Label - -export const Label = StateTemplate.bind({}); - -<Canvas> - <Story name="Label">{Label}</Story> -</Canvas> - -### Left label position - -export const LabelLeft = StateTemplate.bind({}); -LabelLeft.args = { - labelPosition: "left", -}; - -<Canvas> - <Story name="Label, left position" args={LabelLeft.args}> - {LabelLeft} - </Story> -</Canvas> - -### Description - -export const Description = StateTemplate.bind({}); -Description.args = { - description: "Every type of cheese will be consumed, regardless of stink.", -}; - -<Canvas> - <Story name="Description">{Description}</Story> -</Canvas> - -### Left description position - -export const DescriptionLeft = StateTemplate.bind({}); -DescriptionLeft.args = { - labelPosition: "left", - description: "Every type of cheese will be consumed, regardless of stink.", -}; - -<Canvas> - <Story name="Description, left position">{DescriptionLeft}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/Switch/Switch.stories.tsx b/frontend/src/metabase/ui/components/inputs/Switch/Switch.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..03278232ca165084d49e33d36bccb4bcb2e2861e --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Switch/Switch.stories.tsx @@ -0,0 +1,81 @@ +import { Stack, Switch, type SwitchProps } from "metabase/ui"; + +const args = { + labelPosition: "right", + label: "Eat all the cheese", + description: undefined, + size: "md", + disabled: false, +}; + +const argTypes = { + labelPosition: { + control: { type: "inline-radio" }, + options: ["left", "right"], + }, + variant: { + control: { type: "inline-radio" }, + options: ["default", "stretch"], + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + size: { + control: { type: "inline-radio" }, + options: ["xs", "sm", "md"], + }, + disabled: { + control: { type: "boolean" }, + }, +}; + +const StateTemplate = (args: SwitchProps) => ( + <Stack> + <Switch {...args} label="Unchecked switch" checked={false} /> + <Switch {...args} label="Checked switch" checked /> + <Switch {...args} label="Disabled unchecked switch" disabled /> + <Switch {...args} label="Disabled checked switch" disabled checked /> + </Stack> +); + +export default { + title: "Inputs/Switch", + component: Switch, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const Label = { + render: StateTemplate, + name: "Label", +}; + +export const LabelLeftPosition = { + render: StateTemplate, + name: "Label, left position", + args: { labelPosition: "left" }, +}; + +export const Description = { + render: StateTemplate, + name: "Description", + args: { + description: "Every type of cheese will be consumed, regardless of stink.", + }, +}; + +export const DescriptionLeftPosition = { + render: StateTemplate, + name: "Description, left position", + args: { + labelPosition: "left", + description: "Every type of cheese will be consumed, regardless of stink.", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.mdx b/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.mdx new file mode 100644 index 0000000000000000000000000000000000000000..eb5335324358d9fa4459bfd11de10d7789293dea --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.mdx @@ -0,0 +1,129 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Icon } from "metabase/ui"; +import { Stack, TextInput } from "metabase/ui"; +import * as TextInputStories from "./TextInput.stories"; + +<Meta of={TextInputStories} /> + +# TextInput + +Our themed wrapper around [Mantine TextInput](https://v6.mantine.dev/core/text-input/). + +## Docs + +- [Figma File](https://www.figma.com/file/oIZhYS5OoRA7twd4KqN4Eu/Input-%2F-Text?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) +- [Mantine TextInput Docs](https://v6.mantine.dev/core/text-input/) + +## Examples + +<Canvas> + <Story of={TextInputStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={TextInputStories.EmptyMd} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={TextInputStories.FilledMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={TextInputStories.AsteriskMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={TextInputStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={TextInputStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={TextInputStories.ErrorMd} /> +</Canvas> + +#### Icon + +<Canvas> + <Story of={TextInputStories.IconMd} /> +</Canvas> + +#### Right section + +<Canvas> + <Story of={TextInputStories.RightSectionMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={TextInputStories.ReadOnlyMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={TextInputStories.EmptyXs} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={TextInputStories.FilledXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={TextInputStories.AsteriskXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={TextInputStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={TextInputStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={TextInputStories.ErrorXs} /> +</Canvas> + +#### Icon + +<Canvas> + <Story of={TextInputStories.IconXs} /> +</Canvas> + +#### Right section + +<Canvas> + <Story of={TextInputStories.RightSectionXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={TextInputStories.ReadOnlyXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.stories.mdx b/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.stories.mdx deleted file mode 100644 index 5236aed01ee40a62828042ee016a2094233cd383..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.stories.mdx +++ /dev/null @@ -1,324 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Icon } from "metabase/ui"; -import { Stack, TextInput } from "metabase/ui"; - -export const args = { - variant: "default", - size: "md", - label: "Label", - description: undefined, - error: undefined, - placeholder: "Placeholder", - disabled: false, - readOnly: false, - withAsterisk: false, -}; - -export const sampleArgs = { - value: "Metabase", - label: "Company or team name", - description: "Name used for this instance", - placeholder: "Department of awesome", - error: "required", -}; - -export const argTypes = { - variant: { - options: ["default", "unstyled"], - control: { type: "inline-radio" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, -}; - -<Meta - title="Inputs/TextInput" - component={TextInput} - args={args} - argTypes={argTypes} -/> - -# TextInput - -Our themed wrapper around [Mantine TextInput](https://v6.mantine.dev/core/text-input/). - -## Docs - -- [Figma File](https://www.figma.com/file/oIZhYS5OoRA7twd4KqN4Eu/Input-%2F-Text?type=design&node-id=1-96&mode=design&t=yaNljw178EFJeU7k-0) -- [Mantine TextInput Docs](https://v6.mantine.dev/core/text-input/) - -## Examples - -export const DefaultTemplate = args => <TextInput {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <TextInput {...args} variant="default" /> - <TextInput {...args} variant="unstyled" /> - </Stack> -); - -export const IconTemplate = args => ( - <VariantTemplate {...args} icon={<Icon name="dashboard" />} /> -); - -export const RightSectionTemplate = args => ( - <VariantTemplate {...args} rightSection={<Icon name="chevrondown" />} /> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Filled - -export const FilledMd = VariantTemplate.bind({}); -FilledMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Filled, md">{FilledMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Icon - -export const IconMd = IconTemplate.bind({}); -IconMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Icon, md">{IconMd}</Story> -</Canvas> - -#### Right section - -export const RightSectionMd = RightSectionTemplate.bind({}); -RightSectionMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Right section, md">{RightSectionMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = RightSectionTemplate.bind({}); -ReadOnlyMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - readOnly: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Filled - -export const FilledXs = VariantTemplate.bind({}); -FilledXs.args = { - ...FilledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Filled, xs">{FilledXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Icon - -export const IconXs = IconTemplate.bind({}); -IconXs.args = { - ...IconMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Icon, xs">{IconXs}</Story> -</Canvas> - -#### Right section - -export const RightSectionXs = RightSectionTemplate.bind({}); -RightSectionXs.args = { - ...RightSectionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Right section, xs">{RightSectionXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = RightSectionTemplate.bind({}); -RightSectionXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.stories.tsx b/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0bc4b31cf48b4e15929f5766dfb844a8f578ea29 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/TextInput/TextInput.stories.tsx @@ -0,0 +1,252 @@ +import { Icon, Stack, TextInput, type TextInputProps } from "metabase/ui"; + +const args = { + variant: "default", + size: "md", + label: "Label", + description: undefined, + error: undefined, + placeholder: "Placeholder", + disabled: false, + readOnly: false, + withAsterisk: false, +}; + +const sampleArgs = { + value: "Metabase", + label: "Company or team name", + description: "Name used for this instance", + placeholder: "Department of awesome", + error: "required", +}; + +const argTypes = { + variant: { + options: ["default", "unstyled"], + control: { type: "inline-radio" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, +}; + +const VariantTemplate = (args: TextInputProps) => ( + <Stack> + <TextInput {...args} variant="default" /> + <TextInput {...args} variant="unstyled" /> + </Stack> +); + +const IconTemplate = (args: TextInputProps) => ( + <VariantTemplate {...args} icon={<Icon name="dashboard" />} /> +); + +const RightSectionTemplate = (args: TextInputProps) => ( + <VariantTemplate {...args} rightSection={<Icon name="chevrondown" />} /> +); + +export default { + title: "Inputs/TextInput", + component: TextInput, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", +}; + +export const FilledMd = { + render: VariantTemplate, + name: "Filled, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + }, +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const IconMd = { + render: IconTemplate, + name: "Icon, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const RightSectionMd = { + render: RightSectionTemplate, + name: "Right section, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: RightSectionTemplate, + name: "Read only, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + readOnly: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + size: "xs", + }, +}; + +export const FilledXs = { + render: VariantTemplate, + name: "Filled, xs", + args: { + ...FilledMd.args, + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const IconXs = { + render: IconTemplate, + name: "Icon, xs", + args: { + ...IconMd.args, + size: "xs", + }, +}; + +export const RightSectionXs = { + render: RightSectionTemplate, + name: "Right section, xs", + args: { + ...RightSectionMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: RightSectionTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.mdx b/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b49ee0b8aa0b0baa01699f5112a79922d7145413 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.mdx @@ -0,0 +1,116 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Icon } from "metabase/ui"; +import { Stack, Textarea } from "metabase/ui"; +import * as TextareaStories from "./Textarea.stories"; + +<Meta of={TextareaStories} /> + +# Textarea + +Our themed wrapper around [Mantine Textarea](https://v6.mantine.dev/core/textarea/). + +## Docs + +- [Mantine Textarea Docs](https://v6.mantine.dev/core/textarea/) + +## Examples + +<Canvas> + <Story of={TextareaStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={TextareaStories.EmptyMd} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={TextareaStories.FilledMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={TextareaStories.AsteriskMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={TextareaStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={TextareaStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={TextareaStories.ErrorMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={TextareaStories.ReadOnlyMd} /> +</Canvas> + +#### Autosize + +<Canvas> + <Story of={TextareaStories.AutosizeMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={TextareaStories.EmptyXs} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={TextareaStories.FilledXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={TextareaStories.AsteriskXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={TextareaStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={TextareaStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={TextareaStories.ErrorXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={TextareaStories.ReadOnlyXs} /> +</Canvas> + +#### Autosize + +<Canvas> + <Story of={TextareaStories.AutosizeXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.stories.mdx b/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.stories.mdx deleted file mode 100644 index 644e4bcf9f789bbfa2e9d51e734d7f6e60c62302..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.stories.mdx +++ /dev/null @@ -1,305 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Icon } from "metabase/ui"; -import { Stack, Textarea } from "metabase/ui"; - -export const args = { - variant: "default", - size: "md", - label: "Label", - description: undefined, - error: undefined, - placeholder: "Placeholder", - disabled: false, - readOnly: false, - withAsterisk: false, - autosize: false, - minRows: undefined, - maxRows: undefined, -}; - -export const sampleArgs = { - value: "Metabase", - label: "Company or team name", - description: "Name used for this instance", - placeholder: "Department of awesome", - error: "required", -}; - -export const argTypes = { - variant: { - options: ["default", "unstyled"], - control: { type: "inline-radio" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, - autosize: { - control: { type: "boolean" }, - }, - minRows: { - control: { type: "number" }, - }, - maxRows: { - control: { type: "number" }, - }, -}; - -<Meta - title="Inputs/Textarea" - component={Textarea} - args={args} - argTypes={argTypes} -/> - -# Textarea - -Our themed wrapper around [Mantine Textarea](https://v6.mantine.dev/core/textarea/). - -## Docs - -- [Mantine Textarea Docs](https://v6.mantine.dev/core/textarea/) - -## Examples - -export const DefaultTemplate = args => <Textarea {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <Textarea {...args} variant="default" /> - <Textarea {...args} variant="unstyled" /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); -EmptyMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Filled - -export const FilledMd = VariantTemplate.bind({}); -FilledMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Filled, md">{FilledMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = VariantTemplate.bind({}); -ReadOnlyMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - readOnly: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -#### Autosize - -export const AutosizeMd = VariantTemplate.bind({}); -AutosizeMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - autosize: true, -}; - -<Canvas> - <Story name="Autosize, md">{AutosizeMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Filled - -export const FilledXs = VariantTemplate.bind({}); -FilledXs.args = { - ...FilledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Filled, xs">{FilledXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = VariantTemplate.bind({}); -ReadOnlyXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> - -#### Autosize - -export const AutosizeXs = VariantTemplate.bind({}); -AutosizeXs.args = { - ...AutosizeXs.args, - size: "xs", -}; - -<Canvas> - <Story name="Autosize, xs">{AutosizeXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.stories.tsx b/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ad208e26b8c1233d18e7c6598112b2cdc5019b2 --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/Textarea/Textarea.stories.tsx @@ -0,0 +1,242 @@ +import { Stack, Textarea, type TextareaProps } from "metabase/ui"; + +const args = { + variant: "default", + size: "md", + label: "Label", + description: undefined, + error: undefined, + placeholder: "Placeholder", + disabled: false, + readOnly: false, + withAsterisk: false, + autosize: false, + minRows: undefined, + maxRows: undefined, +}; + +const sampleArgs = { + value: "Metabase", + label: "Company or team name", + description: "Name used for this instance", + placeholder: "Department of awesome", + error: "required", +}; + +const argTypes = { + variant: { + options: ["default", "unstyled"], + control: { type: "inline-radio" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, + autosize: { + control: { type: "boolean" }, + }, + minRows: { + control: { type: "number" }, + }, + maxRows: { + control: { type: "number" }, + }, +}; + +const VariantTemplate = (args: TextareaProps) => ( + <Stack> + <Textarea {...args} variant="default" /> + <Textarea {...args} variant="unstyled" /> + </Stack> +); + +export default { + title: "Inputs/Textarea", + component: Textarea, + args, + argTypes, +}; + +export const Default = { + render: VariantTemplate, + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + }, +}; + +export const FilledMd = { + render: VariantTemplate, + name: "Filled, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + }, +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: VariantTemplate, + name: "Read only, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + readOnly: true, + }, +}; + +export const AutosizeMd = { + render: VariantTemplate, + name: "Autosize, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + autosize: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + ...EmptyMd.args, + size: "xs", + }, +}; + +export const FilledXs = { + render: VariantTemplate, + name: "Filled, xs", + args: { + ...FilledMd.args, + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: VariantTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; + +export const AutosizeXs = { + render: VariantTemplate, + name: "Autosize, xs", + args: { + ...AutosizeMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.mdx b/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b3681436493d4cac6cfa7eaf0dc36045a4735c8e --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.mdx @@ -0,0 +1,104 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Stack, TimeInput } from "metabase/ui"; +import * as TimeInputStories from "./TimeInput.stories"; + +<Meta of={TimeInputStories} /> + +# TimeInput + +Our themed wrapper around [Mantine TimeInput](https://v6.mantine.dev/dates/time-input/). + +## Docs + +- [Mantine TimeInput Docs](https://v6.mantine.dev/dates/time-input/) +- [HTML Time Input Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time) + +# Examples + +<Canvas> + <Story of={TimeInputStories.Default} /> +</Canvas> + +### Size - md + +<Canvas> + <Story of={TimeInputStories.EmptyMd} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={TimeInputStories.FilledMd} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={TimeInputStories.AsteriskMd} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={TimeInputStories.DescriptionMd} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={TimeInputStories.DisabledMd} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={TimeInputStories.ErrorMd} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={TimeInputStories.ReadOnlyMd} /> +</Canvas> + +### Size - xs + +<Canvas> + <Story of={TimeInputStories.EmptyXs} /> +</Canvas> + +#### Filled + +<Canvas> + <Story of={TimeInputStories.FilledXs} /> +</Canvas> + +#### Asterisk + +<Canvas> + <Story of={TimeInputStories.AsteriskXs} /> +</Canvas> + +#### Description + +<Canvas> + <Story of={TimeInputStories.DescriptionXs} /> +</Canvas> + +#### Disabled + +<Canvas> + <Story of={TimeInputStories.DisabledXs} /> +</Canvas> + +#### Error + +<Canvas> + <Story of={TimeInputStories.ErrorXs} /> +</Canvas> + +#### Read only + +<Canvas> + <Story of={TimeInputStories.ReadOnlyXs} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.stories.mdx b/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.stories.mdx deleted file mode 100644 index 5bde69e5558210843884e2846d1e245d21107d38..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.stories.mdx +++ /dev/null @@ -1,265 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Stack, TimeInput } from "metabase/ui"; - -export const args = { - variant: "default", - size: "md", - label: "Label", - description: undefined, - error: undefined, - placeholder: "Placeholder", - disabled: false, - readOnly: false, - withAsterisk: false, - onChange: event => { - console.log(event.target.value); - }, -}; - -export const sampleArgs = { - value: new Date(0, 0, 1, 20, 40), - label: "Time of day", - description: "The time you've this page", - placholder: "HH:MM", - error: "required", -}; - -export const argTypes = { - variant: { - options: ["default", "unstyled"], - control: { type: "inline-radio" }, - }, - size: { - options: ["xs", "md"], - control: { type: "inline-radio" }, - }, - label: { - control: { type: "text" }, - }, - description: { - control: { type: "text" }, - }, - placeholder: { - control: { type: "text" }, - }, - error: { - control: { type: "text" }, - }, - disabled: { - control: { type: "boolean" }, - }, - readOnly: { - control: { type: "boolean" }, - }, - withAsterisk: { - control: { type: "boolean" }, - }, -}; - -<Meta title="Inputs/TimeInput" component={TimeInput} args={args} /> - -# TimeInput - -Our themed wrapper around [Mantine TimeInput](https://v6.mantine.dev/dates/time-input/). - -## Docs - -- [Mantine TimeInput Docs](https://v6.mantine.dev/dates/time-input/) -- [HTML Time Input Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time) - -# Examples - -export const DefaultTemplate = args => <TimeInput {...args} />; - -export const VariantTemplate = args => ( - <Stack> - <TimeInput {...args} variant="default" /> - <TimeInput {...args} variant="unstyled" /> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Size - md - -export const EmptyMd = VariantTemplate.bind({}); -EmptyMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Empty, md">{EmptyMd}</Story> -</Canvas> - -#### Filled - -export const FilledMd = VariantTemplate.bind({}); -FilledMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Filled, md">{FilledMd}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskMd = VariantTemplate.bind({}); -AsteriskMd.args = { - label: sampleArgs.label, - placeholder: sampleArgs.placeholder, - withAsterisk: true, -}; - -<Canvas> - <Story name="Asterisk, md">{AsteriskMd}</Story> -</Canvas> - -#### Description - -export const DescriptionMd = VariantTemplate.bind({}); -DescriptionMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, -}; - -<Canvas> - <Story name="Description, md">{DescriptionMd}</Story> -</Canvas> - -#### Disabled - -export const DisabledMd = VariantTemplate.bind({}); -DisabledMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - disabled: true, - withAsterisk: true, -}; - -<Canvas> - <Story name="Disabled, md">{DisabledMd}</Story> -</Canvas> - -#### Error - -export const ErrorMd = VariantTemplate.bind({}); -ErrorMd.args = { - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - error: sampleArgs.error, - withAsterisk: true, -}; - -<Canvas> - <Story name="Error, md">{ErrorMd}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyMd = VariantTemplate.bind({}); -ReadOnlyMd.args = { - defaultValue: sampleArgs.value, - label: sampleArgs.label, - description: sampleArgs.description, - placeholder: sampleArgs.placeholder, - readOnly: true, -}; - -<Canvas> - <Story name="Read only, md">{ReadOnlyMd}</Story> -</Canvas> - -### Size - xs - -export const EmptyXs = VariantTemplate.bind({}); -EmptyXs.args = { - ...EmptyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Empty, xs">{EmptyXs}</Story> -</Canvas> - -#### Filled - -export const FilledXs = VariantTemplate.bind({}); -FilledXs.args = { - ...FilledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Filled, xs">{FilledXs}</Story> -</Canvas> - -#### Asterisk - -export const AsteriskXs = VariantTemplate.bind({}); -AsteriskXs.args = { - ...AsteriskMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Asterisk, xs">{AsteriskXs}</Story> -</Canvas> - -#### Description - -export const DescriptionXs = VariantTemplate.bind({}); -DescriptionXs.args = { - ...DescriptionMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Description, xs">{DescriptionXs}</Story> -</Canvas> - -#### Disabled - -export const DisabledXs = VariantTemplate.bind({}); -DisabledXs.args = { - ...DisabledMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Disabled, xs">{DisabledXs}</Story> -</Canvas> - -#### Error - -export const ErrorXs = VariantTemplate.bind({}); -ErrorXs.args = { - ...ErrorMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Error, xs">{ErrorXs}</Story> -</Canvas> - -#### Read only - -export const ReadOnlyXs = VariantTemplate.bind({}); -ReadOnlyXs.args = { - ...ReadOnlyMd.args, - size: "xs", -}; - -<Canvas> - <Story name="Read only, xs">{ReadOnlyXs}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.stories.tsx b/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48ca56ff02463d6401c6c01a8cf95ef6f8a472ec --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/TimeInput/TimeInput.stories.tsx @@ -0,0 +1,213 @@ +import { Stack, TimeInput, type TimeInputProps } from "metabase/ui"; + +const args = { + variant: "default", + size: "md", + label: "Label", + description: undefined, + error: undefined, + placeholder: "Placeholder", + disabled: false, + readOnly: false, + withAsterisk: false, + onChange: (event: React.ChangeEvent<HTMLInputElement>) => { + // eslint-disable-next-line no-console + console.log(event.target.value); + }, +}; + +const sampleArgs = { + value: new Date(0, 0, 1, 20, 40), + label: "Time of day", + description: "The time you've this page", + placeholder: "HH:MM", + error: "required", +}; + +const argTypes = { + variant: { + options: ["default", "unstyled"], + control: { type: "inline-radio" }, + }, + size: { + options: ["xs", "md"], + control: { type: "inline-radio" }, + }, + label: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + placeholder: { + control: { type: "text" }, + }, + error: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, + readOnly: { + control: { type: "boolean" }, + }, + withAsterisk: { + control: { type: "boolean" }, + }, +}; + +const VariantTemplate = (args: TimeInputProps) => ( + <Stack> + <TimeInput {...args} variant="default" /> + <TimeInput {...args} variant="unstyled" /> + </Stack> +); + +export default { + title: "Inputs/TimeInput", + component: TimeInput, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const EmptyMd = { + render: VariantTemplate, + name: "Empty, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + }, +}; + +export const FilledMd = { + render: VariantTemplate, + name: "Filled, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + }, +}; + +export const AsteriskMd = { + render: VariantTemplate, + name: "Asterisk, md", + args: { + label: sampleArgs.label, + placeholder: sampleArgs.placeholder, + withAsterisk: true, + }, +}; + +export const DescriptionMd = { + render: VariantTemplate, + name: "Description, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + }, +}; + +export const DisabledMd = { + render: VariantTemplate, + name: "Disabled, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + disabled: true, + withAsterisk: true, + }, +}; + +export const ErrorMd = { + render: VariantTemplate, + name: "Error, md", + args: { + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + error: sampleArgs.error, + withAsterisk: true, + }, +}; + +export const ReadOnlyMd = { + render: VariantTemplate, + name: "Read only, md", + args: { + defaultValue: sampleArgs.value, + label: sampleArgs.label, + description: sampleArgs.description, + placeholder: sampleArgs.placeholder, + readOnly: true, + }, +}; + +export const EmptyXs = { + render: VariantTemplate, + name: "Empty, xs", + args: { + ...EmptyMd.args, + size: "xs", + }, +}; + +export const FilledXs = { + render: VariantTemplate, + name: "Filled, xs", + args: { + ...FilledMd.args, + size: "xs", + }, +}; + +export const AsteriskXs = { + render: VariantTemplate, + name: "Asterisk, xs", + args: { + ...AsteriskMd.args, + size: "xs", + }, +}; + +export const DescriptionXs = { + render: VariantTemplate, + name: "Description, xs", + args: { + ...DescriptionMd.args, + size: "xs", + }, +}; + +export const DisabledXs = { + render: VariantTemplate, + name: "Disabled, xs", + args: { + ...DisabledMd.args, + size: "xs", + }, +}; + +export const ErrorXs = { + render: VariantTemplate, + name: "Error, xs", + args: { + ...ErrorMd.args, + size: "xs", + }, +}; + +export const ReadOnlyXs = { + render: VariantTemplate, + name: "Read only, xs", + args: { + ...ReadOnlyMd.args, + size: "xs", + }, +}; diff --git a/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.mdx b/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.mdx new file mode 100644 index 0000000000000000000000000000000000000000..8c807f53350c412d81d3e2ad32e2cfd15eb7ab56 --- /dev/null +++ b/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.mdx @@ -0,0 +1,36 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Icon } from "metabase/ui"; +import { Group, Tabs } from "metabase/ui"; +import * as TabsStories from "./Tabs.stories"; + +<Meta of={TabsStories} /> + +# Tabs + +Our themed wrapper around [Mantine Tabs](https://v6.mantine.dev/core/tabs/). + +## Docs + +- [Figma File](https://www.figma.com/file/uPYsD4ncNpQPFzxsLnTnGd/Navigation-%2F-Tabs?type=design&node-id=1-96&mode=design&t=dtMJ59HbOKZ8TOgJ-0) +- [Mantine Tabs Docs](https://v6.mantine.dev/core/tabs/) + +## Examples + +<Canvas> + <Story of={TabsStories.Default} /> +</Canvas> + +<Canvas> + <Story of={TabsStories.Icons} /> +</Canvas> + +### Vertical orientation + +<Canvas> + <Story of={TabsStories.VerticalOrientation} /> +</Canvas> + +<Canvas> + <Story of={TabsStories.VerticalOrientationIcons} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.stories.mdx b/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.stories.mdx deleted file mode 100644 index 6f247a10efc7fac5855018493982414ea7aefede..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.stories.mdx +++ /dev/null @@ -1,108 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Icon } from "metabase/ui"; -import { Group, Tabs } from "metabase/ui"; - -export const args = { - orientation: "horizontal", -}; - -export const argTypes = { - orientation: { - options: ["horizontal", "vertical"], - control: { type: "inline-radio" }, - }, -}; - -export const tabs = [ - { value: "overview", label: "Overview", icon: "home" }, - { value: "metrics", label: "Metrics", icon: "metric" }, - { value: "segments", label: "Segments", icon: "segment" }, - { value: "actions", label: "Actions", icon: "bolt", disabled: true }, - { value: "filters", label: "Filters", icon: "filter" }, -]; - -<Meta - title="Navigation/Tabs" - component={Tabs} - args={args} - argTypes={argTypes} -/> - -# Tabs - -Our themed wrapper around [Mantine Tabs](https://v6.mantine.dev/core/tabs/). - -## Docs - -- [Figma File](https://www.figma.com/file/uPYsD4ncNpQPFzxsLnTnGd/Navigation-%2F-Tabs?type=design&node-id=1-96&mode=design&t=dtMJ59HbOKZ8TOgJ-0) -- [Mantine Tabs Docs](https://v6.mantine.dev/core/tabs/) - -## Examples - -export const DefaultTemplate = args => ( - <Tabs {...args}> - <Tabs.List> - {tabs.map(tab => ( - <Tabs.Tab key={tab.value} value={tab.value} disabled={tab.disabled}> - {tab.label} - </Tabs.Tab> - ))} - </Tabs.List> - {tabs.map(tab => ( - <Tabs.Panel key={tab.value} value={tab.value} /> - ))} - </Tabs> -); - -export const IconsTemplate = args => ( - <Tabs {...args}> - <Tabs.List> - {tabs.map(tab => ( - <Tabs.Tab - key={tab.value} - value={tab.value} - disabled={tab.disabled} - icon={<Icon name={tab.icon} />} - > - {tab.label} - </Tabs.Tab> - ))} - </Tabs.List> - {tabs.map(tab => ( - <Tabs.Panel key={tab.value} value={tab.value} /> - ))} - </Tabs> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -export const Icons = IconsTemplate.bind({}); - -<Canvas> - <Story name="Icons">{Icons}</Story> -</Canvas> - -### Vertical orientation - -export const Vertical = DefaultTemplate.bind({}); -Vertical.args = { - orientation: "vertical", -}; - -<Canvas> - <Story name="Vertical orientation">{Vertical}</Story> -</Canvas> - -export const VerticalIcons = IconsTemplate.bind({}); -VerticalIcons.args = { - orientation: "vertical", -}; - -<Canvas> - <Story name="Vertical orientation, icons">{VerticalIcons}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.stories.tsx b/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c988c015ae870a2810424ebed98ca97f930d7c9a --- /dev/null +++ b/frontend/src/metabase/ui/components/navigation/Tabs/Tabs.stories.tsx @@ -0,0 +1,92 @@ +import { Icon, Tabs, type TabsProps } from "metabase/ui"; + +const args = { + orientation: "horizontal", +}; + +const argTypes = { + orientation: { + options: ["horizontal", "vertical"], + control: { type: "inline-radio" }, + }, +}; + +const tabs = [ + { value: "overview", label: "Overview", icon: "home" }, + { value: "metrics", label: "Metrics", icon: "metric" }, + { value: "segments", label: "Segments", icon: "segment" }, + { value: "actions", label: "Actions", icon: "bolt", disabled: true }, + { value: "filters", label: "Filters", icon: "filter" }, +]; + +const DefaultTemplate = (args: TabsProps) => ( + <Tabs {...args}> + <Tabs.List> + {tabs.map(tab => ( + <Tabs.Tab key={tab.value} value={tab.value} disabled={tab.disabled}> + {tab.label} + </Tabs.Tab> + ))} + </Tabs.List> + {tabs.map(tab => ( + <Tabs.Panel key={tab.value} value={tab.value}> + {tab.label} + </Tabs.Panel> + ))} + </Tabs> +); + +const IconsTemplate = (args: TabsProps) => ( + <Tabs {...args}> + <Tabs.List> + {tabs.map(tab => ( + <Tabs.Tab + key={tab.value} + value={tab.value} + disabled={tab.disabled} + icon={<Icon name={tab.icon as keyof typeof Icon} />} + > + {tab.label} + </Tabs.Tab> + ))} + </Tabs.List> + {tabs.map(tab => ( + <Tabs.Panel key={tab.value} value={tab.value}> + {tab.label} + </Tabs.Panel> + ))} + </Tabs> +); + +export default { + title: "Navigation/Tabs", + component: Tabs, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const Icons = { + render: IconsTemplate, + name: "Icons", +}; + +export const VerticalOrientation = { + render: DefaultTemplate, + name: "Vertical orientation", + args: { + orientation: "vertical", + }, +}; + +export const VerticalOrientationIcons = { + render: IconsTemplate, + name: "Vertical orientation, icons", + args: { + orientation: "vertical", + }, +}; diff --git a/frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.mdx b/frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.mdx new file mode 100644 index 0000000000000000000000000000000000000000..4290be6d3ccc6b6db535c44f82c750fb82f383ad --- /dev/null +++ b/frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.mdx @@ -0,0 +1,29 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { + Box, + Button, + Flex, + HoverCard, + Text, + TextInput, + Stack, +} from "metabase/ui"; +import * as HoverCardStories from "./HoverCard.stories"; + +<Meta of={HoverCardStories} /> + +# HoverCard + +Our themed wrapper around [Mantine HoverCard](https://v6.mantine.dev/core/hover-card/). + +## Docs + +- [Mantine HoverCard Docs](https://v6.mantine.dev/core/hover-card/) + +## Examples + +<Canvas> + <Story of={HoverCardStories.Default} /> + <Story of={HoverCardStories.InteractiveContent} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.stories.mdx b/frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.stories.tsx similarity index 52% rename from frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.stories.mdx rename to frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.stories.tsx index 4623e0bd5b81df0e9a29a1a1d8e72f154c94fdcb..1f946ccc12eefb4c3ba32bccd42f84a62f37b00e 100644 --- a/frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.stories.mdx +++ b/frontend/src/metabase/ui/components/overlays/HoverCard/HoverCard.stories.tsx @@ -1,21 +1,20 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; import { Box, Button, Flex, HoverCard, + type HoverCardProps, + Stack, Text, TextInput, - Stack, } from "metabase/ui"; -export const args = { +const args = { label: "HoverCard", position: "bottom", }; -export const argTypes = { +const argTypes = { position: { options: [ "bottom", @@ -35,7 +34,7 @@ export const argTypes = { }, }; -export const sampleArgs = { +const sampleArgs = { simple: <Text>Hover!</Text>, interactive: ( <Stack spacing="sm"> @@ -46,24 +45,10 @@ export const sampleArgs = { ), }; -<Meta - title="Overlays/HoverCard" - component={HoverCard} - args={args} - argTypes={argTypes} -/> - -# HoverCard - -Our themed wrapper around [Mantine HoverCard](https://v6.mantine.dev/core/hover-card/). - -## Docs - -- [Mantine HoverCard Docs](https://v6.mantine.dev/core/hover-card/) - -## Examples - -export const DefaultTemplate = ({ children, ...args }) => ( +const DefaultTemplate = ({ + children, + ...args +}: { children: React.ReactNode } & HoverCardProps) => ( <Flex justify="center"> <HoverCard {...args}> <HoverCard.Target> @@ -76,17 +61,25 @@ export const DefaultTemplate = ({ children, ...args }) => ( </Flex> ); -export const Default = DefaultTemplate.bind({}); -Default.args = { - children: sampleArgs.simple, +export default { + title: "Overlays/HoverCard", + component: HoverCard, + args, + argTypes, }; -export const Interactive = DefaultTemplate.bind({}); -Interactive.args = { - children: sampleArgs.interactive, +export const Default = { + render: DefaultTemplate, + name: "Default", + args: { + children: sampleArgs.simple, + }, }; -<Canvas> - <Story name="Default">{Default}</Story> - <Story name="Interactive content">{Interactive}</Story> -</Canvas> +export const InteractiveContent = { + render: DefaultTemplate, + name: "Interactive content", + args: { + children: sampleArgs.interactive, + }, +}; diff --git a/frontend/src/metabase/ui/components/overlays/Menu/Menu.mdx b/frontend/src/metabase/ui/components/overlays/Menu/Menu.mdx new file mode 100644 index 0000000000000000000000000000000000000000..725a52e0e16b4e11ce7e610bd8355430edb93278 --- /dev/null +++ b/frontend/src/metabase/ui/components/overlays/Menu/Menu.mdx @@ -0,0 +1,67 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Icon } from "metabase/ui"; +import { Button, Flex, Text } from "metabase/ui"; +import { Menu } from "./"; +import * as MenuStories from "./Menu.stories"; + +<Meta of={MenuStories} /> + +# Menu + +Our themed wrapper around [Mantine Menu](https://v6.mantine.dev/core/menu/). + +## When to use Menu + +Use this menu in the following cases: + +- To display a list (up to 10) of static actions +- Use it as the primary menu without additional flyout popups +- Examples: + New, Settings, More on Metabase’s main UI + +Not to use: + +- As filter popup, selector or multi-selector +- GUI editor dropdowns +- Dynamic and composite menu items, e.g., group by month +- Dynamic dropdown for table column picker +- Any other advanced popups such as column popup on chill mode + +## Docs + +- [Figma File](https://www.figma.com/file/MZhwfwmOaa8HeCBBUCeq7R/Menu?type=design&node-id=1-96&mode=design&t=vj3dPYMbYVYVuKBy-0) +- [Mantine Menu Docs](https://v6.mantine.dev/core/menu/) + +## Caveats + +- The position of menu is auto-programmed responsively according to the location of the trigger button on the page +- Limit the menu items to 10 or fewer + +## Usage guidelines + +- Although menu item label can be as long as you wish, keep it simple and concise +- In general, use sentence casing for menu item labels +- Use icon + label when applicable for better visual affordance + +## Examples + +<Canvas> + <Story of={MenuStories.Default} /> +</Canvas> + +### Right section + +<Canvas> + <Story of={MenuStories.RightSection} /> +</Canvas> + +### Icons + +<Canvas> + <Story of={MenuStories.Icons} /> +</Canvas> + +### Labels and dividers + +<Canvas> + <Story of={MenuStories.LabelsAndDividers} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/overlays/Menu/Menu.stories.mdx b/frontend/src/metabase/ui/components/overlays/Menu/Menu.stories.tsx similarity index 60% rename from frontend/src/metabase/ui/components/overlays/Menu/Menu.stories.mdx rename to frontend/src/metabase/ui/components/overlays/Menu/Menu.stories.tsx index 56d88f0badabd831558d5eed81e194d9428f5307..8c316f249c974ee05f653ad22ce480c9edd17580 100644 --- a/frontend/src/metabase/ui/components/overlays/Menu/Menu.stories.mdx +++ b/frontend/src/metabase/ui/components/overlays/Menu/Menu.stories.tsx @@ -1,9 +1,6 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Icon } from "metabase/ui"; -import { Button, Flex, Text } from "metabase/ui"; -import { Menu } from "./"; +import { Button, Flex, Icon, Menu, type MenuProps, Text } from "metabase/ui"; -export const args = { +const args = { trigger: "click", position: "bottom", disabled: false, @@ -12,7 +9,7 @@ export const args = { closeOnItemClick: true, }; -export const argTypes = { +const argTypes = { trigger: { options: ["click", "hover"], control: { type: "inline-radio" }, @@ -57,47 +54,7 @@ export const argTypes = { }, }; -<Meta title="Overlays/Menu" component={Menu} args={args} argTypes={argTypes} /> - -# Menu - -Our themed wrapper around [Mantine Menu](https://v6.mantine.dev/core/menu/). - -## When to use Menu - -Use this menu in the following cases: - -- To display a list (up to 10) of static actions -- Use it as the primary menu without additional flyout popups -- Examples: + New, Settings, More on Metabase’s main UI - -Not to use: - -- As filter popup, selector or multi-selector -- GUI editor dropdowns -- Dynamic and composite menu items, e.g., group by month -- Dynamic dropdown for table column picker -- Any other advanced popups such as column popup on chill mode - -## Docs - -- [Figma File](https://www.figma.com/file/MZhwfwmOaa8HeCBBUCeq7R/Menu?type=design&node-id=1-96&mode=design&t=vj3dPYMbYVYVuKBy-0) -- [Mantine Menu Docs](https://v6.mantine.dev/core/menu/) - -## Caveats - -- The position of menu is auto-programmed responsively according to the location of the trigger button on the page -- Limit the menu items to 10 or fewer - -## Usage guidelines - -- Although menu item label can be as long as you wish, keep it simple and concise -- In general, use sentence casing for menu item labels -- Use icon + label when applicable for better visual affordance - -## Examples - -export const DefaultTemplate = args => ( +const DefaultTemplate = (args: MenuProps) => ( <Flex justify="center"> <Menu {...args}> <Menu.Target> @@ -115,7 +72,7 @@ export const DefaultTemplate = args => ( </Flex> ); -export const RightSectionTemplate = args => ( +const RightSectionTemplate = (args: MenuProps) => ( <Flex justify="center"> <Menu {...args}> <Menu.Target> @@ -135,7 +92,7 @@ export const RightSectionTemplate = args => ( </Flex> ); -export const IconsTemplate = args => ( +const IconsTemplate = (args: MenuProps) => ( <Flex justify="center"> <Menu {...args}> <Menu.Target> @@ -153,7 +110,7 @@ export const IconsTemplate = args => ( </Flex> ); -export const LabelsAndDividersTemplate = args => ( +const LabelsAndDividersTemplate = (args: MenuProps) => ( <Flex justify="center"> <Menu {...args}> <Menu.Target> @@ -179,32 +136,29 @@ export const LabelsAndDividersTemplate = args => ( </Flex> ); -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Right section - -export const RightSection = RightSectionTemplate.bind({}); - -<Canvas> - <Story name="Right section">{RightSection}</Story> -</Canvas> - -### Icons - -export const Icons = IconsTemplate.bind({}); +export default { + title: "Overlays/Menu", + component: Menu, + args, + argTypes, +}; -<Canvas> - <Story name="Icons">{Icons}</Story> -</Canvas> +export const Default = { + render: DefaultTemplate, + name: "Default", +}; -### Labels and dividers +export const RightSection = { + render: RightSectionTemplate, + name: "Right section", +}; -export const LabelsAndDividers = LabelsAndDividersTemplate.bind({}); +export const Icons = { + render: IconsTemplate, + name: "Icons", +}; -<Canvas> - <Story name="Labels and dividers">{LabelsAndDividers}</Story> -</Canvas> +export const LabelsAndDividers = { + render: LabelsAndDividersTemplate, + name: "Labels and dividers", +}; diff --git a/frontend/src/metabase/ui/components/overlays/Modal/Modal.mdx b/frontend/src/metabase/ui/components/overlays/Modal/Modal.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b24b4fdc131a19603647e7a3ac407e715037ad3b --- /dev/null +++ b/frontend/src/metabase/ui/components/overlays/Modal/Modal.mdx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Button, Flex, TextInput } from "metabase/ui"; +import { Modal } from "./"; +import * as ModalStories from "./Modal.stories"; + +<Meta of={ModalStories} /> + +# Modal + +Our themed wrapper around [Mantine Modal](https://v6.mantine.dev/core/modal/). + +## Docs + +- [Mantine Modal Docs](https://v6.mantine.dev/core/modal/) + +## Guidelines + +### Modal headings should be in sentence case. + +Sentence-case fits the friendly, casual personality of Metabase. This means it should be `Add to dashboard` instead of `Add To Dashboard` for example. + +<Canvas> + <Story of={ModalStories.SentenceCaseTitles} /> +</Canvas> + +### Headings for confirmation modals should always be a question, and the body text should elaborate on the consequences + +E.g.,`Delete this database?` with body copy that contains clarifying information about what will happen if this action is taken, e.g., `This can't be undone, and questions that rely on this data will no longer work.` + +<Canvas> + <Story of={ModalStories.Confirmation} /> +</Canvas> + +### Buttons should be right-aligned, with the primary action on the far right + +This is primarily to reinforce the top-left-to-bottom-right eye travel path for left-to-right language readers. + +(Research from [NN Group says it really doesn't matter that much](https://www.nngroup.com/articles/ok-cancel-or-cancel-ok/) which side the buttons align to, so this is mostly for consistency and convention.) + +### The primary button for create or neutral actions should be blue, and red for destructive actions + +- Examples of “create†and neutral actions: + + - Save + - Create + - Invite + - Download + - Okay + - Done + +- Examples of “destructive†actions + - Discard + - Delete + - Deactivate + - Revoke + - Remove + +### The text for primary action buttons should always be a verb + +If the title of a modal is e.g., "Add this to a dashboard," the primary action should be `Add` instead of "Yes" for example. + +Or if we ask, "Reset this password?" the primary button should say `Reset`. This provides good additional confirmation to the user about what's going to happen if they click the button. Additionally, though we could write, "Yes, reset," that's unnecessarily verbose. + +### The secondary action should always say `Cancel` with rare exceptions + +This is for the sake of predictability, consistency, and convention. + +There are some rare exceptions, like when the modal is a confirmation or a followup from a previous modal. As an example, when you save a question, we ask you in a modal if you want to add it to a dashboard. In this case, saying "cancel" is ambiguous (does this cancel saving the card?), and it makes sense for it to read `Not now` + +### There should almost never be more than two buttons in a modal + +There are few legitimate cases where a modal should have more than two buttons. E.g., if there were a third action that says "learn more", clicking it might open a new window, but it leaves the modal in an ambiguous state. + +It’s okay for a modal to have only a single button. Here’s an example: + +<Canvas> + <Story of={ModalStories.SingleButton} /> +</Canvas> + +### It’s okay if a modal doesn’t have body text + +Sometimes it’s not necessary. Here's a modal with a heading, but no body text. + +<Canvas> + <Story of={ModalStories.NoBodyText} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/overlays/Modal/Modal.stories.mdx b/frontend/src/metabase/ui/components/overlays/Modal/Modal.stories.mdx deleted file mode 100644 index e77b76afaa706998ffb1462cf5f492a55585dc58..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/overlays/Modal/Modal.stories.mdx +++ /dev/null @@ -1,224 +0,0 @@ -import { useState } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Button, Flex, TextInput } from "metabase/ui"; -import { Modal } from "./"; - -export const args = { - centered: true, - fullScreen: false, - size: "md", -}; - -export const argTypes = { - centered: { - control: { type: "boolean" }, - }, - fullScreen: { - control: { type: "boolean" }, - }, - title: { - control: { type: "text" }, - }, - size: { - control: { - type: "select", - options: ["xs", "sm", "md", "lg", "xl", "auto"], - }, - }, -}; - -<Meta - title="Overlays/Modal" - component={Modal} - args={args} - argTypes={argTypes} -/> - -# Modal - -Our themed wrapper around [Mantine Modal](https://v6.mantine.dev/core/modal/). - -## Docs - -- [Mantine Modal Docs](https://v6.mantine.dev/core/modal/) - -## Guidelines - -### Modal headings should be in sentence case. - -Sentence-case fits the friendly, casual personality of Metabase. This means it should be `Add to dashboard` instead of `Add To Dashboard` for example. - -export const SimpleWithTitle = args => { - const [isOpen, setOpen] = useState(false); - const handleOpen = () => setOpen(true); - const handleClose = () => setOpen(false); - return ( - <Flex justify="center"> - <Button variant="filled" onClick={handleOpen}> - Open example - </Button> - <Modal - title="Add to dashboard?" - {...args} - opened={isOpen} - onClose={handleClose} - > - <Flex direction="row" justify="flex-end" mt="md"> - <Button type="submit" variant="filled" ml="sm"> - Add - </Button> - </Flex> - </Modal> - </Flex> - ); -}; - -<Canvas> - <Story name="Sentence case titles">{SimpleWithTitle}</Story> -</Canvas> - -### Headings for confirmation modals should always be a question, and the body text should elaborate on the consequences - -E.g.,`Delete this database?` with body copy that contains clarifying information about what will happen if this action is taken, e.g., `This can't be undone, and questions that rely on this data will no longer work.` - -export const Confirmation = args => { - const [isOpen, setOpen] = useState(false); - const handleOpen = () => setOpen(true); - const handleClose = () => setOpen(false); - return ( - <Flex justify="center"> - <Button variant="filled" onClick={handleOpen}> - Open example - </Button> - <Modal - title="Delete this database?" - {...args} - opened={isOpen} - onClose={handleClose} - > - <Text> - This can't be undone, and questions that rely on this data will no - longer work. - </Text> - <Flex direction="row" justify="flex-end" mt="md"> - <Button type="submit" ml="sm"> - Cancel - </Button> - <Button type="submit" variant="filled" color="error" ml="sm"> - Delete - </Button> - </Flex> - </Modal> - </Flex> - ); -}; - -<Canvas> - <Story name="Confirmation">{Confirmation}</Story> -</Canvas> - -### Buttons should be right-aligned, with the primary action on the far right - -This is primarily to reinforce the top-left-to-bottom-right eye travel path for left-to-right language readers. - -(Research from [NN Group says it really doesn't matter that much](https://www.nngroup.com/articles/ok-cancel-or-cancel-ok/) which side the buttons align to, so this is mostly for consistency and convention.) - -### The primary button for create or neutral actions should be blue, and red for destructive actions - -- Examples of “create†and neutral actions: - - - Save - - Create - - Invite - - Download - - Okay - - Done - -- Examples of “destructive†actions - - Discard - - Delete - - Deactivate - - Revoke - - Remove - -### The text for primary action buttons should always be a verb - -If the title of a modal is e.g., "Add this to a dashboard," the primary action should be `Add` instead of "Yes" for example. - -Or if we ask, "Reset this password?" the primary button should say `Reset`. This provides good additional confirmation to the user about what's going to happen if they click the button. Additionally, though we could write, "Yes, reset," that's unnecessarily verbose. - -### The secondary action should always say `Cancel` with rare exceptions - -This is for the sake of predictability, consistency, and convention. - -There are some rare exceptions, like when the modal is a confirmation or a followup from a previous modal. As an example, when you save a question, we ask you in a modal if you want to add it to a dashboard. In this case, saying "cancel" is ambiguous (does this cancel saving the card?), and it makes sense for it to read `Not now` - -### There should almost never be more than two buttons in a modal - -There are few legitimate cases where a modal should have more than two buttons. E.g., if there were a third action that says "learn more", clicking it might open a new window, but it leaves the modal in an ambiguous state. - -It’s okay for a modal to have only a single button. Here’s an example: - -export const SingleButton = args => { - const [isOpen, setOpen] = useState(false); - const handleOpen = () => setOpen(true); - const handleClose = () => setOpen(false); - return ( - <Flex justify="center"> - <Button variant="filled" onClick={handleOpen}> - Open example - </Button> - <Modal - title="Single button example" - {...args} - opened={isOpen} - onClose={handleClose} - > - <Text>Sometimes all you need is one option.</Text> - <Flex direction="row" justify="flex-end" mt="md"> - <Button type="submit" variant="filled" ml="sm"> - Add - </Button> - </Flex> - </Modal> - </Flex> - ); -}; - -<Canvas> - <Story name="Single button">{SingleButton}</Story> -</Canvas> - -### It’s okay if a modal doesn’t have body text - -Sometimes it’s not necessary. Here's a modal with a heading, but no body text. - -export const NoBodyText = args => { - const [isOpen, setOpen] = useState(false); - const handleOpen = () => setOpen(true); - const handleClose = () => setOpen(false); - return ( - <Flex justify="center"> - <Button variant="filled" onClick={handleOpen}> - Open example - </Button> - <Modal - title="Saved! Add this to a dashboard?" - {...args} - opened={isOpen} - onClose={handleClose} - > - <Flex direction="row" justify="flex-end" mt="md"> - <Button onClick={handleClose}>Not now</Button> - <Button type="submit" variant="filled" ml="sm"> - Add - </Button> - </Flex> - </Modal> - </Flex> - ); -}; - -<Canvas> - <Story name="No body text">{NoBodyText}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/overlays/Modal/Modal.stories.tsx b/frontend/src/metabase/ui/components/overlays/Modal/Modal.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b4e4239cb214f63d1c6d205bcff8cf15466539c --- /dev/null +++ b/frontend/src/metabase/ui/components/overlays/Modal/Modal.stories.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; + +import { Button, Flex, Text } from "metabase/ui"; + +import { Modal, type ModalProps } from "./"; + +const args = { + centered: true, + fullScreen: false, + size: "md", +}; + +const argTypes = { + centered: { + control: { type: "boolean" }, + }, + fullScreen: { + control: { type: "boolean" }, + }, + title: { + control: { type: "text" }, + }, + size: { + control: { + type: "select", + options: ["xs", "sm", "md", "lg", "xl", "auto"], + }, + }, +}; + +const SimpleWithTitleTemplate = (args: ModalProps) => { + const [isOpen, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + return ( + <Flex justify="center"> + <Button variant="filled" onClick={handleOpen}> + Open example + </Button> + <Modal + title="Add to dashboard?" + {...args} + opened={isOpen} + onClose={handleClose} + > + <Flex direction="row" justify="flex-end" mt="md"> + <Button type="submit" variant="filled" ml="sm"> + Add + </Button> + </Flex> + </Modal> + </Flex> + ); +}; + +const ConfirmationTemplate = (args: ModalProps) => { + const [isOpen, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + return ( + <Flex justify="center"> + <Button variant="filled" onClick={handleOpen}> + Open example + </Button> + <Modal + title="Delete this database?" + {...args} + opened={isOpen} + onClose={handleClose} + > + <Text> + This cannot be undone, and questions that rely on this data will no + longer work. + </Text> + <Flex direction="row" justify="flex-end" mt="md"> + <Button type="submit" ml="sm"> + Cancel + </Button> + <Button type="submit" variant="filled" color="error" ml="sm"> + Delete + </Button> + </Flex> + </Modal> + </Flex> + ); +}; + +const SingleButtonTemplate = (args: ModalProps) => { + const [isOpen, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + return ( + <Flex justify="center"> + <Button variant="filled" onClick={handleOpen}> + Open example + </Button> + <Modal + title="Single button example" + {...args} + opened={isOpen} + onClose={handleClose} + > + <Text>Sometimes all you need is one option.</Text> + <Flex direction="row" justify="flex-end" mt="md"> + <Button type="submit" variant="filled" ml="sm"> + Add + </Button> + </Flex> + </Modal> + </Flex> + ); +}; + +const NoBodyTextTemplate = (args: ModalProps) => { + const [isOpen, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + return ( + <Flex justify="center"> + <Button variant="filled" onClick={handleOpen}> + Open example + </Button> + <Modal + title="Saved! Add this to a dashboard?" + {...args} + opened={isOpen} + onClose={handleClose} + > + <Flex direction="row" justify="flex-end" mt="md"> + <Button onClick={handleClose}>Not now</Button> + <Button type="submit" variant="filled" ml="sm"> + Add + </Button> + </Flex> + </Modal> + </Flex> + ); +}; + +export default { + title: "Overlays/Modal", + component: Modal, + args, + argTypes, +}; + +export const SentenceCaseTitles = { + render: SimpleWithTitleTemplate, + name: "Sentence case titles", +}; + +export const Confirmation = { + render: ConfirmationTemplate, + name: "Confirmation", +}; + +export const SingleButton = { + render: SingleButtonTemplate, + name: "Single button", +}; + +export const NoBodyText = { + render: NoBodyTextTemplate, + name: "No body text", +}; diff --git a/frontend/src/metabase/ui/components/overlays/Popover/Popover.mdx b/frontend/src/metabase/ui/components/overlays/Popover/Popover.mdx new file mode 100644 index 0000000000000000000000000000000000000000..b2131a31e671e8ff562b88a1b06d179a71f2dded --- /dev/null +++ b/frontend/src/metabase/ui/components/overlays/Popover/Popover.mdx @@ -0,0 +1,29 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { + Box, + Button, + Flex, + Popover, + Text, + TextInput, + Stack, +} from "metabase/ui"; +import * as PopoverStories from "./Popover.stories"; + +<Meta of={PopoverStories} /> + +# Popover + +Our themed wrapper around [Mantine Popover](https://v6.mantine.dev/core/popover/). + +## Docs + +- [Mantine Popover Docs](https://v6.mantine.dev/core/popover/) + +## Examples + +<Canvas> + <Story of={PopoverStories.Default} /> + <Story of={PopoverStories.InteractiveContent} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/overlays/Popover/Popover.stories.mdx b/frontend/src/metabase/ui/components/overlays/Popover/Popover.stories.tsx similarity index 53% rename from frontend/src/metabase/ui/components/overlays/Popover/Popover.stories.mdx rename to frontend/src/metabase/ui/components/overlays/Popover/Popover.stories.tsx index 38717cc9cb394ab2c2ba74050352acbbddaad042..7dbeaf1fe6f93e9adc7c6cab1bcb9eae10ddeb94 100644 --- a/frontend/src/metabase/ui/components/overlays/Popover/Popover.stories.mdx +++ b/frontend/src/metabase/ui/components/overlays/Popover/Popover.stories.tsx @@ -1,21 +1,20 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; import { Box, Button, Flex, Popover, + type PopoverProps, + Stack, Text, TextInput, - Stack, } from "metabase/ui"; -export const args = { +const args = { label: "Popover", position: "bottom", }; -export const argTypes = { +const argTypes = { position: { options: [ "bottom", @@ -35,7 +34,7 @@ export const argTypes = { }, }; -export const sampleArgs = { +const sampleArgs = { simple: <Text>Popover!</Text>, interactive: ( <Stack spacing="sm"> @@ -46,24 +45,10 @@ export const sampleArgs = { ), }; -<Meta - title="Overlays/Popover" - component={Popover} - args={args} - argTypes={argTypes} -/> - -# Popover - -Our themed wrapper around [Mantine Popover](https://v6.mantine.dev/core/popover/). - -## Docs - -- [Mantine Popover Docs](https://v6.mantine.dev/core/popover/) - -## Examples - -export const DefaultTemplate = ({ children, ...args }) => ( +const DefaultTemplate = ({ + children, + ...args +}: { children: React.ReactNode } & PopoverProps) => ( <Flex justify="center"> <Popover {...args}> <Popover.Target> @@ -76,17 +61,25 @@ export const DefaultTemplate = ({ children, ...args }) => ( </Flex> ); -export const Default = DefaultTemplate.bind({}); -Default.args = { - children: sampleArgs.simple, +export default { + title: "Overlays/Popover", + component: Popover, + args, + argTypes, }; -export const Interactive = DefaultTemplate.bind({}); -Interactive.args = { - children: sampleArgs.interactive, +export const Default = { + render: DefaultTemplate, + name: "Default", + args: { + children: sampleArgs.simple, + }, }; -<Canvas> - <Story name="Default">{Default}</Story> - <Story name="Interactive content">{Interactive}</Story> -</Canvas> +export const InteractiveContent = { + render: DefaultTemplate, + name: "Interactive content", + args: { + children: sampleArgs.interactive, + }, +}; diff --git a/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.mdx b/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.mdx new file mode 100644 index 0000000000000000000000000000000000000000..98b709e97d44180db45e31999d58c2f5180cbe56 --- /dev/null +++ b/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.mdx @@ -0,0 +1,20 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Button, Flex, Grid, Tooltip } from "metabase/ui"; +import * as TooltipStories from "./Tooltip.stories"; + +<Meta of={TooltipStories} /> + +# Tooltip + +Our themed wrapper around [Mantine Tooltip](https://v6.mantine.dev/core/tooltip/). + +## Docs + +- [Mantine Tooltip Docs](https://v6.mantine.dev/core/tooltip/) + +## Examples + +<Canvas> + <Story of={TooltipStories.Default} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.stories.mdx b/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.stories.mdx deleted file mode 100644 index d34f758019be35e661b5fa84489feb2c24af8ef3..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.stories.mdx +++ /dev/null @@ -1,62 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Button, Flex, Grid, Tooltip } from "metabase/ui"; - -export const args = { - label: "Tooltip", - position: "bottom", -}; - -export const argTypes = { - label: { - control: { type: "text" }, - }, - position: { - options: [ - "bottom", - "left", - "right", - "top", - "bottom-end", - "bottom-start", - "left-end", - "left-start", - "right-end", - "right-start", - "top-end", - "top-start", - ], - control: { type: "select" }, - }, -}; - -<Meta - title="Overlays/Tooltip" - component={Tooltip} - args={args} - argTypes={argTypes} -/> - -# Tooltip - -Our themed wrapper around [Mantine Tooltip](https://v6.mantine.dev/core/tooltip/). - -## Docs - -- [Mantine Tooltip Docs](https://v6.mantine.dev/core/tooltip/) - -## Examples - -export const DefaultTemplate = args => ( - <Flex justify="center"> - <Tooltip {...args}> - <Button variant="filled">Toggle tooltip</Button> - </Tooltip> - </Flex> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.stories.tsx b/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..faa8caa5fbc292f7b0d1f2b648ed4939775939f0 --- /dev/null +++ b/frontend/src/metabase/ui/components/overlays/Tooltip/Tooltip.stories.tsx @@ -0,0 +1,49 @@ +import { Button, Flex, Tooltip, type TooltipProps } from "metabase/ui"; + +const args = { + label: "Tooltip", + position: "bottom", +}; + +const argTypes = { + label: { + control: { type: "text" }, + }, + position: { + options: [ + "bottom", + "left", + "right", + "top", + "bottom-end", + "bottom-start", + "left-end", + "left-start", + "right-end", + "right-start", + "top-end", + "top-start", + ], + control: { type: "select" }, + }, +}; + +const DefaultTemplate = (args: TooltipProps) => ( + <Flex justify="center"> + <Tooltip {...args}> + <Button variant="filled">Toggle tooltip</Button> + </Tooltip> + </Flex> +); + +export default { + title: "Overlays/Tooltip", + component: Tooltip, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; diff --git a/frontend/src/metabase/ui/components/typography/Anchor/Anchor.mdx b/frontend/src/metabase/ui/components/typography/Anchor/Anchor.mdx new file mode 100644 index 0000000000000000000000000000000000000000..93519ad2460d7febea734aef066d8ac0dafef2a3 --- /dev/null +++ b/frontend/src/metabase/ui/components/typography/Anchor/Anchor.mdx @@ -0,0 +1,36 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Anchor, Grid, Text } from "metabase/ui"; +import * as AnchorStories from "./Anchor.stories"; + +<Meta of={AnchorStories} /> + +# Anchor + +Our themed wrapper around [Mantine Anchor](https://v6.mantine.dev/core/anchor/). + +## When to use Anchor + +The Anchor component allows users to display links with themed styles, and replaces the usage of `<a href="<url>">text</a>`. This component uses the same props as the `Text` component, so it can handle sizing, line clamps, text decoration, and font weight. For regular text, use the `Text` component, and for code, use the `Code` component. + +## Docs + +- [Figma File](https://www.figma.com/file/8nuIBDQGSGKLfAPsebbASA/Navigation-%2F-Anchor?type=design&node-id=1-96&mode=design&t=2eUYOsqZZeMc4OGT-0) +- [Mantine Anchor Docs](https://v6.mantine.dev/core/anchor/) + +## Examples + +<Canvas> + <Story of={AnchorStories.Default} /> +</Canvas> + +### Sizes + +<Canvas> + <Story of={AnchorStories.Sizes} /> +</Canvas> + +## Related components + +- Anchor +- Code diff --git a/frontend/src/metabase/ui/components/typography/Anchor/Anchor.stories.mdx b/frontend/src/metabase/ui/components/typography/Anchor/Anchor.stories.mdx deleted file mode 100644 index bf449f57a678cbb1c86e61885ba78b79ea76223a..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/typography/Anchor/Anchor.stories.mdx +++ /dev/null @@ -1,92 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Anchor, Grid, Text } from "metabase/ui"; - -export const args = { - size: "md", - align: "unset", - truncate: false, -}; - -export const sampleArgs = { - text: "Weniger", - href: "https://example.test", -}; - -export const argTypes = { - size: { - options: ["xs", "sm", "md", "lg"], - control: { type: "inline-radio" }, - }, - align: { - options: ["left", "center", "right"], - control: { type: "inline-radio" }, - }, - truncate: { - control: { type: "boolean" }, - }, -}; - -<Meta - title="Typography/Anchor" - component={Anchor} - args={args} - argTypes={argTypes} -/> - -# Anchor - -Our themed wrapper around [Mantine Anchor](https://v6.mantine.dev/core/anchor/). - -## When to use Anchor - -The Anchor component allows users to display links with themed styles, and replaces the usage of `<a href="<url>">text</a>`. This component uses the same props as the `Text` component, so it can handle sizing, line clamps, text decoration, and font weight. For regular text, use the `Text` component, and for code, use the `Code` component. - -## Docs - -- [Figma File](https://www.figma.com/file/8nuIBDQGSGKLfAPsebbASA/Navigation-%2F-Anchor?type=design&node-id=1-96&mode=design&t=2eUYOsqZZeMc4OGT-0) -- [Mantine Anchor Docs](https://v6.mantine.dev/core/anchor/) - -## Examples - -export const DefaultTemplate = args => ( - <Anchor {...args} href={sampleArgs.href}> - {sampleArgs.text} - </Anchor> -); - -export const SizeTemplate = args => ( - <Grid align="center" maw="18rem"> - {argTypes.size.options.map(size => ( - <Fragment key={size}> - <Grid.Col span={2}> - <Text weight="bold">{size}</Text> - </Grid.Col> - <Grid.Col span={10}> - <Anchor {...args} size={size} href={sampleArgs.href}> - {sampleArgs.text} - </Anchor> - </Grid.Col> - </Fragment> - ))} - </Grid> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Sizes - -export const Sizes = SizeTemplate.bind({}); - -<Canvas> - <Story name="Sizes">{Sizes}</Story> -</Canvas> - -## Related components - -- Anchor -- Code diff --git a/frontend/src/metabase/ui/components/typography/Anchor/Anchor.stories.tsx b/frontend/src/metabase/ui/components/typography/Anchor/Anchor.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6d62a6c0f98a5cbf3b45ca17eb15c053d4ba75d --- /dev/null +++ b/frontend/src/metabase/ui/components/typography/Anchor/Anchor.stories.tsx @@ -0,0 +1,68 @@ +import { Fragment } from "react"; + +import { Anchor, type AnchorProps, Grid, Text } from "metabase/ui"; + +const args = { + size: "md", + align: "unset", + truncate: false, +}; + +const sampleArgs = { + text: "Weniger", + href: "https://example.test", +}; + +const argTypes = { + size: { + options: ["xs", "sm", "md", "lg"], + control: { type: "inline-radio" }, + }, + align: { + options: ["left", "center", "right"], + control: { type: "inline-radio" }, + }, + truncate: { + control: { type: "boolean" }, + }, +}; + +const DefaultTemplate = (args: AnchorProps) => ( + <Anchor {...args} href={sampleArgs.href}> + {sampleArgs.text} + </Anchor> +); + +const SizeTemplate = (args: AnchorProps) => ( + <Grid align="center" maw="18rem"> + {argTypes.size.options.map(size => ( + <Fragment key={size}> + <Grid.Col span={2}> + <Text weight="bold">{size}</Text> + </Grid.Col> + <Grid.Col span={10}> + <Anchor {...args} size={size} href={sampleArgs.href}> + {sampleArgs.text} + </Anchor> + </Grid.Col> + </Fragment> + ))} + </Grid> +); + +export default { + title: "Typography/Anchor", + component: Anchor, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const Sizes = { + render: SizeTemplate, + name: "Sizes", +}; diff --git a/frontend/src/metabase/ui/components/typography/List/List.mdx b/frontend/src/metabase/ui/components/typography/List/List.mdx new file mode 100644 index 0000000000000000000000000000000000000000..acf1839bc67ad1fdbe930043b04e1cf71935a6a9 --- /dev/null +++ b/frontend/src/metabase/ui/components/typography/List/List.mdx @@ -0,0 +1,18 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Grid, List, Icon } from "metabase/ui"; +import * as ListStories from "./List.stories"; + +<Meta of={ListStories} /> + +# List + +Our themed wrapper around [Mantine List](https://v6.mantine.dev/core/list/). + +<Canvas> + <Story of={ListStories.Default} /> +</Canvas> + +<Canvas> + <Story of={ListStories.WithIcons} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/typography/List/List.stories.mdx b/frontend/src/metabase/ui/components/typography/List/List.stories.tsx similarity index 61% rename from frontend/src/metabase/ui/components/typography/List/List.stories.mdx rename to frontend/src/metabase/ui/components/typography/List/List.stories.tsx index f8e0e0630b6b08abb4f0e252014a21bb60900099..f0de1e84734007dfb7ec6d3b5a4d323b8c28bb6c 100644 --- a/frontend/src/metabase/ui/components/typography/List/List.stories.mdx +++ b/frontend/src/metabase/ui/components/typography/List/List.stories.tsx @@ -1,20 +1,12 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Grid, List, Icon } from "metabase/ui"; +import { Icon, List, type ListProps } from "metabase/ui"; -export const args = { +const args = { size: "md", type: "ordered", withPadding: false, }; -export const sampleArgs = { - size: "md", - type: "ordered", - withPadding: false, -}; - -export const argTypes = { +const argTypes = { size: { options: ["xs", "sm", "md", "lg"], control: { type: "inline-radio" }, @@ -28,18 +20,7 @@ export const argTypes = { }, }; -<Meta - title="Typography/List" - component={List} - args={args} - argTypes={argTypes} -/> - -# List - -Our themed wrapper around [Mantine List](https://v6.mantine.dev/core/list/). - -export const DefaultTemplate = args => ( +const DefaultTemplate = (args: ListProps) => ( <List {...args}> <List.Item>Clone or download repository from GitHub</List.Item> <List.Item>Install dependencies with yarn</List.Item> @@ -51,13 +32,7 @@ export const DefaultTemplate = args => ( </List> ); -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -export const WithIconsTemplate = args => { +const WithIconsTemplate = (args: ListProps) => { return ( <List {...args} icon={<Icon name="check" />}> <List.Item>Clone or download repository from GitHub</List.Item> @@ -73,8 +48,19 @@ export const WithIconsTemplate = args => { ); }; -export const WithIcons = WithIconsTemplate.bind({}); +export default { + title: "Typography/List", + component: List, + args, + argTypes, +}; -<Canvas> - <Story name="WithIcons">{WithIcons}</Story> -</Canvas> +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const WithIcons = { + render: WithIconsTemplate, + name: "WithIcons", +}; diff --git a/frontend/src/metabase/ui/components/typography/Text/Text.mdx b/frontend/src/metabase/ui/components/typography/Text/Text.mdx new file mode 100644 index 0000000000000000000000000000000000000000..4df222252c31beee35768edef7ebdf8cd9bc9819 --- /dev/null +++ b/frontend/src/metabase/ui/components/typography/Text/Text.mdx @@ -0,0 +1,48 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Grid, Text } from "metabase/ui"; +import * as TextStories from "./Text.stories"; + +<Meta of={TextStories} /> + +# Text + +Our themed wrapper around [Mantine Text](https://v6.mantine.dev/core/text/). + +## When to use Text + +The Text component allows users to display text with themed styles, and replaces the usage of `<div>text</div>` or `<span>text</span>`. This component also handles sizing, line clamps, text decoration, and font weight. For links, use the `Anchor` component, and for code, use the `Code` component. + +## Docs + +- [Figma File](https://www.figma.com/file/h6aMN8H67eu2w8wmDngfnM/Typography-%2F-Text?type=design&node-id=1-96&mode=design&t=2eUYOsqZZeMc4OGT-0) +- [Mantine Text Docs](https://v6.mantine.dev/core/text/) + +## Examples + +<Canvas> + <Story of={TextStories.Default} /> +</Canvas> + +### Sizes + +<Canvas> + <Story of={TextStories.Sizes} /> +</Canvas> + +### Multiline + +<Canvas> + <Story of={TextStories.Multiline} /> +</Canvas> + +### Truncated + +<Canvas> + <Story of={TextStories.Truncated} /> +</Canvas> + +## Related components + +- Anchor +- Code diff --git a/frontend/src/metabase/ui/components/typography/Text/Text.stories.mdx b/frontend/src/metabase/ui/components/typography/Text/Text.stories.mdx deleted file mode 100644 index 2a9066266f63504d8a28b103289f270ee5e0a3c5..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/typography/Text/Text.stories.mdx +++ /dev/null @@ -1,139 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Grid, Text } from "metabase/ui"; - -export const args = { - size: "md", - align: "unset", - weight: "normal", - italic: false, - underline: false, - strikethrough: false, - truncate: false, - lineClamp: undefined, -}; - -export const sampleArgs = { - shortText: "Weniger", - longText: - "Having small touches of colour makes it more colourful than having the whole thing in colour", -}; - -export const argTypes = { - size: { - options: ["xs", "sm", "md", "lg"], - control: { type: "inline-radio" }, - }, - align: { - options: ["left", "center", "right"], - control: { type: "inline-radio" }, - }, - weight: { - options: ["normal", "bold"], - control: { type: "inline-radio" }, - }, - italic: { - control: { type: "boolean" }, - }, - underline: { - control: { type: "boolean" }, - }, - underline: { - control: { type: "boolean" }, - }, - strikethrough: { - control: { type: "boolean" }, - }, - truncate: { - control: { type: "boolean" }, - }, - lineClamp: { - control: { type: "number" }, - }, -}; - -<Meta - title="Typography/Text" - component={Text} - args={args} - argTypes={argTypes} -/> - -# Text - -Our themed wrapper around [Mantine Text](https://v6.mantine.dev/core/text/). - -## When to use Text - -The Text component allows users to display text with themed styles, and replaces the usage of `<div>text</div>` or `<span>text</span>`. This component also handles sizing, line clamps, text decoration, and font weight. For links, use the `Anchor` component, and for code, use the `Code` component. - -## Docs - -- [Figma File](https://www.figma.com/file/h6aMN8H67eu2w8wmDngfnM/Typography-%2F-Text?type=design&node-id=1-96&mode=design&t=2eUYOsqZZeMc4OGT-0) -- [Mantine Text Docs](https://v6.mantine.dev/core/text/) - -## Examples - -export const DefaultTemplate = args => ( - <Text {...args}>{sampleArgs.shortText}</Text> -); - -export const SizeTemplate = args => ( - <Grid align="center" maw="18rem"> - {argTypes.size.options.map(size => ( - <Fragment key={size}> - <Grid.Col span={2}> - <Text weight="bold">{size}</Text> - </Grid.Col> - <Grid.Col span={10}> - <Text {...args} size={size} /> - </Grid.Col> - </Fragment> - ))} - </Grid> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Sizes - -export const Sizes = SizeTemplate.bind({}); -Sizes.args = { - children: sampleArgs.shortText, -}; - -<Canvas> - <Story name="Sizes">{Sizes}</Story> -</Canvas> - -### Multiline - -export const Multiline = SizeTemplate.bind({}); -Multiline.args = { - children: sampleArgs.longText, -}; - -<Canvas> - <Story name="Multiline">{Multiline}</Story> -</Canvas> - -### Truncated - -export const Truncated = SizeTemplate.bind({}); -Truncated.args = { - children: sampleArgs.longText, - lineClamp: 2, -}; - -<Canvas> - <Story name="Truncated">{Truncated}</Story> -</Canvas> - -## Related components - -- Anchor -- Code diff --git a/frontend/src/metabase/ui/components/typography/Text/Text.stories.tsx b/frontend/src/metabase/ui/components/typography/Text/Text.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5e53b3abdf9d24fb1efe6ec91bfdd1c240dc91fe --- /dev/null +++ b/frontend/src/metabase/ui/components/typography/Text/Text.stories.tsx @@ -0,0 +1,106 @@ +import { Fragment } from "react"; + +import { Grid, Text, type TextProps } from "metabase/ui"; + +const args = { + size: "md", + align: "unset", + weight: "normal", + italic: false, + underline: false, + strikethrough: false, + truncate: false, + lineClamp: undefined, +}; + +const sampleArgs = { + shortText: "Weniger", + longText: + "Having small touches of colour makes it more colourful than having the whole thing in colour", +}; + +const argTypes = { + size: { + options: ["xs", "sm", "md", "lg"], + control: { type: "inline-radio" }, + }, + align: { + options: ["left", "center", "right"], + control: { type: "inline-radio" }, + }, + weight: { + options: ["normal", "bold"], + control: { type: "inline-radio" }, + }, + italic: { + control: { type: "boolean" }, + }, + underline: { + control: { type: "boolean" }, + }, + strikethrough: { + control: { type: "boolean" }, + }, + truncate: { + control: { type: "boolean" }, + }, + lineClamp: { + control: { type: "number" }, + }, +}; + +const DefaultTemplate = (args: TextProps) => ( + <Text {...args}>{sampleArgs.shortText}</Text> +); + +const SizeTemplate = (args: TextProps) => ( + <Grid align="center" maw="18rem"> + {argTypes.size.options.map(size => ( + <Fragment key={size}> + <Grid.Col span={2}> + <Text weight="bold">{size}</Text> + </Grid.Col> + <Grid.Col span={10}> + <Text {...args} size={size} /> + </Grid.Col> + </Fragment> + ))} + </Grid> +); + +export default { + title: "Typography/Text", + component: Text, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const Sizes = { + render: SizeTemplate, + name: "Sizes", + args: { + children: sampleArgs.shortText, + }, +}; + +export const Multiline = { + render: SizeTemplate, + name: "Multiline", + args: { + children: sampleArgs.longText, + }, +}; + +export const Truncated = { + render: SizeTemplate, + name: "Truncated", + args: { + children: sampleArgs.longText, + lineClamp: 2, + }, +}; diff --git a/frontend/src/metabase/ui/components/typography/Title/Title.mdx b/frontend/src/metabase/ui/components/typography/Title/Title.mdx new file mode 100644 index 0000000000000000000000000000000000000000..41f6810ce32c1e9085efa8a6029b2d286690977e --- /dev/null +++ b/frontend/src/metabase/ui/components/typography/Title/Title.mdx @@ -0,0 +1,48 @@ +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Box, Stack, Title } from "metabase/ui"; +import * as TitleStories from "./Title.stories"; + +<Meta of={TitleStories} /> + +# Title + +Our themed wrapper around [Mantine Title](https://v6.mantine.dev/core/title/). + +## When to use Title + +TBD + +## Docs + +- [Figma File](https://www.figma.com/file/SEQS7bshKQ4y4V5FwvdROv/Typography-%2F-Title?type=design&node-id=1-96&mode=design&t=2eUYOsqZZeMc4OGT-0) +- [Mantine Title Docs](https://v6.mantine.dev/core/title/) + +## Examples + +<Canvas> + <Story of={TitleStories.Default} /> +</Canvas> + +### Sizes + +<Canvas> + <Story of={TitleStories.Sizes} /> +</Canvas> + +### Underlined + +<Canvas> + <Story of={TitleStories.Underlined} /> +</Canvas> + +### Truncated + +<Canvas> + <Story of={TitleStories.Truncated} /> +</Canvas> + +### Truncated and underlined + +<Canvas> + <Story of={TitleStories.TruncatedAndUnderlined} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/typography/Title/Title.stories.mdx b/frontend/src/metabase/ui/components/typography/Title/Title.stories.mdx deleted file mode 100644 index 04a251e48287f243c2e4070900ab0fdb7960c9f9..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/typography/Title/Title.stories.mdx +++ /dev/null @@ -1,140 +0,0 @@ -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Box, Stack, Title } from "metabase/ui"; - -export const args = { - align: "left", - order: 1, - underline: false, - truncate: false, -}; - -export const argTypes = { - align: { - options: ["left", "center", "right"], - control: { type: "inline-radio" }, - }, - order: { - options: [1, 2, 3, 4], - control: { type: "inline-radio" }, - }, - underline: { - control: { type: "boolean" }, - }, - truncate: { - control: { type: "boolean" }, - }, -}; - -<Meta - title="Typography/Title" - component={Title} - args={args} - argTypes={argTypes} -/> - -# Title - -Our themed wrapper around [Mantine Title](https://v6.mantine.dev/core/title/). - -## When to use Title - -TBD - -## Docs - -- [Figma File](https://www.figma.com/file/SEQS7bshKQ4y4V5FwvdROv/Typography-%2F-Title?type=design&node-id=1-96&mode=design&t=2eUYOsqZZeMc4OGT-0) -- [Mantine Title Docs](https://v6.mantine.dev/core/title/) - -## Examples - -export const DefaultTemplate = args => <Title {...args}>Header</Title>; - -export const SizeTemplate = args => ( - <Stack> - <Title {...args} order={1}> - Header 1 - </Title> - <Title {...args} order={2}> - Header 2 - </Title> - <Title {...args} order={3}> - Header 3 - </Title> - <Title {...args} order={4}> - Header 4 - </Title> - </Stack> -); - -export const TruncatedTemplate = args => ( - <Stack> - <Box w="6rem"> - <Title {...args} order={1}> - Header 1 - </Title> - </Box> - <Box w="5rem"> - <Title {...args} order={2}> - Header 2 - </Title> - </Box> - <Box w="4rem"> - <Title {...args} order={3}> - Header 3 - </Title> - </Box> - <Box w="3.5rem"> - <Title {...args} order={4}> - Header 4 - </Title> - </Box> - </Stack> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Sizes - -export const Sizes = SizeTemplate.bind({}); - -<Canvas> - <Story name="Sizes">{Sizes}</Story> -</Canvas> - -### Underlined - -export const Underlined = SizeTemplate.bind({}); -Underlined.args = { - underline: true, -}; - -<Canvas> - <Story name="Underlined">{Underlined}</Story> -</Canvas> - -### Truncated - -export const Truncated = TruncatedTemplate.bind({}); -Truncated.args = { - truncate: true, -}; - -<Canvas> - <Story name="Truncated">{Truncated}</Story> -</Canvas> - -### Truncated and underlined - -export const TruncatedAndUnderlined = TruncatedTemplate.bind({}); -TruncatedAndUnderlined.args = { - truncate: true, - underline: true, -}; - -<Canvas> - <Story name="Truncated and underlined">{TruncatedAndUnderlined}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/typography/Title/Title.stories.tsx b/frontend/src/metabase/ui/components/typography/Title/Title.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..85430e80d52b331f978bc84c5dc48a41631bfb92 --- /dev/null +++ b/frontend/src/metabase/ui/components/typography/Title/Title.stories.tsx @@ -0,0 +1,111 @@ +import { Box, Stack, Title, type TitleProps } from "metabase/ui"; + +const args = { + align: "left", + order: 1, + underline: false, + truncate: false, +}; + +const argTypes = { + align: { + options: ["left", "center", "right"], + control: { type: "inline-radio" }, + }, + order: { + options: [1, 2, 3, 4], + control: { type: "inline-radio" }, + }, + underline: { + control: { type: "boolean" }, + }, + truncate: { + control: { type: "boolean" }, + }, +}; + +const DefaultTemplate = (args: TitleProps) => <Title {...args}>Header</Title>; + +const SizeTemplate = (args: TitleProps) => ( + <Stack> + <Title {...args} order={1}> + Header 1 + </Title> + <Title {...args} order={2}> + Header 2 + </Title> + <Title {...args} order={3}> + Header 3 + </Title> + <Title {...args} order={4}> + Header 4 + </Title> + </Stack> +); + +const TruncatedTemplate = (args: TitleProps) => ( + <Stack> + <Box w="6rem"> + <Title {...args} order={1}> + Header 1 + </Title> + </Box> + <Box w="5rem"> + <Title {...args} order={2}> + Header 2 + </Title> + </Box> + <Box w="4rem"> + <Title {...args} order={3}> + Header 3 + </Title> + </Box> + <Box w="3.5rem"> + <Title {...args} order={4}> + Header 4 + </Title> + </Box> + </Stack> +); + +export default { + title: "Typography/Title", + component: Title, + args, + argTypes, +}; + +export const Default = { + render: DefaultTemplate, + name: "Default", +}; + +export const Sizes = { + render: SizeTemplate, + name: "Sizes", +}; + +export const Underlined = { + render: SizeTemplate, + name: "Underlined", + args: { + underline: true, + }, +}; + +export const Truncated = { + render: TruncatedTemplate, + name: "Truncated", + args: { + truncate: true, + }, +}; + +export const TruncatedAndUnderlined = { + render: TruncatedTemplate, + name: "Truncated and underlined", + args: { + truncate: true, + underline: true, + }, +}; diff --git a/frontend/src/metabase/ui/components/utils/Divider/Divider.mdx b/frontend/src/metabase/ui/components/utils/Divider/Divider.mdx new file mode 100644 index 0000000000000000000000000000000000000000..3b07966ebd946778b1761f43aad04a61436cdf68 --- /dev/null +++ b/frontend/src/metabase/ui/components/utils/Divider/Divider.mdx @@ -0,0 +1,26 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Group, Divider, Text } from "metabase/ui"; +import * as DividerStories from "./Divider.stories"; + +<Meta of={DividerStories} /> + +# Divider + +Our themed wrapper around [Mantine Divider](https://v6.mantine.dev/core/divider/). + +## Docs + +- [Mantine Divider Docs](https://v6.mantine.dev/core/divider/) + +## Examples + +<Canvas> + <Story of={DividerStories.Default} /> +</Canvas> + +### Vertical orientation + +<Canvas> + <Story of={DividerStories.VerticalOrientation} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/utils/Divider/Divider.stories.mdx b/frontend/src/metabase/ui/components/utils/Divider/Divider.stories.mdx deleted file mode 100644 index 844b5e1aaafe25f786a1870a8358f4b9451efdb1..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/ui/components/utils/Divider/Divider.stories.mdx +++ /dev/null @@ -1,60 +0,0 @@ -import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Group, Divider, Text } from "metabase/ui"; - -export const args = { - orientation: "horizontal", -}; - -export const argTypes = { - orientation: { - options: ["horizontal", "vertical"], - control: { type: "inline-radio" }, - }, -}; - -<Meta - title="Utils/Divider" - component={Divider} - args={args} - argTypes={argTypes} -/> - -# Divider - -Our themed wrapper around [Mantine Divider](https://v6.mantine.dev/core/divider/). - -## Docs - -- [Mantine Divider Docs](https://v6.mantine.dev/core/divider/) - -## Examples - -export const DefaultTemplate = args => <Divider {...args} />; - -export const VerticalTemplate = args => ( - <Group> - <Text>Overview</Text> - <Divider {...args} /> - <Text>Metrics</Text> - <Divider {...args} /> - <Text>Segments</Text> - </Group> -); - -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### Vertical orientation - -export const Vertical = VerticalTemplate.bind({}); -Vertical.args = { - orientation: "vertical", -}; - -<Canvas> - <Story name="Vertical orientation">{Vertical}</Story> -</Canvas> diff --git a/frontend/src/metabase/ui/components/utils/Divider/Divider.stories.tsx b/frontend/src/metabase/ui/components/utils/Divider/Divider.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd6c78152b9635ef427a6d3bd52b693213d61910 --- /dev/null +++ b/frontend/src/metabase/ui/components/utils/Divider/Divider.stories.tsx @@ -0,0 +1,41 @@ +import { Divider, type DividerProps, Group, Text } from "metabase/ui"; + +const args = { + orientation: "horizontal", +}; + +const argTypes = { + orientation: { + options: ["horizontal", "vertical"], + control: { type: "inline-radio" }, + }, +}; + +const VerticalTemplate = (args: DividerProps) => ( + <Group> + <Text>Overview</Text> + <Divider {...args} /> + <Text>Metrics</Text> + <Divider {...args} /> + <Text>Segments</Text> + </Group> +); + +export default { + title: "Utils/Divider", + component: Divider, + args, + argTypes, +}; + +export const Default = { + name: "Default", +}; + +export const VerticalOrientation = { + render: VerticalTemplate, + name: "Vertical orientation", + args: { + orientation: "vertical", + }, +}; diff --git a/frontend/src/metabase/ui/components/utils/Paper/Paper.mdx b/frontend/src/metabase/ui/components/utils/Paper/Paper.mdx new file mode 100644 index 0000000000000000000000000000000000000000..bdb5b9330f9e1dadaa70b5f6698071e940157180 --- /dev/null +++ b/frontend/src/metabase/ui/components/utils/Paper/Paper.mdx @@ -0,0 +1,33 @@ +import { Fragment } from "react"; +import { Canvas, Story, Meta } from "@storybook/blocks"; +import { Grid, Paper, Text } from "metabase/ui"; +import * as PaperStories from "./Paper.stories"; + +<Meta of={PaperStories} /> + +# Paper + +Our themed wrapper around [Mantine Paper](https://v6.mantine.dev/core/paper/). + +## Docs + +- [Figma File](https://www.figma.com/file/uc2OFS4lu0GvVDFB7Sz1hw/Paper?type=design&node-id=1-227&mode=design&t=x44LGyAYVvvURkVS-0) +- [Mantine Paper Docs](https://v6.mantine.dev/core/paper/) + +## Examples + +<Canvas> + <Story of={PaperStories.Default} /> +</Canvas> + +### No border + +<Canvas> + <Story of={PaperStories.NoBorder} /> +</Canvas> + +### Border + +<Canvas> + <Story of={PaperStories.Border} /> +</Canvas> diff --git a/frontend/src/metabase/ui/components/utils/Paper/Paper.stories.mdx b/frontend/src/metabase/ui/components/utils/Paper/Paper.stories.tsx similarity index 54% rename from frontend/src/metabase/ui/components/utils/Paper/Paper.stories.mdx rename to frontend/src/metabase/ui/components/utils/Paper/Paper.stories.tsx index e178029777b616505c3a797f15661ee892704f20..b0318106dd381d97b699d8c916f74629bc019ebe 100644 --- a/frontend/src/metabase/ui/components/utils/Paper/Paper.stories.mdx +++ b/frontend/src/metabase/ui/components/utils/Paper/Paper.stories.tsx @@ -1,19 +1,19 @@ import { Fragment } from "react"; -import { Canvas, Story, Meta } from "@storybook/addon-docs"; -import { Grid, Paper, Text } from "metabase/ui"; -export const args = { +import { Grid, Paper, type PaperProps, Text } from "metabase/ui"; + +const args = { p: "md", radius: "md", shadow: "md", withBorder: false, }; -export const sampleArgs = { +const sampleArgs = { text: "The elm tree planted by Eleanor Bold, the judge’s daughter, fell last night.", }; -export const argTypes = { +const argTypes = { p: { options: ["xs", "sm", "md", "lg", "xl"], control: { type: "inline-radio" }, @@ -31,30 +31,17 @@ export const argTypes = { }, }; -<Meta title="Utils/Paper" component={Paper} args={args} argTypes={argTypes} /> - -# Paper - -Our themed wrapper around [Mantine Paper](https://v6.mantine.dev/core/paper/). - -## Docs - -- [Figma File](https://www.figma.com/file/uc2OFS4lu0GvVDFB7Sz1hw/Paper?type=design&node-id=1-227&mode=design&t=x44LGyAYVvvURkVS-0) -- [Mantine Paper Docs](https://v6.mantine.dev/core/paper/) - -## Examples - -export const DefaultTemplate = args => ( +const DefaultTemplate = (args: PaperProps) => ( <Paper {...args}> <Text>{sampleArgs.text}</Text> </Paper> ); -export const GridTemplate = args => ( +const GridTemplate = (args: PaperProps) => ( <Grid columns={argTypes.radius.options.length + 1} align="center" gutter="xl"> <Grid.Col span={1} /> {argTypes.radius.options.map(radius => ( - <Grid.Col key={radius} span={1} align="center"> + <Grid.Col key={radius} span={1}> <Text weight="bold">Radius {radius}</Text> </Grid.Col> ))} @@ -75,27 +62,27 @@ export const GridTemplate = args => ( </Grid> ); -export const Default = DefaultTemplate.bind({}); - -<Canvas> - <Story name="Default">{Default}</Story> -</Canvas> - -### No border - -export const NoBorder = GridTemplate.bind({}); - -<Canvas> - <Story name="No border">{NoBorder}</Story> -</Canvas> +export default { + title: "Utils/Paper", + component: Paper, + args, + argTypes, +}; -### Border +export const Default = { + render: DefaultTemplate, + name: "Default", +}; -export const Border = GridTemplate.bind({}); -Border.args = { - withBorder: true, +export const NoBorder = { + render: GridTemplate, + name: "No border", }; -<Canvas> - <Story name="Border">{Border}</Story> -</Canvas> +export const Border = { + render: GridTemplate, + name: "Border", + args: { + withBorder: true, + }, +}; diff --git a/frontend/src/metabase/ui/components/utils/Space/index.ts b/frontend/src/metabase/ui/components/utils/Space/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8642898cb204f17ae2892a22aa428c3edb655b03 --- /dev/null +++ b/frontend/src/metabase/ui/components/utils/Space/index.ts @@ -0,0 +1 @@ +export { Space } from "@mantine/core"; diff --git a/frontend/src/metabase/ui/components/utils/index.ts b/frontend/src/metabase/ui/components/utils/index.ts index 74b31abd95f305b0c9d3842d7f49a4ea772c382e..38acb3fe37a3ebc63734ea0241e5da1a667207c0 100644 --- a/frontend/src/metabase/ui/components/utils/index.ts +++ b/frontend/src/metabase/ui/components/utils/index.ts @@ -4,4 +4,5 @@ export * from "./Divider"; export * from "./FocusTrap"; export * from "./Paper"; export * from "./Transition"; +export * from "./Space"; export * from "./Portal"; diff --git a/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.stories.tsx b/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.stories.tsx index 6184f07a6d6cc9f23e2979f4a2401b190abfceb6..5014bb401ecfbb32c914fdc661aad8179ab1712e 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.stories.tsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.stories.tsx @@ -1,4 +1,4 @@ -import type { Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { VisualizationWrapper } from "__support__/storybook"; import { Box } from "metabase/ui"; @@ -18,7 +18,7 @@ export default { component: TableInteractive, }; -export const Default: Story = () => ( +export const Default: StoryFn = () => ( <VisualizationWrapper> <Box h={500}> <Visualization rawSeries={RAW_SERIES} />, diff --git a/frontend/src/metabase/visualizations/components/TableSimple/TableSimple.stories.tsx b/frontend/src/metabase/visualizations/components/TableSimple/TableSimple.stories.tsx index 9803db33c4797c64c42f5930c4a2415d78a85d63..37e8612120ca95afd3cd056a032b3aadf0767609 100644 --- a/frontend/src/metabase/visualizations/components/TableSimple/TableSimple.stories.tsx +++ b/frontend/src/metabase/visualizations/components/TableSimple/TableSimple.stories.tsx @@ -1,4 +1,4 @@ -import type { Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { SdkVisualizationWrapper, @@ -14,7 +14,7 @@ export default { title: "viz/TableSimple", }; -export const Default: Story = () => ( +export const Default: StoryFn = () => ( <VisualizationWrapper> <Box h={500}> <Visualization rawSeries={RAW_SERIES} isDashboard />, @@ -22,7 +22,7 @@ export const Default: Story = () => ( </VisualizationWrapper> ); -export const EmbeddingTheme: Story = () => { +export const EmbeddingTheme: StoryFn = () => { const theme: MetabaseTheme = { colors: { brand: "#eccc68", diff --git a/frontend/src/metabase/visualizations/components/skeletons/ChartSkeleton/ChartSkeleton.stories.tsx b/frontend/src/metabase/visualizations/components/skeletons/ChartSkeleton/ChartSkeleton.stories.tsx index a6854f4199ed15f6c53641c3a53a92adb7e06782..b14b9627e179259130f9b43aaad2e3e9f69cda55 100644 --- a/frontend/src/metabase/visualizations/components/skeletons/ChartSkeleton/ChartSkeleton.stories.tsx +++ b/frontend/src/metabase/visualizations/components/skeletons/ChartSkeleton/ChartSkeleton.stories.tsx @@ -1,13 +1,13 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; -import ChartSkeleton from "./ChartSkeleton"; +import ChartSkeleton, { type ChartSkeletonProps } from "./ChartSkeleton"; export default { title: "Visualizations/ChartSkeleton", component: ChartSkeleton, }; -const Template: ComponentStory<typeof ChartSkeleton> = args => { +const Template: StoryFn<ChartSkeletonProps> = args => { return ( <div style={{ padding: 8, height: 250, backgroundColor: "white" }}> <ChartSkeleton {...args} /> @@ -15,90 +15,132 @@ const Template: ComponentStory<typeof ChartSkeleton> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - display: "table", - description: "Description", +export const Default = { + render: Template, + + args: { + display: "table", + description: "Description", + }, }; export const Empty = Template.bind({ display: null, }); -export const Area = Template.bind({}); -Area.args = { - display: "area", - name: "Area", +export const Area = { + render: Template, + + args: { + display: "area", + name: "Area", + }, }; -export const Bar = Template.bind({}); -Bar.args = { - display: "bar", - name: "Bar", +export const Bar = { + render: Template, + + args: { + display: "bar", + name: "Bar", + }, }; -export const Funnel = Template.bind({}); -Funnel.args = { - display: "funnel", - name: "Funnel", +export const Funnel = { + render: Template, + + args: { + display: "funnel", + name: "Funnel", + }, }; -export const Line = Template.bind({}); -Line.args = { - display: "line", - name: "Line", +export const Line = { + render: Template, + + args: { + display: "line", + name: "Line", + }, }; -export const Map = Template.bind({}); -Map.args = { - display: "map", - name: "Map", +export const Map = { + render: Template, + + args: { + display: "map", + name: "Map", + }, }; -export const Pie = Template.bind({}); -Pie.args = { - display: "pie", - name: "Pie", +export const Pie = { + render: Template, + + args: { + display: "pie", + name: "Pie", + }, }; -export const Progress = Template.bind({}); -Progress.args = { - display: "progress", - name: "Progress", +export const Progress = { + render: Template, + + args: { + display: "progress", + name: "Progress", + }, }; -export const Row = Template.bind({}); -Row.args = { - display: "row", - name: "Row", +export const Row = { + render: Template, + + args: { + display: "row", + name: "Row", + }, }; -export const Scalar = Template.bind({}); -Scalar.args = { - display: "scalar", - name: "Scalar", +export const Scalar = { + render: Template, + + args: { + display: "scalar", + name: "Scalar", + }, }; -export const Scatter = Template.bind({}); -Scatter.args = { - display: "scatter", - name: "Scatter", +export const Scatter = { + render: Template, + + args: { + display: "scatter", + name: "Scatter", + }, }; -export const SmartScalar = Template.bind({}); -SmartScalar.args = { - display: "smartscalar", - name: "SmartScalar", +export const SmartScalar = { + render: Template, + + args: { + display: "smartscalar", + name: "SmartScalar", + }, }; -export const Table = Template.bind({}); -Table.args = { - display: "table", - name: "Table", +export const Table = { + render: Template, + + args: { + display: "table", + name: "Table", + }, }; -export const Waterfall = Template.bind({}); -Waterfall.args = { - display: "waterfall", - name: "Waterfall", +export const Waterfall = { + render: Template, + + args: { + display: "waterfall", + name: "Waterfall", + }, }; diff --git a/frontend/src/metabase/visualizations/components/skeletons/StaticSkeleton/StaticSkeleton.stories.tsx b/frontend/src/metabase/visualizations/components/skeletons/StaticSkeleton/StaticSkeleton.stories.tsx index 6e2ce808a61010a893529d0c470c209441e68bcd..9b0cb0a0835105cef55de1233d59af511f2a9962 100644 --- a/frontend/src/metabase/visualizations/components/skeletons/StaticSkeleton/StaticSkeleton.stories.tsx +++ b/frontend/src/metabase/visualizations/components/skeletons/StaticSkeleton/StaticSkeleton.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import StaticSkeleton from "./StaticSkeleton"; @@ -7,7 +7,7 @@ export default { component: StaticSkeleton, }; -const Template: ComponentStory<typeof StaticSkeleton> = args => { +const Template: StoryFn<typeof StaticSkeleton> = args => { return ( <div style={{ padding: 8, height: 250, backgroundColor: "white" }}> <StaticSkeleton {...args} /> @@ -15,15 +15,16 @@ const Template: ComponentStory<typeof StaticSkeleton> = args => { ); }; -export const Default = Template.bind({}); -Default.args = { - name: "Question", - icon: { name: "bar" }, +export const Default = { + render: Template, + + args: { + name: "Question", + description: "This is the question’s description", + icon: { name: "bar" }, + }, }; -export const WithDescription = Template.bind({}); -Default.args = { - name: "Question", - description: "This is the question’s description", - icon: { name: "bar" }, +export const WithDescription = { + render: Template, }; diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/option/tooltip.tsx b/frontend/src/metabase/visualizations/echarts/cartesian/option/tooltip.tsx index 5cde775c28fff338c477140a1341b172f3e69b1e..1aba5917a9a234a86a78d702742580296a49f535 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/option/tooltip.tsx +++ b/frontend/src/metabase/visualizations/echarts/cartesian/option/tooltip.tsx @@ -4,6 +4,7 @@ import { renderToString } from "react-dom/server"; import { EChartsTooltip } from "metabase/visualizations/components/ChartTooltip/EChartsTooltip"; import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; import { getTooltipModel } from "metabase/visualizations/visualizations/CartesianChart/events"; +import type { CardDisplayType } from "metabase-types/api"; import { getTooltipBaseOption } from "../../tooltip"; import { @@ -14,6 +15,7 @@ import type { BaseCartesianChartModel, DataKey } from "../model/types"; interface ChartItemTooltip { dataIndex: number; + display: CardDisplayType; seriesId?: DataKey | null; settings: ComputedVisualizationSettings; chartModel: BaseCartesianChartModel; @@ -23,6 +25,7 @@ const ChartItemTooltip = ({ chartModel, settings, dataIndex, + display, seriesId, }: ChartItemTooltip) => { if (dataIndex == null || seriesId == null) { @@ -33,6 +36,7 @@ const ChartItemTooltip = ({ chartModel, settings, dataIndex, + display, seriesId, ); @@ -46,6 +50,7 @@ const ChartItemTooltip = ({ export const getTooltipOption = ( chartModel: BaseCartesianChartModel, settings: ComputedVisualizationSettings, + display: CardDisplayType, containerRef: React.RefObject<HTMLDivElement>, ): TooltipOption => { return { @@ -70,6 +75,7 @@ export const getTooltipOption = ( settings={settings} chartModel={chartModel} dataIndex={dataIndex} + display={display} seriesId={seriesId} />, ); diff --git a/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx b/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx index 1c0eabdc096911d6c2775f578967dca77ded9d17..0e4d6d428d11f0a289cf6e5ebb4fcdfe008acf6c 100644 --- a/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx +++ b/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx @@ -5,6 +5,7 @@ import _ from "underscore"; import { isNotNull } from "metabase/lib/types"; import TooltipStyles from "metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.module.css"; +import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; import type { ClickObject } from "metabase-lib"; import type { BaseCartesianChartModel } from "../cartesian/model/types"; @@ -130,14 +131,20 @@ export const useClickedStateTooltipSync = ( export const useCartesianChartSeriesColorsClasses = ( chartModel: BaseCartesianChartModel, + settings: ComputedVisualizationSettings, ) => { - const hexColors = useMemo( - () => - chartModel.seriesModels - .map(seriesModel => seriesModel.color) - .filter(isNotNull), - [chartModel], - ); + const hexColors = useMemo(() => { + const seriesColors = chartModel.seriesModels + .map(seriesModel => seriesModel.color) + .filter(isNotNull); + + const settingColors = [ + settings["waterfall.increase_color"], + settings["waterfall.decrease_color"], + ].filter(isNotNull); + + return [...seriesColors, ...settingColors]; + }, [chartModel, settings]); return useInjectSeriesColorsClasses(hexColors); }; diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js index 634a075c96bdf1d99ac25beb8d1bbc0edbfa710d..d1541aa8bb679c577913d25fefbec68022ceeee0 100644 --- a/frontend/src/metabase/visualizations/lib/settings/graph.js +++ b/frontend/src/metabase/visualizations/lib/settings/graph.js @@ -279,12 +279,7 @@ export const LEGEND_SETTINGS = { export const TOOLTIP_SETTINGS = { "graph.tooltip_type": { - getDefault: ([{ card }]) => { - const shouldShowComparisonTooltip = !["waterfall", "scatter"].includes( - card.display, - ); - return shouldShowComparisonTooltip ? "series_comparison" : "default"; - }, + getDefault: () => "series_comparison", hidden: true, }, "graph.tooltip_columns": { diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx index c57c7a1ced3ef6b25a98e956635fcc102d8796cb..8791757975d49f96f210e50a33c9cc8a7716adf6 100644 --- a/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx @@ -1,4 +1,4 @@ -import type { ComponentStory } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { SdkVisualizationWrapper } from "__support__/storybook"; import { color } from "metabase/lib/colors"; @@ -7,14 +7,14 @@ import { getStaticChartTheme } from "metabase/static-viz/components/RowChart/the import { Box } from "metabase/ui"; import { useRowChartTheme } from "metabase/visualizations/visualizations/RowChart/utils/theme"; -import { RowChart } from "./RowChart"; +import { RowChart, type RowChartProps } from "./RowChart"; export default { title: "Visualizations/shared/RowChart", component: RowChart, }; -const Template: ComponentStory<typeof RowChart> = args => { +const Template: StoryFn<RowChartProps<any>> = args => { return ( <Box h={600} bg="white" p="8px"> <RowChart {...args} /> @@ -81,8 +81,10 @@ const DEFAULT_ROW_CHART_ARGS = { style: { fontFamily: "Lato" }, }; -export const Default = Template.bind({}); -Default.args = DEFAULT_ROW_CHART_ARGS; +export const Default = { + render: Template, + args: DEFAULT_ROW_CHART_ARGS, +}; const ThemedRowChart = () => { const theme = useRowChartTheme("Lato", false, false); diff --git a/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts b/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts index 77bd77cd9853644ec756e8b18d71efb7c6403ea2..3df9e4d8c732cec06dc7f6e448e84bca3b83a9ab 100644 --- a/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts +++ b/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts @@ -2,7 +2,10 @@ import { t } from "ttag"; import _ from "underscore"; import { isNotNull } from "metabase/lib/types"; -import { getMaxDimensionsSupported } from "metabase/visualizations"; +import { + getMaxDimensionsSupported, + getMaxMetricsSupported, +} from "metabase/visualizations"; import { getCardsColumns } from "metabase/visualizations/echarts/cartesian/model"; import { dimensionIsNumeric } from "metabase/visualizations/lib/numeric"; import { dimensionIsTimeseries } from "metabase/visualizations/lib/timeseries"; @@ -38,19 +41,19 @@ export function getDefaultMetricFilter(display: string) { } export function getAreDimensionsAndMetricsValid(rawSeries: RawSeries) { - return rawSeries.some( - ({ card, data }) => - columnsAreValid( - card.visualization_settings["graph.dimensions"], - data, - getDefaultDimensionFilter(card.display), - ) && - columnsAreValid( - card.visualization_settings["graph.metrics"], - data, - getDefaultMetricFilter(card.display), - ), - ); + return rawSeries.some(({ card, data }) => { + const dimensions = card.visualization_settings["graph.dimensions"]; + const metrics = card.visualization_settings["graph.metrics"]; + + const dimensionsFilter = getDefaultDimensionFilter(card.display); + const metricsFilter = getDefaultMetricFilter(card.display); + + return ( + columnsAreValid(dimensions, data, dimensionsFilter) && + columnsAreValid(metrics, data, metricsFilter) && + (metrics ?? []).length <= getMaxMetricsSupported(card.display) + ); + }); } export function getDefaultDimensions( @@ -74,6 +77,7 @@ export function getDefaultMetrics( rawSeries: RawSeries, settings: ComputedVisualizationSettings, ) { + const [{ card }] = rawSeries; const prevMetrics = settings["graph.metrics"] ?? []; const defaultMetrics = getDefaultColumns(rawSeries).metrics; if ( @@ -83,8 +87,7 @@ export function getDefaultMetrics( ) { return prevMetrics; } - - return defaultMetrics; + return defaultMetrics.slice(0, getMaxMetricsSupported(card.display)); } export const STACKABLE_SERIES_DISPLAY_TYPES = new Set(["area", "bar"]); diff --git a/frontend/src/metabase/visualizations/visualizations/BarChart/BarChart.stories.tsx b/frontend/src/metabase/visualizations/visualizations/BarChart/BarChart.stories.tsx index 1a41df9bd01c4fbf0fb3570532dab4bc5cc82015..e52f06d67be137452fa6d4ff4b2a34101f11b5c6 100644 --- a/frontend/src/metabase/visualizations/visualizations/BarChart/BarChart.stories.tsx +++ b/frontend/src/metabase/visualizations/visualizations/BarChart/BarChart.stories.tsx @@ -1,4 +1,4 @@ -import type { Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { SdkVisualizationWrapper, @@ -37,7 +37,7 @@ const MOCK_SERIES = [ }, ]; -export const Default: Story = () => ( +export const Default: StoryFn = () => ( <VisualizationWrapper> <Box h={500}> <Visualization rawSeries={MOCK_SERIES} width={500} /> @@ -46,7 +46,7 @@ export const Default: Story = () => ( ); // Example of how themes can be applied in the SDK. -export const EmbeddingHugeFont: Story = () => { +export const EmbeddingHugeFont: StoryFn = () => { const theme: MetabaseTheme = { fontSize: "20px", components: { cartesian: { padding: "0.5rem 1rem" } }, diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/CartesianChart.tsx b/frontend/src/metabase/visualizations/visualizations/CartesianChart/CartesianChart.tsx index 9fb5ab77cababe3e9735552b956c963690bb034a..67c95abeca70bdde4febb513669f1471174cd544 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/CartesianChart.tsx +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/CartesianChart.tsx @@ -121,7 +121,10 @@ function _CartesianChart(props: VisualizationProps) { }, []); const canSelectTitle = !!onChangeCardAndRun; - const seriesColorsCss = useCartesianChartSeriesColorsClasses(chartModel); + const seriesColorsCss = useCartesianChartSeriesColorsClasses( + chartModel, + settings, + ); useCloseTooltipOnScroll(chartRef); diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/chart-definition.ts b/frontend/src/metabase/visualizations/visualizations/CartesianChart/chart-definition.ts index b73ee17977065436c20ef953b03aa954531fac40..700f507cb4050185cff1575f00fb2807d905be16 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/chart-definition.ts +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/chart-definition.ts @@ -1,4 +1,3 @@ -import { t } from "ttag"; import _ from "underscore"; import { GRAPH_GOAL_SETTINGS } from "metabase/visualizations/lib/settings/goal"; @@ -50,12 +49,6 @@ export const getCartesianChartDefinition = ( }, checkRenderable(series, settings) { - if (series.length > (this.maxMetricsSupported ?? Infinity)) { - throw new Error( - t`${this.uiName} chart does not support multiple series`, - ); - } - validateDatasetRows(series); validateChartDataSettings(settings); validateStacking(settings); diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts b/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts index 7641dc0b9178983cbb150f331f37370a250212d7..35c3c05e83f1b8dd4f0e2e66e60c0671579e1304 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts @@ -63,6 +63,7 @@ import type Metadata from "metabase-lib/v1/metadata/Metadata"; import { isNative } from "metabase-lib/v1/queries/utils/card"; import { getColumnKey } from "metabase-lib/v1/queries/utils/column-key"; import type { + CardDisplayType, CardId, RawSeries, TimelineEvent, @@ -371,6 +372,7 @@ export const getTooltipModel = ( chartModel: BaseCartesianChartModel, settings: ComputedVisualizationSettings, echartsDataIndex: number, + display: CardDisplayType, seriesDataKey: DataKey, ): EChartsTooltipModel | null => { const dataIndex = getDataIndex( @@ -419,6 +421,7 @@ export const getTooltipModel = ( settings, datum, dataIndex, + display, hoveredSeries, ); }; @@ -473,6 +476,7 @@ export const getSeriesOnlyTooltipModel = ( settings: ComputedVisualizationSettings, datum: Datum, dataIndex: number, + display: CardDisplayType, hoveredSeries: SeriesModel, ): EChartsTooltipModel | null => { const header = String( @@ -498,7 +502,9 @@ export const getSeriesOnlyTooltipModel = ( return { isFocused, name: seriesModel.name, - markerColorClass: getMarkerColorClass(seriesModel.color), + markerColorClass: getMarkerColorClass( + getSeriesOnlyTooltipRowColor(seriesModel, datum, settings, display), + ), values: [ formatValueForTooltip({ value: datum[seriesModel.dataKey], @@ -530,6 +536,23 @@ export const getSeriesOnlyTooltipModel = ( }; }; +const getSeriesOnlyTooltipRowColor = ( + seriesModel: SeriesModel, + datum: Datum, + settings: ComputedVisualizationSettings, + display: CardDisplayType, +) => { + const value = datum[seriesModel.dataKey]; + if (display === "waterfall" && typeof value === "number") { + const color = + value >= 0 + ? settings["waterfall.increase_color"] + : settings["waterfall.decrease_color"]; + return color ?? seriesModel.color; + } + return seriesModel.color; +}; + export const getStackedTooltipModel = ( chartModel: BaseCartesianChartModel, settings: ComputedVisualizationSettings, diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/use-models-and-option.ts b/frontend/src/metabase/visualizations/visualizations/CartesianChart/use-models-and-option.ts index e4c1808b854b7702b942b5d20777025d4245392d..9830c97045a97b3b9dab0e888a49eed18e204c8a 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/use-models-and-option.ts +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/use-models-and-option.ts @@ -18,6 +18,7 @@ import { getWaterfallChartModel } from "metabase/visualizations/echarts/cartesia import { getWaterfallChartOption } from "metabase/visualizations/echarts/cartesian/waterfall/option"; import { useBrowserRenderingContext } from "metabase/visualizations/hooks/use-browser-rendering-context"; import type { VisualizationProps } from "metabase/visualizations/types"; +import type { CardDisplayType } from "metabase-types/api"; export function useModelsAndOption( { @@ -122,8 +123,13 @@ export function useModelsAndOption( }, [selectedTimelineEventIds, hovered?.timelineEvents]); const tooltipOption = useMemo(() => { - return getTooltipOption(chartModel, settings, containerRef); - }, [chartModel, settings, containerRef]); + return getTooltipOption( + chartModel, + settings, + card.display as CardDisplayType, + containerRef, + ); + }, [chartModel, settings, card.display, containerRef]); const option = useMemo(() => { if (width === 0 || height === 0) { diff --git a/frontend/src/metabase/visualizations/visualizations/Funnel/Funnel.stories.tsx b/frontend/src/metabase/visualizations/visualizations/Funnel/Funnel.stories.tsx index 9686676b006b425e55fa449f7739c52a03f21f5e..6ad1bff18a8a3ac2a9cfd95fe0968ee703d1cf0d 100644 --- a/frontend/src/metabase/visualizations/visualizations/Funnel/Funnel.stories.tsx +++ b/frontend/src/metabase/visualizations/visualizations/Funnel/Funnel.stories.tsx @@ -1,4 +1,4 @@ -import type { Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { VisualizationWrapper } from "__support__/storybook"; import { NumberColumn, StringColumn } from "__support__/visualizations"; @@ -44,7 +44,7 @@ const MOCK_SERIES = [ }, ]; -export const Default: Story = () => ( +export const Default: StoryFn = () => ( <VisualizationWrapper> <Box h={500}> <Visualization rawSeries={MOCK_SERIES} width={500} /> diff --git a/frontend/src/metabase/visualizations/visualizations/LineChart/LineChart.stories.tsx b/frontend/src/metabase/visualizations/visualizations/LineChart/LineChart.stories.tsx index 1ddbc39c59f30298dfadad7f246475156baa41ca..404d80be3e7c3e57887cc8f4e439c551847f87c5 100644 --- a/frontend/src/metabase/visualizations/visualizations/LineChart/LineChart.stories.tsx +++ b/frontend/src/metabase/visualizations/visualizations/LineChart/LineChart.stories.tsx @@ -1,4 +1,4 @@ -import type { Story } from "@storybook/react"; +import type { StoryObj } from "@storybook/react"; import { SdkVisualizationWrapper, @@ -45,38 +45,38 @@ const MOCK_SERIES = [ }, ]; -export const Default: Story = () => ( - <VisualizationWrapper> - <Box h={500}> - <Visualization rawSeries={MOCK_SERIES} width={500} /> - </Box> - </VisualizationWrapper> -); - // This story has become flaky on CI, so we're skipping it for now. -Default.story = { +export const Default: StoryObj = { + render: () => ( + <VisualizationWrapper> + <Box h={500}> + <Visualization rawSeries={MOCK_SERIES} width={500} /> + </Box> + </VisualizationWrapper> + ), + parameters: { loki: { skip: true }, }, }; -export const EmbeddingHugeFont: Story = () => { - const theme: MetabaseTheme = { - fontSize: "20px", - components: { cartesian: { padding: "0.5rem 1rem" } }, - }; +// This story has become flaky on CI, so we're skipping it for now. +export const EmbeddingHugeFont: StoryObj = { + render: () => { + const theme: MetabaseTheme = { + fontSize: "20px", + components: { cartesian: { padding: "0.5rem 1rem" } }, + }; - return ( - <SdkVisualizationWrapper theme={theme}> - <Box h={500}> - <Visualization rawSeries={MOCK_SERIES} width={500} /> - </Box> - </SdkVisualizationWrapper> - ); -}; + return ( + <SdkVisualizationWrapper theme={theme}> + <Box h={500}> + <Visualization rawSeries={MOCK_SERIES} width={500} /> + </Box> + </SdkVisualizationWrapper> + ); + }, -// This story has become flaky on CI, so we're skipping it for now. -EmbeddingHugeFont.story = { parameters: { loki: { skip: true }, }, diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart/PieChart.stories.tsx b/frontend/src/metabase/visualizations/visualizations/PieChart/PieChart.stories.tsx index a8e7ef526a34e58c9ebd0904db3378e5b2d41e77..87b124487c52e5fd8668c69cb7bf0712f4b54c13 100644 --- a/frontend/src/metabase/visualizations/visualizations/PieChart/PieChart.stories.tsx +++ b/frontend/src/metabase/visualizations/visualizations/PieChart/PieChart.stories.tsx @@ -1,4 +1,4 @@ -import type { Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { SdkVisualizationWrapper } from "__support__/storybook"; import type { MetabaseTheme } from "embedding-sdk"; @@ -17,7 +17,7 @@ export default { // @ts-expect-error: incompatible prop types with registerVisualization registerVisualization(PieChart); -const Template: Story = args => { +const Template: StoryFn = args => { const { backgroundColor, ...props } = args; const theme: MetabaseTheme = { @@ -54,24 +54,28 @@ const Template: Story = args => { ); }; -export const EmbeddedQuestion = Template.bind({}); -EmbeddedQuestion.args = { - isDashboard: false, - backgroundColor: "#ebe6e2", -}; -// TODO unskip this and the next story once rendering delay is completely gone. -EmbeddedQuestion.story = { +export const EmbeddedQuestion = { + render: Template, + + args: { + isDashboard: false, + backgroundColor: "#ebe6e2", + }, + parameters: { + // TODO unskip this and the next story once rendering delay is completely gone. loki: { skip: true }, }, }; -export const EmbeddedDashcard = Template.bind({}); -EmbeddedDashcard.args = { - isDashboard: true, - backgroundColor: "#dee9e9", -}; -EmbeddedDashcard.story = { +export const EmbeddedDashcard = { + render: Template, + + args: { + isDashboard: true, + backgroundColor: "#dee9e9", + }, + parameters: { loki: { skip: true }, }, diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.stories.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.stories.tsx index c77efc737fd1455d5d4e3a3f1ff90ff3c088b0aa..c7250dd410db2d7bf39624748e983f26809887e1 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.stories.tsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.stories.tsx @@ -1,4 +1,4 @@ -import type { Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { SdkVisualizationWrapper, @@ -15,7 +15,7 @@ export default { component: PivotTable, }; -export const Default: Story = () => { +export const Default: StoryFn = () => { return ( <VisualizationWrapper> <PivotTableTestWrapper /> @@ -23,7 +23,7 @@ export const Default: Story = () => { ); }; -export const EmbeddingTheme: Story = () => { +export const EmbeddingTheme: StoryFn = () => { const theme = { colors: { border: "#95a5a6", @@ -53,7 +53,7 @@ export const EmbeddingTheme: Story = () => { ); }; -export const HorizontalScroll43215: Story = () => { +export const HorizontalScroll43215: StoryFn = () => { return ( <VisualizationWrapper> <Box h="400px" w="600px"> @@ -67,7 +67,7 @@ export const HorizontalScroll43215: Story = () => { ); }; -export const OuterHorizontalScroll: Story = () => { +export const OuterHorizontalScroll: StoryFn = () => { return ( <VisualizationWrapper> <Box h="400px" w="320px"> @@ -83,7 +83,7 @@ export const OuterHorizontalScroll: Story = () => { ); }; -export const NoOuterHorizontalScroll: Story = () => { +export const NoOuterHorizontalScroll: StoryFn = () => { return ( <VisualizationWrapper> <Box h="400px" w="320px"> diff --git a/frontend/src/metabase/visualizations/visualizations/SmartScalar/SmartScalar.stories.tsx b/frontend/src/metabase/visualizations/visualizations/SmartScalar/SmartScalar.stories.tsx index c20fa858531dc7ed7dc2b08597f4f4a685b1a85e..21dad94d23b2906e39ed3ea26ebb1f3482578e28 100644 --- a/frontend/src/metabase/visualizations/visualizations/SmartScalar/SmartScalar.stories.tsx +++ b/frontend/src/metabase/visualizations/visualizations/SmartScalar/SmartScalar.stories.tsx @@ -1,4 +1,4 @@ -import type { Story } from "@storybook/react"; +import type { StoryFn } from "@storybook/react"; import { SdkVisualizationWrapper, @@ -29,14 +29,14 @@ const MOCK_SERIES = mockSeries({ insights: [{ unit: "month", col: "Count" }], }); -export const Default: Story = () => ( +export const Default: StoryFn = () => ( <VisualizationWrapper> <Visualization rawSeries={MOCK_SERIES} width={500} /> </VisualizationWrapper> ); // Example of how themes can be applied in the SDK. -export const EmbeddingTheme: Story = () => { +export const EmbeddingTheme: StoryFn = () => { const theme: MetabaseTheme = { colors: { positive: "#4834d4", diff --git a/modules/drivers/athena/test/metabase/test/data/athena.clj b/modules/drivers/athena/test/metabase/test/data/athena.clj index db4b4ea7d743155ed0e91469bb25779ac9a051fc..599fbf2786692b9f5efb19a441cbdcc9b917efb5 100644 --- a/modules/drivers/athena/test/metabase/test/data/athena.clj +++ b/modules/drivers/athena/test/metabase/test/data/athena.clj @@ -23,7 +23,8 @@ (sql-jdbc.tx/add-test-extensions! :athena) (doseq [feature [:test/time-type - :test/timestamptz-type]] + :test/timestamptz-type + :test/dynamic-dataset-loading]] (defmethod driver/database-supports? [:athena feature] [_driver _feature _database] false)) diff --git a/modules/drivers/databricks/deps.edn b/modules/drivers/databricks/deps.edn new file mode 100644 index 0000000000000000000000000000000000000000..28baf6232c9be42cf32d6da1ea0b6e650dc53c02 --- /dev/null +++ b/modules/drivers/databricks/deps.edn @@ -0,0 +1,5 @@ +{:paths + ["src" "resources"] + + :deps + {com.databricks/databricks-jdbc {:mvn/version "2.6.34"}}} diff --git a/modules/drivers/databricks/resources/metabase-plugin.yaml b/modules/drivers/databricks/resources/metabase-plugin.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d8f5135f62624d3e3ad7a3bff5bd00d7f1de5ae7 --- /dev/null +++ b/modules/drivers/databricks/resources/metabase-plugin.yaml @@ -0,0 +1,39 @@ +info: + name: Databricks driver + version: 1.0.0 + description: Allows Metabase to connect to Databricks SQL warehouse +driver: + - name: hive-like + lazy-load: true + abstract: true + parent: sql-jdbc + - name: databricks + display-name: Databricks + lazy-load: true + parent: hive-like + connection-properties: + - host + - name: http-path + display-name: HTTP path + required: true + - name: token + display-name: Personal Access Token + required: true + - name: catalog + display-name: Catalog + default: default + required: true + - name: schema-filters + type: schema-filters + display-name: Schemas + - advanced-options-start + - merge: + - additional-options + - placeholder: 'IgnoreTransactions=0' +init: + - step: load-namespace + namespace: metabase.driver.hive-like + - step: load-namespace + namespace: metabase.driver.databricks + - step: register-jdbc-driver + class: com.databricks.client.jdbc.Driver diff --git a/modules/drivers/databricks/src/metabase/driver/databricks.clj b/modules/drivers/databricks/src/metabase/driver/databricks.clj new file mode 100644 index 0000000000000000000000000000000000000000..7772c1eea8033b0061647a469c5991e2147953be --- /dev/null +++ b/modules/drivers/databricks/src/metabase/driver/databricks.clj @@ -0,0 +1,280 @@ +(ns metabase.driver.databricks + (:require + [clojure.string :as str] + [honey.sql :as sql] + [java-time.api :as t] + [metabase.driver :as driver] + [metabase.driver.hive-like :as driver.hive-like] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] + [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] + [metabase.driver.sql-jdbc.execute.legacy-impl :as sql-jdbc.legacy] + [metabase.driver.sql-jdbc.sync :as sql-jdbc.sync] + [metabase.driver.sql.query-processor :as sql.qp] + [metabase.query-processor.timezone :as qp.timezone] + [metabase.util :as u] + [metabase.util.honey-sql-2 :as h2x] + [metabase.util.log :as log] + [ring.util.codec :as codec]) + (:import + [java.sql Connection ResultSet ResultSetMetaData Statement] + [java.time LocalDate LocalDateTime LocalTime OffsetDateTime ZonedDateTime OffsetTime])) + +(set! *warn-on-reflection* true) + +(driver/register! :databricks, :parent :hive-like) + +(doseq [[feature supported?] {:basic-aggregations true + :binning true + :describe-fields true + :describe-fks true + :expression-aggregations true + :expressions true + :native-parameters true + :nested-queries true + :set-timezone true + :standard-deviation-aggregations true + :test/jvm-timezone-setting false}] + (defmethod driver/database-supports? [:databricks feature] [_driver _feature _db] supported?)) + +(defmethod sql-jdbc.sync/database-type->base-type :databricks + [driver database-type] + (condp re-matches (u/lower-case-en (name database-type)) + #"timestamp" :type/DateTimeWithLocalTZ + #"timestamp_ntz" :type/DateTime + ((get-method sql-jdbc.sync/database-type->base-type :hive-like) + driver database-type))) + +(defmethod sql-jdbc.sync/describe-fields-sql :databricks + [driver & {:keys [schema-names table-names]}] + (sql/format {:select [[:c.column_name :name] + [:c.full_data_type :database-type] + [:c.ordinal_position :database-position] + [:c.table_schema :table-schema] + [:c.table_name :table-name] + [[:case [:= :cs.constraint_type [:inline "PRIMARY KEY"]] true :else false] :pk?] + [[:case [:not= :c.comment [:inline ""]] :c.comment :else nil] :field-comment]] + :from [[:information_schema.columns :c]] + ;; Following links contains contains diagram of `information_schema`: + ;; https://docs.databricks.com/en/sql/language-manual/sql-ref-information-schema.html + :left-join [[{:select [[:tc.table_catalog :table_catalog] + [:tc.table_schema :table_schema] + [:tc.table_name :table_name] + [:ccu.column_name :column_name] + [:tc.constraint_type :constraint_type]] + :from [[:information_schema.table_constraints :tc]] + :join [[:information_schema.constraint_column_usage :ccu] + [:and + [:= :tc.constraint_catalog :ccu.constraint_catalog] + [:= :tc.constraint_schema :ccu.constraint_schema] + [:= :tc.constraint_name :ccu.constraint_name]]] + :where [:= :tc.constraint_type [:inline "PRIMARY KEY"]] + ;; In case on pk constraint is used by multiple columns this query would return duplicate + ;; rows. Group by ensures all rows are distinct. This may not be necessary, but rather + ;; safe than sorry. + :group-by [:tc.table_catalog + :tc.table_schema + :tc.table_name + :ccu.column_name + :tc.constraint_type]} + :cs] + [:and + [:= :c.table_catalog :cs.table_catalog] + [:= :c.table_schema :cs.table_schema] + [:= :c.table_name :cs.table_name] + [:= :c.column_name :cs.column_name]]] + :where [:and + ;; Ignore `timestamp_ntz` type columns. Columns of this type are not recognizable from + ;; `timestamp` columns when fetching the data. This exception should be removed when the problem + ;; is resolved by Databricks in underlying jdbc driver. + [:not= :c.full_data_type [:inline "timestamp_ntz"]] + [:not [:in :c.table_schema ["information_schema"]]] + (when schema-names [:in :c.table_schema schema-names]) + (when table-names [:in :c.table_name table-names])] + :order-by [:table-schema :table-name :database-position]} + :dialect (sql.qp/quote-style driver))) + +(defmethod sql-jdbc.sync/describe-fks-sql :databricks + [driver & {:keys [schema-names table-names]}] + (sql/format {:select (vec + {:fk_kcu.table_schema "fk-table-schema" + :fk_kcu.table_name "fk-table-name" + :fk_kcu.column_name "fk-column-name" + :pk_kcu.table_schema "pk-table-schema" + :pk_kcu.table_name "pk-table-name" + :pk_kcu.column_name "pk-column-name"}) + :from [[:information_schema.key_column_usage :fk_kcu]] + :join [[:information_schema.referential_constraints :rc] + [:and + [:= :fk_kcu.constraint_catalog :rc.constraint_catalog] + [:= :fk_kcu.constraint_schema :rc.constraint_schema] + [:= :fk_kcu.constraint_name :rc.constraint_name]] + [:information_schema.key_column_usage :pk_kcu] + [[:and + [:= :pk_kcu.constraint_catalog :rc.unique_constraint_catalog] + [:= :pk_kcu.constraint_schema :rc.unique_constraint_schema] + [:= :pk_kcu.constraint_name :rc.unique_constraint_name]]]] + :where [:and + [:not [:in :fk_kcu.table_schema ["information_schema"]]] + (when table-names [:in :fk_kcu.table_name table-names]) + (when schema-names [:in :fk_kcu.table_schema schema-names])] + :order-by [:fk-table-schema :fk-table-name]} + :dialect (sql.qp/quote-style driver))) + +(defmethod sql-jdbc.execute/set-timezone-sql :databricks + [_driver] + "SET TIME ZONE %s;") + +(defmethod driver/db-default-timezone :databricks + [driver database] + (sql-jdbc.execute/do-with-connection-with-options + driver database nil + (fn [^Connection conn] + (with-open [stmt (.prepareStatement conn "select current_timezone()") + rset (.executeQuery stmt)] + (when (.next rset) + (.getString rset 1)))))) + +(defn- preprocess-additional-options + [additional-options] + (when (string? (not-empty additional-options)) + (str/replace-first additional-options #"^(?!;)" ";"))) + +(defmethod sql-jdbc.conn/connection-details->spec :databricks + [_driver {:keys [catalog host http-path log-level token additional-options] :as _details}] + (assert (string? (not-empty catalog)) "Catalog is mandatory.") + (merge + {:classname "com.databricks.client.jdbc.Driver" + :subprotocol "databricks" + ;; Reading through the changelog revealed `EnableArrow=0` solves multiple problems. Including the exception logged + ;; during first `can-connect?` call. Ref: + ;; https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/jdbc/2.6.40/docs/release-notes.txt + :subname (str "//" host ":443/;EnableArrow=0" + ";ConnCatalog=" (codec/url-encode catalog) + (preprocess-additional-options additional-options)) + :transportMode "http" + :ssl 1 + :AuthMech 3 + :HttpPath http-path + :uid "token" + :pwd token + :UseNativeQuery 1} + ;; Following is used just for tests. See the [[metabase.driver.sql-jdbc.connection-test/perturb-db-details]] + ;; and test that is using the function. + (when log-level + {:LogLevel log-level}))) + +(defmethod sql.qp/quote-style :databricks + [_driver] + :mysql) + +(defmethod sql.qp/date [:databricks :day-of-week] [driver _ expr] + (sql.qp/adjust-day-of-week driver [:dayofweek (h2x/->timestamp expr)])) + +(defmethod driver/db-start-of-week :databricks + [_] + :sunday) + +(defmethod sql.qp/date [:databricks :week] + [driver _unit expr] + (let [week-extract-fn (fn [expr] + (-> [:date_sub + (h2x/+ (h2x/->timestamp expr) + [::driver.hive-like/interval 1 :day]) + [:dayofweek (h2x/->timestamp expr)]] + (h2x/with-database-type-info "timestamp")))] + (sql.qp/adjust-start-of-week driver week-extract-fn expr))) + +(defmethod sql-jdbc.execute/do-with-connection-with-options :databricks + [driver db-or-id-or-spec options f] + (sql-jdbc.execute/do-with-resolved-connection + driver + db-or-id-or-spec + options + (fn [^Connection conn] + (let [read-only? (.isReadOnly conn)] + (try + (.setReadOnly conn false) + ;; Method is re-implemented because `legacy_time_parser_policy` has to be set to pass the test suite. + ;; https://docs.databricks.com/en/sql/language-manual/parameters/legacy_time_parser_policy.html + (with-open [^Statement stmt (.createStatement conn)] + (.execute stmt "set legacy_time_parser_policy = legacy")) + (finally + (.setReadOnly conn read-only?)))) + (sql-jdbc.execute/set-default-connection-options! driver db-or-id-or-spec conn options) + (f conn)))) + +(defmethod sql.qp/datetime-diff [:databricks :second] + [_driver _unit x y] + [:- + [:unix_timestamp y (if (instance? LocalDate y) + (h2x/literal "yyyy-MM-dd") + (h2x/literal "yyyy-MM-dd HH:mm:ss"))] + [:unix_timestamp x (if (instance? LocalDate x) + (h2x/literal "yyyy-MM-dd") + (h2x/literal "yyyy-MM-dd HH:mm:ss"))]]) + +(def ^:private timestamp-database-type-names #{"TIMESTAMP" "TIMESTAMP_NTZ"}) + +;; Both timestamp types, TIMESTAMP and TIMESTAMP_NTZ, are returned in `Types/TIMESTAMP` sql type. TIMESTAMP is wall +;; clock with date in session timezone. Hence the following implementation adds the results timezone in LocalDateTime +;; gathered from JDBC driver and then adjusts the value to ZULU. Presentation tweaks (ie. changing to report for users' +;; pleasure) are done in `wrap-value-literals` middleware. +(defmethod sql-jdbc.execute/read-column-thunk [:databricks java.sql.Types/TIMESTAMP] + [_driver ^ResultSet rs ^ResultSetMetaData rsmeta ^Integer i] + ;; TIMESTAMP is returned also for TIMESTAMP_NTZ type!!! Hence only true branch is hit until this is fixed upstream. + (let [database-type-name (.getColumnTypeName rsmeta i)] + (assert (timestamp-database-type-names database-type-name)) + (if (= "TIMESTAMP" database-type-name) + (fn [] + (assert (some? (qp.timezone/results-timezone-id))) + (when-let [t (.getTimestamp rs i)] + (t/with-offset-same-instant + (t/offset-date-time + (t/zoned-date-time (t/local-date-time t) + (t/zone-id (qp.timezone/results-timezone-id)))) + (t/zone-id "Z")))) + (fn [] + (when-let [t (.getTimestamp rs i)] + (t/local-date-time t)))))) + +(defn- date-time->results-local-date-time + "For datetime types with zone info generate LocalDateTime as in that zone. Databricks java driver does not support + setting OffsetDateTime or ZonedDateTime parameters. It uses parameters as in session timezone. Hence, this function + shifts LocalDateTime so wall clock corresponds to Databricks' timezone." + [dt] + (if (instance? LocalDateTime dt) + dt + (let [tz-str (try (qp.timezone/results-timezone-id) + (catch Throwable _ + (log/trace "Failed to get `results-timezone-id`. Using system timezone.") + (qp.timezone/system-timezone-id))) + adjusted-dt (t/with-zone-same-instant (t/zoned-date-time dt) (t/zone-id tz-str))] + (t/local-date-time adjusted-dt)))) + +(defn- set-parameter-to-local-date-time + [driver prepared-statement index object] + ((get-method sql-jdbc.execute/set-parameter [::sql-jdbc.legacy/use-legacy-classes-for-read-and-set LocalDateTime]) + driver prepared-statement index (date-time->results-local-date-time object))) + +(defmethod sql-jdbc.execute/set-parameter [:databricks OffsetDateTime] + [driver prepared-statement index object] + (set-parameter-to-local-date-time driver prepared-statement index object)) + +(defmethod sql-jdbc.execute/set-parameter [:databricks ZonedDateTime] + [driver prepared-statement index object] + (set-parameter-to-local-date-time driver prepared-statement index object)) + +;; +;; `set-parameter` is implmented also for LocalTime and OffsetTime, even though Databricks does not support time types. +;; It enables creation of `attempted-murders` dataset, hence making the driver compatible with more of existing tests. +;; + +(defmethod sql-jdbc.execute/set-parameter [:databricks LocalTime] + [driver prepared-statement index object] + (set-parameter-to-local-date-time driver prepared-statement index + (t/local-date-time (t/local-date 1970 1 1) object))) + +(defmethod sql-jdbc.execute/set-parameter [:databricks OffsetTime] + [driver prepared-statement index object] + (set-parameter-to-local-date-time driver prepared-statement index + (t/local-date-time (t/local-date 1970 1 1) object))) diff --git a/modules/drivers/databricks/src/metabase/driver/hive_like.clj b/modules/drivers/databricks/src/metabase/driver/hive_like.clj new file mode 100644 index 0000000000000000000000000000000000000000..03d863ed0aa7c3cc36b6958c00973370921f5ca8 --- /dev/null +++ b/modules/drivers/databricks/src/metabase/driver/hive_like.clj @@ -0,0 +1,280 @@ +(ns metabase.driver.hive-like + (:require + [buddy.core.codecs :as codecs] + [clojure.string :as str] + [honey.sql :as sql] + [java-time.api :as t] + [metabase.driver :as driver] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] + [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] + [metabase.driver.sql-jdbc.execute.legacy-impl :as sql-jdbc.legacy] + [metabase.driver.sql-jdbc.sync :as sql-jdbc.sync] + [metabase.driver.sql.query-processor :as sql.qp] + [metabase.driver.sql.util :as sql.u] + [metabase.util :as u] + [metabase.util.date-2 :as u.date] + [metabase.util.honey-sql-2 :as h2x]) + (:import + (java.sql ResultSet Types) + (java.time LocalDate OffsetDateTime ZonedDateTime))) + +(set! *warn-on-reflection* true) + +(driver/register! :hive-like + :parent #{:sql-jdbc ::sql-jdbc.legacy/use-legacy-classes-for-read-and-set} + :abstract? true) + +(doseq [[feature supported?] {:now true + :datetime-diff true}] + (defmethod driver/database-supports? [:hive-like feature] [_driver _feature _db] supported?)) + +(defmethod driver/escape-alias :hive-like + [driver s] + ;; replace question marks inside aliases with `_QMARK_`, otherwise Spark SQL will interpret them as JDBC parameter + ;; placeholder (yes, even if the identifier is quoted... (:unamused:) + ;; + ;; `_QMARK_` is kind of arbitrary but that's what [[munge]] does and it seems like it would lead to less potential + ;; name clashes than if we just used underscores. + (let [s (str/replace s #"\?" "_QMARK_")] + ((get-method driver/escape-alias :sql) driver s))) + +(defmethod driver/db-start-of-week :hive-like + [_] + :sunday) + +(defmethod sql-jdbc.conn/data-warehouse-connection-pool-properties :hive-like + [driver database] + ;; The Hive JDBC driver doesn't support `Connection.isValid()`, so we need to supply a test query for c3p0 to use to + ;; validate connections upon checkout. + (merge + ((get-method sql-jdbc.conn/data-warehouse-connection-pool-properties :sql-jdbc) driver database) + {"preferredTestQuery" "SELECT 1"})) + +(defmethod sql-jdbc.sync/database-type->base-type :hive-like + [_ database-type] + (condp re-matches (u/lower-case-en (name database-type)) + #"boolean" :type/Boolean + #"tinyint" :type/Integer + #"smallint" :type/Integer + #"int" :type/Integer + #"bigint" :type/BigInteger + #"float" :type/Float + #"double" :type/Float + #"double precision" :type/Double + #"decimal.*" :type/Decimal + #"char.*" :type/Text + #"varchar.*" :type/Text + #"string.*" :type/Text + #"binary*" :type/* + #"date" :type/Date + #"time" :type/Time + #"timestamp" :type/DateTime + #"interval" :type/* + #"array.*" :type/Array + #"map" :type/Dictionary + #".*" :type/*)) + +(defmethod sql.qp/current-datetime-honeysql-form :hive-like + [_] + (h2x/with-database-type-info :%now "timestamp")) + +(defmethod sql.qp/unix-timestamp->honeysql [:hive-like :seconds] + [_ _ expr] + (h2x/->timestamp [:from_unixtime expr])) + +(defn- date-format [format-str expr] + [:date_format expr (h2x/literal format-str)]) + +(defn- str-to-date [format-str expr] + (h2x/->timestamp [:from_unixtime [:unix_timestamp expr (h2x/literal format-str)]])) + +(defn- trunc-with-format [format-str expr] + (str-to-date format-str (date-format format-str expr))) + +(defmethod sql.qp/date [:hive-like :default] [_ _ expr] expr) +(defmethod sql.qp/date [:hive-like :minute] [_ _ expr] (trunc-with-format "yyyy-MM-dd HH:mm" (h2x/->timestamp expr))) +(defmethod sql.qp/date [:hive-like :minute-of-hour] [_ _ expr] [:minute (h2x/->timestamp expr)]) +(defmethod sql.qp/date [:hive-like :hour] [_ _ expr] (trunc-with-format "yyyy-MM-dd HH" (h2x/->timestamp expr))) +(defmethod sql.qp/date [:hive-like :hour-of-day] [_ _ expr] [:hour (h2x/->timestamp expr)]) +(defmethod sql.qp/date [:hive-like :day] [_ _ expr] (trunc-with-format "yyyy-MM-dd" (h2x/->timestamp expr))) +(defmethod sql.qp/date [:hive-like :day-of-month] [_ _ expr] [:dayofmonth (h2x/->timestamp expr)]) +(defmethod sql.qp/date [:hive-like :day-of-year] [_ _ expr] (h2x/->integer (date-format "D" (h2x/->timestamp expr)))) +(defmethod sql.qp/date [:hive-like :month] [_ _ expr] [:trunc (h2x/->timestamp expr) (h2x/literal :MM)]) +(defmethod sql.qp/date [:hive-like :month-of-year] [_ _ expr] [:month (h2x/->timestamp expr)]) +(defmethod sql.qp/date [:hive-like :quarter-of-year] [_ _ expr] [:quarter (h2x/->timestamp expr)]) +(defmethod sql.qp/date [:hive-like :year] [_ _ expr] [:trunc (h2x/->timestamp expr) (h2x/literal :year)]) + +(def ^:private date-extract-units + "See https://spark.apache.org/docs/3.3.0/api/sql/#extract" + #{:year :y :years :yr :yrs + :yearofweek + :quarter :qtr + :month :mon :mons :months + :week :w :weeks + :day :d :days + :dayofweek :dow + :dayofweek_iso :dow_iso + :doy + :hour :h :hours :hr :hrs + :minute :m :min :mins :minutes + :second :s :sec :seconds :secs}) + +(defn- format-date-extract + [_fn [unit expr]] + {:pre [(contains? date-extract-units unit)]} + (let [[expr-sql & expr-args] (sql/format-expr expr {:nested true})] + (into [(format "extract(%s FROM %s)" (name unit) expr-sql)] + expr-args))) + +(sql/register-fn! ::date-extract #'format-date-extract) + +(defn- format-interval + "Interval actually supports more than just plain numbers, but that's all we currently need. See + https://spark.apache.org/docs/latest/sql-ref-literals.html#interval-literal" + [_fn [amount unit]] + {:pre [(number? amount) + ;; other units are supported too but we're not currently supporting them. + (#{:year :month :week :day :hour :minute :second :millisecond} unit)]} + [(format "(interval '%d' %s)" (long amount) (name unit))]) + +(sql/register-fn! ::interval #'format-interval) + +(defmethod sql.qp/date [:hive-like :day-of-week] + [driver _unit expr] + (sql.qp/adjust-day-of-week driver (-> [::date-extract :dow (h2x/->timestamp expr)] + (h2x/with-database-type-info "integer")))) + +(defmethod sql.qp/date [:hive-like :week] + [driver _unit expr] + (let [week-extract-fn (fn [expr] + (-> [:date_sub + (h2x/+ (h2x/->timestamp expr) + [::interval 1 :day]) + [::date-extract :dow (h2x/->timestamp expr)]] + (h2x/with-database-type-info "timestamp")))] + (sql.qp/adjust-start-of-week driver week-extract-fn expr))) + +(defmethod sql.qp/date [:hive-like :week-of-year-iso] + [_driver _unit expr] + [:weekofyear (h2x/->timestamp expr)]) + +(defmethod sql.qp/date [:hive-like :quarter] + [_driver _unit expr] + [:add_months + [:trunc (h2x/->timestamp expr) (h2x/literal :year)] + (h2x/* (h2x/- [:quarter (h2x/->timestamp expr)] + 1) + 3)]) + +(defmethod sql.qp/->honeysql [:hive-like :replace] + [driver [_ arg pattern replacement]] + [:regexp_replace + (sql.qp/->honeysql driver arg) + (sql.qp/->honeysql driver pattern) + (sql.qp/->honeysql driver replacement)]) + +(defmethod sql.qp/->honeysql [:hive-like :regex-match-first] + [driver [_ arg pattern]] + [:regexp_extract (sql.qp/->honeysql driver arg) (sql.qp/->honeysql driver pattern) 0]) + +(defmethod sql.qp/->honeysql [:hive-like :median] + [driver [_ arg]] + [:percentile (sql.qp/->honeysql driver arg) 0.5]) + +(defmethod sql.qp/->honeysql [:hive-like :percentile] + [driver [_ arg p]] + [:percentile (sql.qp/->honeysql driver arg) (sql.qp/->honeysql driver p)]) + +(defmethod sql.qp/add-interval-honeysql-form :hive-like + [driver hsql-form amount unit] + (if (= unit :quarter) + (recur driver hsql-form (* amount 3) :month) + (h2x/+ (h2x/->timestamp hsql-form) + [::interval amount unit]))) + +(defmethod sql.qp/datetime-diff [:hive-like :year] + [driver _unit x y] + [:div (sql.qp/datetime-diff driver :month x y) 12]) + +(defmethod sql.qp/datetime-diff [:hive-like :quarter] + [driver _unit x y] + [:div (sql.qp/datetime-diff driver :month x y) 3]) + +(defmethod sql.qp/datetime-diff [:hive-like :month] + [_driver _unit x y] + (h2x/->integer [:months_between y x])) + +(defmethod sql.qp/datetime-diff [:hive-like :week] + [_driver _unit x y] + [:div [:datediff y x] 7]) + +(defmethod sql.qp/datetime-diff [:hive-like :day] + [_driver _unit x y] + [:datediff y x]) + +(defmethod sql.qp/datetime-diff [:hive-like :hour] + [driver _unit x y] + [:div (sql.qp/datetime-diff driver :second x y) 3600]) + +(defmethod sql.qp/datetime-diff [:hive-like :minute] + [driver _unit x y] + [:div (sql.qp/datetime-diff driver :second x y) 60]) + +(defmethod sql.qp/datetime-diff [:hive-like :second] + [_driver _unit x y] + [:- [:unix_timestamp y] [:unix_timestamp x]]) + +(def ^:dynamic *inline-param-style* + "How we should include inline params when compiling SQL. `:friendly` (the default) or `:paranoid`. `:friendly` makes a + best-effort attempt to escape strings and generate SQL that is nice to look at, but should not be considered safe + against all SQL injection -- use this for 'convert to SQL' functionality. `:paranoid` hex-encodes strings so SQL + injection is impossible; this isn't nice to look at, so use this for actually running a query." + :friendly) + +(defmethod sql.qp/inline-value [:hive-like String] + [_driver ^String s] + ;; Because Spark SQL doesn't support parameterized queries (e.g. `?`) convert the entire String to hex and decode. + ;; e.g. encode `abc` as `decode(unhex('616263'), 'utf-8')` to prevent SQL injection + (case *inline-param-style* + :friendly (str \' (sql.u/escape-sql s :backslashes) \') + :paranoid (format "decode(unhex('%s'), 'utf-8')" (codecs/bytes->hex (.getBytes s "UTF-8"))))) + +;; Hive/Spark SQL doesn't seem to like DATEs so convert it to a DATETIME first +(defmethod sql.qp/inline-value [:hive-like LocalDate] + [driver t] + (sql.qp/inline-value driver (t/local-date-time t (t/local-time 0)))) + +(defmethod sql.qp/inline-value [:hive-like OffsetDateTime] + [_ t] + (format "to_utc_timestamp('%s', '%s')" (u.date/format-sql (t/local-date-time t)) (t/zone-offset t))) + +(defmethod sql.qp/inline-value [:hive-like ZonedDateTime] + [_ t] + (format "to_utc_timestamp('%s', '%s')" (u.date/format-sql (t/local-date-time t)) (t/zone-id t))) + +;; Hive/Spark SQL doesn't seem to like DATEs so convert it to a DATETIME first +(defmethod sql-jdbc.execute/set-parameter [:hive-like LocalDate] + [driver ps i t] + (sql-jdbc.execute/set-parameter driver ps i (t/local-date-time t (t/local-time 0)))) + +;; TIMEZONE FIXME — not sure what timezone the results actually come back as +;; +;; Also, pretty sure Spark SQL doesn't have a TIME type anyway. +;; https://spark.apache.org/docs/latest/sql-ref-datatypes.html +(defmethod sql-jdbc.execute/read-column-thunk [:hive-like Types/TIME] + [_ ^ResultSet rs _rsmeta ^Integer i] + (fn [] + (when-let [t (.getTimestamp rs i)] + (t/offset-time (t/local-time t) (t/zone-offset 0))))) + +(defmethod sql-jdbc.execute/read-column-thunk [:hive-like Types/DATE] + [_ ^ResultSet rs _rsmeta ^Integer i] + (fn [] + (when-let [s (.getString rs i)] + (u.date/parse s)))) + +(defmethod sql-jdbc.execute/read-column-thunk [:hive-like Types/TIMESTAMP] + [_ ^ResultSet rs _rsmeta ^Integer i] + (fn [] + (when-let [t (.getTimestamp rs i)] + (t/zoned-date-time (t/local-date-time t) (t/zone-id "UTC"))))) diff --git a/modules/drivers/databricks/src/metabase/driver/hive_like/fixed_hive_connection.clj b/modules/drivers/databricks/src/metabase/driver/hive_like/fixed_hive_connection.clj new file mode 100644 index 0000000000000000000000000000000000000000..52ae776359321f83bc33c982e7041cdb3ca7ce74 --- /dev/null +++ b/modules/drivers/databricks/src/metabase/driver/hive_like/fixed_hive_connection.clj @@ -0,0 +1,21 @@ +(ns metabase.driver.hive-like.fixed-hive-connection + (:import + (java.sql Connection ResultSet SQLException) + (java.util Properties) + (org.apache.hive.jdbc HiveConnection))) + +(set! *warn-on-reflection* true) + +(defn fixed-hive-connection + "Subclass of [[org.apache.hive.jdbc.HiveConnection]] has a few special overrides to make things work as expected with + Metabase." + ^Connection [^String url ^Properties properties] + (proxy [HiveConnection] [url properties] + (getHoldability [] + ResultSet/CLOSE_CURSORS_AT_COMMIT) + + (setReadOnly [read-only?] + (when (.isClosed ^Connection this) + (throw (SQLException. "Connection is closed"))) + (when read-only? + (throw (SQLException. "Enabling read-only mode is not supported")))))) diff --git a/modules/drivers/databricks/test/metabase/driver/databricks_test.clj b/modules/drivers/databricks/test/metabase/driver/databricks_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..d408729f82240e517360d4d4c12b6e08bf01d34f --- /dev/null +++ b/modules/drivers/databricks/test/metabase/driver/databricks_test.clj @@ -0,0 +1,241 @@ +(ns metabase.driver.databricks-test + (:require + [clojure.test :refer :all] + [java-time.api :as t] + [metabase.driver :as driver] + [metabase.driver.databricks :as databricks] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] + [metabase.test :as mt] + [toucan2.core :as t2])) + +(deftest ^:parallel sync-test + (testing "`driver/describe-database` implementation returns expected resutls." + (mt/test-driver + :databricks + (is (= {:tables + #{{:name "venues", :schema "test-data", :description nil} + {:name "checkins", :schema "test-data", :description nil} + {:name "users", :schema "test-data", :description nil} + {:name "people", :schema "test-data", :description nil} + {:name "categories", :schema "test-data", :description nil} + {:name "reviews", :schema "test-data", :description nil} + {:name "orders", :schema "test-data", :description nil} + {:name "products", :schema "test-data", :description nil}}} + (driver/describe-database :databricks (mt/db))))))) + +(deftest ^:parallel describe-fields-test + (testing "`describe-fields` returns expected values" + (mt/test-driver + :databricks + (is (= #{{:table-schema "test-data" + :table-name "orders" + :pk? true + :name "id" + :database-type "int" + :database-position 0 + :base-type :type/Integer + :json-unfolding false} + {:table-schema "test-data" + :table-name "orders" + :pk? false + :name "user_id" + :database-type "int" + :database-position 1 + :base-type :type/Integer + :json-unfolding false} + {:table-schema "test-data" + :table-name "orders" + :pk? false + :name "product_id" + :database-type "int" + :database-position 2 + :base-type :type/Integer + :json-unfolding false} + {:table-schema "test-data" + :table-name "orders" + :pk? false + :name "subtotal" + :database-type "double" + :database-position 3 + :base-type :type/Float + :json-unfolding false} + {:table-schema "test-data" + :table-name "orders" + :pk? false + :name "tax" + :database-type "double" + :database-position 4 + :base-type :type/Float + :json-unfolding false} + {:table-schema "test-data" + :table-name "orders" + :pk? false + :name "total" + :database-type "double" + :database-position 5 + :base-type :type/Float + :json-unfolding false} + {:table-schema "test-data" + :table-name "orders" + :pk? false + :name "discount" + :database-type "double" + :database-position 6 + :base-type :type/Float + :json-unfolding false} + {:table-schema "test-data" + :table-name "orders" + :pk? false + :name "created_at" + :database-type "timestamp" + :database-position 7 + :base-type :type/DateTimeWithLocalTZ + :json-unfolding false} + {:table-schema "test-data" + :table-name "orders" + :pk? false + :name "quantity" + :database-type "int" + :database-position 8 + :base-type :type/Integer + :json-unfolding false}} + (reduce conj #{} (driver/describe-fields :databricks (mt/db) {:schema-names ["test-data"] + :table-names ["orders"]}))))))) + +(deftest ^:parallel describe-fks-test + (testing "`describe-fks` returns expected values" + (mt/test-driver + :databricks + (is (= #{{:fk-table-schema "test-data" + :fk-table-name "orders" + :fk-column-name "product_id" + :pk-table-schema "test-data" + :pk-table-name "products" + :pk-column-name "id"} + {:fk-table-schema "test-data" + :fk-table-name "orders" + :fk-column-name "user_id" + :pk-table-schema "test-data" + :pk-table-name "people" + :pk-column-name "id"}} + (reduce conj #{} (driver/describe-fks :databricks (mt/db) {:schema-names ["test-data"] + :table-names ["orders"]}))))))) + +(mt/defdataset dataset-with-ntz + [["table_with_ntz" [{:field-name "timestamp" + :base-type {:native "timestamp_ntz"}}] + [[(t/local-date-time 2024 10 20 10 20 30)]]]]) + +(deftest timestamp-ntz-ignored-test + (mt/test-driver + :databricks + (mt/dataset + dataset-with-ntz + (testing "timestamp column was ignored during sync" + (let [columns (t2/select :model/Field :table_id (t2/select-one-fn :id :model/Table :db_id (mt/id)))] + (is (= 1 (count columns))) + (is (= "id" (:name (first columns))))))))) + +(deftest ^:parallel db-default-timezone-test + (mt/test-driver + :databricks + (testing "`test-data` timezone is `Etc/UTC`" + (is (= "Etc/UTC" (:timezone (mt/db))))))) + +(deftest ^:synchronized date-time->results-local-date-time-test + (mt/test-driver + :databricks + (mt/with-metadata-provider (mt/id) + (mt/with-results-timezone-id "America/Los_Angeles" + (let [expected (t/local-date-time 2024 8 29 10 20 30)] + (testing "LocalDateTime is not modified" + (is (= expected + (#'databricks/date-time->results-local-date-time (t/local-date-time 2024 8 29 10 20 30))))) + (testing "OffsetDateTime is shifted by results timezone" + (is (= expected + (#'databricks/date-time->results-local-date-time (t/offset-date-time 2024 8 29 17 20 30))))) + (testing "ZonedDateTime is shifted by results timezone" + (is (= expected + (#'databricks/date-time->results-local-date-time (t/zoned-date-time 2024 8 29 17 20 30)))))))))) + +(deftest ^:synchronized timezone-in-set-and-read-functions-test + (mt/test-driver + :databricks + ;; + ;; `created_at` value that is filtered for is 2017-04-18T16:53:37.046Z. That corresponds to filters used in query + ;; considering the report timezone. + ;; + ;; This test ensures that `set-parameter` and `read-column-thunk` datetime implementations work correctly, including + ;; helpers as `date-time->results-local-date-time`. + ;; + ;; This functionality is also exercised in general timezone tests, but it is good to be explicit about it here, + ;; as the driver has specific implementation of those methods, dealing with the fact (1) that even values that should + ;; have some timezone info are returned using java.sql.Types/TIMESTAMP (ie. no timezone) and (2) only LocalDateTime + ;; can be used as parameter (ie. no _Offset_ or _Zoned_). + ;; + (mt/with-metadata-provider (mt/id) + (mt/with-report-timezone-id! "America/Los_Angeles" + (testing "local-date-time" + (let [rows (-> (mt/run-mbql-query + people + {:filter [:and + [:>= $created_at (t/local-date-time 2017 4 18 9 0 0)] + [:< $created_at (t/local-date-time 2017 4 18 10 0 0)]]}) + mt/rows)] + (testing "Baseline: only one row is returned" + (is (= 1 (count rows)))) + (testing "`created_at` column has expected value" + (is (= "2017-04-18T09:53:37.046-07:00" + (last (first rows))))))) + (testing "offset-date-time" + (let [rows (-> (mt/run-mbql-query + people + {:filter [:and + [:>= $created_at (t/offset-date-time 2017 4 18 9 0 0 0 (t/zone-offset "-07:00"))] + [:< $created_at (t/offset-date-time 2017 4 18 10 0 0 0 (t/zone-offset "-07:00"))]]}) + mt/rows)] + (testing "Baseline: only one row is returned" + (is (= 1 (count rows)))) + (testing "`created_at` column has expected value" + (is (= "2017-04-18T09:53:37.046-07:00" + (last (first rows))))))) + (testing "zoned-date-time" + (let [rows (-> (mt/run-mbql-query + people + {:filter [:and + [:>= $created_at (t/zoned-date-time 2017 4 18 9 0 0 0 (t/zone-id "America/Los_Angeles"))] + [:< $created_at (t/zoned-date-time 2017 4 18 10 0 0 0 (t/zone-id "America/Los_Angeles"))]]}) + mt/rows)] + (testing "Baseline: only one row is returned" + (is (= 1 (count rows)))) + (testing "`created_at` column has expected value" + (is (= "2017-04-18T09:53:37.046-07:00" + (last (first rows))))))))))) + +(deftest additional-options-test + (mt/test-driver + :databricks + (testing "Additional options are added to :subname key of generated spec" + (is (re-find #";IgnoreTransactions=0$" + (->> {:http-path "p/a/t/h", + :schema-filters-type "inclusion", + :schema-filters-patterns "xix", + :access-token "xixix", + :host "localhost", + :engine "databricks", + :catalog "ccc" + :additional-options ";IgnoreTransactions=0"} + (sql-jdbc.conn/connection-details->spec :databricks) + :subname)))) + (testing "Leading semicolon is added when missing" + (is (re-find #";IgnoreTransactions=0;bla=1$" + (->> {:http-path "p/a/t/h", + :schema-filters-type "inclusion", + :schema-filters-patterns "xix", + :access-token "xixix", + :host "localhost", + :engine "databricks", + :catalog "ccc" + :additional-options "IgnoreTransactions=0;bla=1"} + (sql-jdbc.conn/connection-details->spec :databricks) + :subname)))))) diff --git a/modules/drivers/databricks/test/metabase/test/data/databricks.clj b/modules/drivers/databricks/test/metabase/test/data/databricks.clj new file mode 100644 index 0000000000000000000000000000000000000000..e5df3cf627fe4b1ec01d9a55ffe806ca7cf2dcf0 --- /dev/null +++ b/modules/drivers/databricks/test/metabase/test/data/databricks.clj @@ -0,0 +1,188 @@ +(ns metabase.test.data.databricks + (:require + [clojure.java.jdbc :as jdbc] + [clojure.string :as str] + [metabase.db.query :as mdb.query] + [metabase.driver :as driver] + [metabase.driver.ddl.interface :as ddl.i] + [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] + [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] + [metabase.lib.schema.common :as lib.schema.common] + [metabase.test.data.interface :as tx] + [metabase.test.data.sql :as sql.tx] + [metabase.test.data.sql-jdbc :as sql-jdbc.tx] + [metabase.test.data.sql-jdbc.execute :as execute] + [metabase.test.data.sql-jdbc.load-data :as load-data] + [metabase.test.data.sql.ddl :as ddl] + [metabase.util.log :as log] + [metabase.util.malli :as mu])) + +(set! *warn-on-reflection* true) + +(sql-jdbc.tx/add-test-extensions! :databricks) + +(doseq [[base-type database-type] {:type/BigInteger "BIGINT" + :type/Boolean "BOOLEAN" + :type/Date "DATE" + ;; TODO: `:type/DateTime` and `:type/Time` should be mapped to TIMESTAMP_NTZ. + ;; There is a bug related to syncing columns of that database type tracked + ;; in https://github.com/metabase/metabase/issues/47359. When that is + ;; resolved types should be changed here and Databricks removed from + ;; broken drivers! + ;; Even though Databricks does not support time types, mapping for `:type/Time` + ;; is defined. It makes tests that use dataset with time columns usable with + ;; the driver, assuming those columns are not used. (Eg. `attempted-murders`.) + :type/Time "TIMESTAMP" #_"TIMESTAMP_NTZ" + :type/DateTime "TIMESTAMP" #_"TIMESTAMP_NTZ" + :type/DateTimeWithLocalTZ "TIMESTAMP" + :type/DateTimeWithTZ "TIMESTAMP" + :type/DateTimeWithZoneOffset "TIMESTAMP" + :type/Decimal "DECIMAL" + :type/Float "DOUBLE" + :type/Integer "INTEGER" + :type/Text "STRING"}] + (defmethod sql.tx/field-base-type->sql-type [:databricks base-type] [_driver _base-type] database-type)) + +(doseq [feature [:test/time-type + :test/timestamptz-type + :test/dynamic-dataset-loading]] + (defmethod driver/database-supports? [:databricks feature] + [_driver _feature _database] + false)) + +(defmethod tx/dbdef->connection-details :databricks + [_driver _connection-type {:keys [database-name] :as _dbdef}] + (merge + {:host (tx/db-test-env-var-or-throw :databricks :host) + :token (tx/db-test-env-var-or-throw :databricks :token) + :http-path (tx/db-test-env-var-or-throw :databricks :http-path) + :catalog (tx/db-test-env-var-or-throw :databricks :catalog)} + ;; Databricks' namespace model: catalog, schema, table. With current implementation user can add all schemas + ;; in catalog on one Metabase database connection. Following expression generates schema filters so only one schema + ;; is treated as a Metabase database, for compatibility with existing tests. + (when (string? (not-empty database-name)) + {:schema-filters-type "inclusion" + :schema-filters-patterns database-name}))) + +(defn- existing-databases + "Set of databases that already exist. Used to avoid creating those" + [] + (sql-jdbc.execute/do-with-connection-with-options + :databricks + (->> (tx/dbdef->connection-details :databricks nil nil) + (sql-jdbc.conn/connection-details->spec :databricks)) + nil + (fn [^java.sql.Connection conn] + (into #{} (map :databasename) (jdbc/query {:connection conn} ["SHOW DATABASES;"]))))) + +(defmethod tx/dataset-already-loaded? :databricks + [_driver dbdef] + (contains? (existing-databases) (:database-name dbdef))) + +;; Shared datasets are used in the CI testing as per discussion on QPD weekly. This makes the testing code simpler, +;; CI job faster and avoids reaching the quotas as it happened with Redshift. +;; +;; If you need to add new dataset, rebind the `*allow-database-creation*` and use standard functions, eg.: +;; +;; (mt/test-driver +;; :databricks +;; (mt/dataset <dataset-name> +;; (mt/db))) +;; +;; Dataset can be destroyed using `tx/destroy-db` to remove the data from Databricks instance. +;; [[*allow-database-deletion*]] must be bound to true. Then `t2/delete!` can be used to remove the reference from +;; application database. +(def ^:private ^:dynamic *allow-database-creation* + "Same approach is used in Databricks driver as in Athena. Dataset creation is disabled by default. Datasets are + preloaded in Databricks instance that tests run against. If you need to create new database on the instance, + run your test with this var bound to true." + false) + +(defmethod tx/create-db! :databricks + [driver {:keys [database-name], :as dbdef} & options] + (let [schema (ddl.i/format-name driver database-name)] + (cond + (contains? (existing-databases) schema) + (log/infof "Databricks database %s already exists, skipping creation" (pr-str schema)) + + (not *allow-database-creation*) + (log/fatalf (str "Databricks database creation is disabled: not creating database %s. Tests will likely fail.\n" + "See metabase.test.data.databricks/*allow-database-creation* for more info.") + (pr-str schema)) + + :else + (do + (log/infof "Creating Databricks database %s" (pr-str schema)) + (apply (get-method tx/create-db! :sql-jdbc/test-extensions) driver dbdef options))))) + +(def ^:dynamic *allow-database-deletion* + "This is used to control `tx/destroy-db!`. Disabling database deletion is useful in CI. Specifically, if initial sync + of some test dataset our test code destroys the database. In Databricks we want to avoid this, because datasets are + preloaded and failing sync is likely sync problem. If you need to destroy some dataset bind this to true prior + to calling `tx/destroy-db!`." + false) + +(defmethod tx/destroy-db! :databricks + [driver dbdef] + (if *allow-database-deletion* + ((get-method tx/destroy-db! :sql-jdbc/test-extensions) driver dbdef) + (log/warn "`*allow-database-creation*` is `false`. Database removal is suppressed."))) + +;; Differences to the :sql-jdbc/test-extensions original: false transactions, not using `jdbc/execute!` for +;; timezone setting, not overriding database timezone. +;; +;; Timezone has to be set using `.execute` because `jdbc/execute` seems to expect returned ResultSet. That's not the +;; case on Databricks. +(mu/defmethod load-data/do-insert! :databricks + [driver :- :keyword + ^java.sql.Connection conn :- (lib.schema.common/instance-of-class java.sql.Connection) + table-identifier + rows] + (let [statements (ddl/insert-rows-dml-statements driver table-identifier rows)] + (when-let [set-timezone-format-string #_{:clj-kondo/ignore [:deprecated-var]} (sql-jdbc.execute/set-timezone-sql driver)] + (let [set-timezone-sql (format set-timezone-format-string "'UTC'")] + (log/debugf "Setting timezone to UTC before inserting data with SQL \"%s\"" set-timezone-sql) + (with-open [stmt (.createStatement conn)] + (.execute stmt set-timezone-sql)))) + (doseq [sql-args statements + :let [sql-args (if (string? sql-args) + [sql-args] + sql-args)]] + (assert (string? (first sql-args)) + (format "Bad sql-args: %s" (pr-str sql-args))) + (log/tracef "[insert] %s" (pr-str sql-args)) + (try + (jdbc/execute! {:connection conn :transaction? false} + sql-args + {:set-parameters (fn [stmt params] + (sql-jdbc.execute/set-parameters! driver stmt params))}) + (catch Throwable e + (throw (ex-info (format "INSERT FAILED: %s" (ex-message e)) + {:driver driver + :sql-args (into [(str/split-lines (mdb.query/format-sql (first sql-args)))] + (rest sql-args))} + e))))))) + +;; With jdbc driver version 2.6.40 test data load fails due to ~statment using more parameters than driver's able to +;; handle. `chunk-size` 25 works with 2.6.40, but dataset loading is really slow. +(defmethod load-data/chunk-size :databricks + [_driver _dbdef _tabledef] + 200) + +(defmethod load-data/row-xform :databricks + [_driver _dbdef tabledef] + (load-data/maybe-add-ids-xform tabledef)) + +(defmethod execute/execute-sql! :databricks [& args] + (apply execute/sequentially-execute-sql! args)) + +(defmethod sql.tx/pk-sql-type :databricks [_] "INT") + +(defmethod sql.tx/drop-db-if-exists-sql :databricks + [driver {:keys [database-name]}] + (format "DROP DATABASE IF EXISTS %s CASCADE" (sql.tx/qualify-and-quote driver database-name))) + +(defmethod sql.tx/qualified-name-components :databricks + ([_ db-name] [db-name]) + ([_ db-name table-name] [db-name table-name]) + ([_ db-name table-name field-name] [db-name table-name field-name])) diff --git a/modules/drivers/deps.edn b/modules/drivers/deps.edn index acbdfdd3f4cf13b0ccc42af084d748232b5ae993..a039633de146239871dc7c3336186c4b6393101c 100644 --- a/modules/drivers/deps.edn +++ b/modules/drivers/deps.edn @@ -9,6 +9,7 @@ :deps {metabase/athena {:local/root "athena"} metabase/bigquery-cloud-sdk {:local/root "bigquery-cloud-sdk"} + metabase/databricks {:local/root "databricks"} metabase/druid {:local/root "druid"} metabase/druid-jdbc {:local/root "druid-jdbc"} metabase/mongo {:local/root "mongo"} diff --git a/modules/drivers/druid/src/metabase/driver/druid.clj b/modules/drivers/druid/src/metabase/driver/druid.clj index 584af57040439d3bb6be2b8e1d7f4bc5f6c4d2fe..2a5e7155edd54a7667bd57e20b5307b8521f9cec 100644 --- a/modules/drivers/druid/src/metabase/driver/druid.clj +++ b/modules/drivers/druid/src/metabase/driver/druid.clj @@ -13,9 +13,10 @@ (driver/register! :druid) -(doseq [[feature supported?] {:expression-aggregations true - :schemas false - :set-timezone true}] +(doseq [[feature supported?] {:expression-aggregations true + :schemas false + :set-timezone true + :temporal/requires-default-unit true}] (defmethod driver/database-supports? [:druid feature] [_driver _feature _db] supported?)) (defmethod driver/can-connect? :druid diff --git a/modules/drivers/druid/src/metabase/driver/druid/query_processor.clj b/modules/drivers/druid/src/metabase/driver/druid/query_processor.clj index 5b76ebacc93341d0306509a525b742ff35c903af..95aaba9b7bbdc89a65d2300af36b477b6f9039f9 100644 --- a/modules/drivers/druid/src/metabase/driver/druid/query_processor.clj +++ b/modules/drivers/druid/src/metabase/driver/druid/query_processor.clj @@ -936,9 +936,10 @@ :extractionFn (unit->extraction-fn unit)}) (defmethod ->dimension-rvalue :field - [[_ _ {:keys [temporal-unit]} :as clause]] - (if temporal-unit - (temporal-dimension-rvalue temporal-unit) + [[_ _ {:keys [base-type temporal-unit]} :as clause]] + (if (or temporal-unit + (isa? base-type :type/Temporal)) + (temporal-dimension-rvalue (or temporal-unit :default)) (->rvalue clause))) (defmulti ^:private handle-breakout diff --git a/release/src/release-log-run.ts b/release/src/release-log-run.ts new file mode 100644 index 0000000000000000000000000000000000000000..0178ba908615937b4fc29d99b6aabefc4cee5c4d --- /dev/null +++ b/release/src/release-log-run.ts @@ -0,0 +1,3 @@ +import { generateReleaseLog } from './release-log'; + +generateReleaseLog(); diff --git a/release/src/version-info.ts b/release/src/version-info.ts index eb3dfdb92929ed501ba6c8176793b59481a40496..e81366001d5e7d2a378fe05936348bb15cfdb4ab 100644 --- a/release/src/version-info.ts +++ b/release/src/version-info.ts @@ -11,6 +11,7 @@ import type { import { getVersionType, isEnterpriseVersion, + isPatchVersion, } from "./version-helpers"; const generateVersionInfo = ({ @@ -65,6 +66,13 @@ export const updateVersionInfoLatestJson = ({ existingVersionInfo: VersionInfoFile; rollout?: number; }) => { + if (isPatchVersion(newLatestVersion)) { + // currently we don't support patch versions as latest, or store them + // in the version-info.json + console.warn(`Version ${newLatestVersion} is a patch version, skipping`); + return existingVersionInfo; + } + if (existingVersionInfo.latest.version === newLatestVersion) { console.warn(`Version ${newLatestVersion} already latest, updating rollout % only`); return { diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-19557ZnrWiDgG4h4cKxF_databases.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-19557ZnrWiDgG4h4cKxF_databases.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-19557ZnrWiDgG4h4cKxF_databases.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-19557ZnrWiDgG4h4cKxF_databases.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-_XgVaPuz7MY8jlG0wSEC_question_performance.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-_XgVaPuz7MY8jlG0wSEC_question_performance.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-_XgVaPuz7MY8jlG0wSEC_question_performance.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-_XgVaPuz7MY8jlG0wSEC_question_performance.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-fFVxT-GLz6WO41bvw-Ar_dashboards_without_recent_views.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-fFVxT-GLz6WO41bvw-Ar_dashboards_without_recent_views.yaml similarity index 58% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-fFVxT-GLz6WO41bvw-Ar_dashboards_without_recent_views.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-fFVxT-GLz6WO41bvw-Ar_dashboards_without_recent_views.yaml index 64f2550c1b3000508ff4e1ffe9ddbbc249e21667..26c5fb7fdd68a13d41902bd751d73db1b0e8ec0c 100644 --- a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-fFVxT-GLz6WO41bvw-Ar_dashboards_without_recent_views.yaml +++ b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-fFVxT-GLz6WO41bvw-Ar_dashboards_without_recent_views.yaml @@ -1,5 +1,5 @@ name: Dashboards without recent views -description: null +description: '' entity_id: -fFVxT-GLz6WO41bvw-Ar created_at: '2023-11-01T11:52:53.834059Z' creator_id: internal@metabase.com @@ -53,6 +53,7 @@ dataset_query: - base-type: type/Text - - expression - Days since last view + - base-type: type/Integer filter: - and - - = @@ -66,6 +67,7 @@ dataset_query: - - not-null - - expression - Days since last view + - base-type: type/Integer - - = - - field - - Internal Metabase Database @@ -99,9 +101,113 @@ dataset_query: - - desc - - expression - Days since last view + - base-type: type/Integer source-table: AxSackBiyXVRUzM_TyyQY type: query -result_metadata: null +result_metadata: +- base_type: type/Integer + coercion_strategy: null + database_type: int4 + description: null + display_name: Entity ID + effective_type: type/Integer + field_ref: + - field + - - Internal Metabase Database + - public + - v_content + - entity_id + - base-type: type/Integer + fk_target_field_id: null + id: + - Internal Metabase Database + - public + - v_content + - entity_id + name: entity_id + nfc_path: null + parent_id: null + position: 0 + semantic_type: type/PK + settings: null + source: fields + table_id: + - Internal Metabase Database + - public + - v_content + visibility_type: normal +- base_type: type/DateTimeWithLocalTZ + coercion_strategy: null + database_type: timestamptz + description: null + display_name: Created At + effective_type: type/DateTimeWithLocalTZ + field_ref: + - field + - - Internal Metabase Database + - public + - v_content + - created_at + - base-type: type/DateTimeWithLocalTZ + temporal-unit: default + fk_target_field_id: null + id: + - Internal Metabase Database + - public + - v_content + - created_at + name: created_at + nfc_path: null + parent_id: null + position: 3 + semantic_type: type/CreationTimestamp + settings: null + source: fields + table_id: + - Internal Metabase Database + - public + - v_content + unit: default + visibility_type: normal +- base_type: type/Text + coercion_strategy: null + database_type: varchar + description: null + display_name: Name + effective_type: type/Text + field_ref: + - field + - - Internal Metabase Database + - public + - v_content + - name + - base-type: type/Text + fk_target_field_id: null + id: + - Internal Metabase Database + - public + - v_content + - name + name: name + nfc_path: null + parent_id: null + position: 6 + semantic_type: type/Name + settings: null + source: fields + table_id: + - Internal Metabase Database + - public + - v_content + visibility_type: normal +- base_type: type/Float + display_name: Days since last view + expression_name: Days since last view + field_ref: + - expression + - Days since last view + name: Days since last view + source: fields visualization_settings: column_settings: '["ref",["expression","Days since last view"]]': @@ -116,6 +222,7 @@ visualization_settings: link_url: /dashboard/{{entity_id}} view_as: link table.cell_column: Days since last view + table.pivot_column: created_at serdes/meta: - id: -fFVxT-GLz6WO41bvw-Ar label: dashboards_without_recent_views diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-lNDM3tJmuL5ltGbX0oyT_activity_log.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-lNDM3tJmuL5ltGbX0oyT_activity_log.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/-lNDM3tJmuL5ltGbX0oyT_activity_log.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/-lNDM3tJmuL5ltGbX0oyT_activity_log.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/0wVIfjBJWclD0lKeABYYl_people.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/0wVIfjBJWclD0lKeABYYl_people.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/0wVIfjBJWclD0lKeABYYl_people.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/0wVIfjBJWclD0lKeABYYl_people.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/3CtVT_JDxNvMM5aFLtEHR_is_admin_.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/3CtVT_JDxNvMM5aFLtEHR_is_admin_.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/3CtVT_JDxNvMM5aFLtEHR_is_admin_.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/3CtVT_JDxNvMM5aFLtEHR_is_admin_.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/4DlO-I7ry2OaVQy7-RGPU_median_load_time_in_seconds_for_questions_in_this_dashboard.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/4DlO-I7ry2OaVQy7-RGPU_median_load_time_in_seconds_for_questions_in_this_dashboard.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/4DlO-I7ry2OaVQy7-RGPU_median_load_time_in_seconds_for_questions_in_this_dashboard.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/4DlO-I7ry2OaVQy7-RGPU_median_load_time_in_seconds_for_questions_in_this_dashboard.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/57V11my5MYVnSlaJYM8cX_most_viewed_models.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/57V11my5MYVnSlaJYM8cX_most_viewed_models.yaml similarity index 90% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/57V11my5MYVnSlaJYM8cX_most_viewed_models.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/57V11my5MYVnSlaJYM8cX_most_viewed_models.yaml index e2a9b777b29ee6e026d26f3699e8d7e6c7b89fb7..74be05437e949032f9bb2de11ddf2bcee366e017 100644 --- a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/57V11my5MYVnSlaJYM8cX_most_viewed_models.yaml +++ b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/57V11my5MYVnSlaJYM8cX_most_viewed_models.yaml @@ -176,17 +176,8 @@ result_metadata: - v_content - name name: name - nfc_path: null - parent_id: null - position: 6 semantic_type: type/Name settings: null - source: breakout - source_alias: Content - Entity Qualified - table_id: - - Internal Metabase Database - - public - - v_content visibility_type: normal - base_type: type/Integer coercion_strategy: null @@ -208,17 +199,8 @@ result_metadata: - v_content - entity_id name: entity_id - nfc_path: null - parent_id: null - position: 0 semantic_type: type/PK settings: null - source: breakout - source_alias: Content - Entity Qualified - table_id: - - Internal Metabase Database - - public - - v_content visibility_type: normal - base_type: type/Text coercion_strategy: null @@ -240,17 +222,8 @@ result_metadata: - v_databases - name name: name_2 - nfc_path: null - parent_id: null - position: 4 semantic_type: type/Name settings: null - source: breakout - source_alias: V Databases - Question Database - table_id: - - Internal Metabase Database - - public - - v_databases visibility_type: normal - base_type: type/Integer coercion_strategy: null @@ -272,27 +245,17 @@ result_metadata: - v_databases - entity_id name: entity_id_2 - nfc_path: null - parent_id: null - position: 0 semantic_type: type/PK settings: null - source: breakout - source_alias: V Databases - Question Database - table_id: - - Internal Metabase Database - - public - - v_databases visibility_type: normal -- aggregation_index: 0 - base_type: type/Integer +- base_type: type/BigInteger display_name: Count + effective_type: type/BigInteger field_ref: - aggregation - 0 name: count semantic_type: type/Quantity - source: aggregation visualization_settings: column_settings: '["name","count"]': @@ -308,7 +271,7 @@ visualization_settings: view_as: link '["name","name_2"]': column_title: Database - link_url: /browse/{{entity_id_2}} + link_url: /browse/databases/{{entity_id_2}} view_as: link graph.dimensions: - name diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/5EsTAgs6Uu_xz69TsrUJ4_last_viewed_models.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/5EsTAgs6Uu_xz69TsrUJ4_last_viewed_models.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/5EsTAgs6Uu_xz69TsrUJ4_last_viewed_models.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/5EsTAgs6Uu_xz69TsrUJ4_last_viewed_models.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/5HQS2xXAPF4hOFudut_Tg_last_viewed_dashboards.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/5HQS2xXAPF4hOFudut_Tg_last_viewed_dashboards.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/5HQS2xXAPF4hOFudut_Tg_last_viewed_dashboards.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/5HQS2xXAPF4hOFudut_Tg_last_viewed_dashboards.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/5gASJxNKdQCmkiGXb5kRP_dashboards_consuming_most_resources.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/5gASJxNKdQCmkiGXb5kRP_dashboards_consuming_most_resources.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/5gASJxNKdQCmkiGXb5kRP_dashboards_consuming_most_resources.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/5gASJxNKdQCmkiGXb5kRP_dashboards_consuming_most_resources.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/5ojUtU9iE-DCggHdFPIll_dashboard_subscriptions.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/5ojUtU9iE-DCggHdFPIll_dashboard_subscriptions.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/5ojUtU9iE-DCggHdFPIll_dashboard_subscriptions.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/5ojUtU9iE-DCggHdFPIll_dashboard_subscriptions.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/613VT_7b325t9FBAJZcU8_question_views_per_month.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/613VT_7b325t9FBAJZcU8_question_views_per_month.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/613VT_7b325t9FBAJZcU8_question_views_per_month.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/613VT_7b325t9FBAJZcU8_question_views_per_month.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/7FLoE9kUELG4Ess6DsSEY_last_downloads.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/7FLoE9kUELG4Ess6DsSEY_last_downloads.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/7FLoE9kUELG4Ess6DsSEY_last_downloads.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/7FLoE9kUELG4Ess6DsSEY_last_downloads.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/95Om4AllyfyB5wtl6TeGi_last_viewed_tables.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/95Om4AllyfyB5wtl6TeGi_last_viewed_tables.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/95Om4AllyfyB5wtl6TeGi_last_viewed_tables.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/95Om4AllyfyB5wtl6TeGi_last_viewed_tables.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/9shJ0y29V5o1lOSDL4ImJ_most_viewed_questions.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/9shJ0y29V5o1lOSDL4ImJ_most_viewed_questions.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/9shJ0y29V5o1lOSDL4ImJ_most_viewed_questions.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/9shJ0y29V5o1lOSDL4ImJ_most_viewed_questions.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/AxSackBiyXVRUzM_TyyQY_content.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/AxSackBiyXVRUzM_TyyQY_content.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/AxSackBiyXVRUzM_TyyQY_content.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/AxSackBiyXVRUzM_TyyQY_content.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Bp2r19P5a9HjDTR4-VuZa_subscriptions_on_this_dashboard.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Bp2r19P5a9HjDTR4-VuZa_subscriptions_on_this_dashboard.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Bp2r19P5a9HjDTR4-VuZa_subscriptions_on_this_dashboard.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Bp2r19P5a9HjDTR4-VuZa_subscriptions_on_this_dashboard.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/DnNyT6EtIn-Zx8bHRFHbV_questions_created_last_week.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/DnNyT6EtIn-Zx8bHRFHbV_questions_created_last_week.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/DnNyT6EtIn-Zx8bHRFHbV_questions_created_last_week.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/DnNyT6EtIn-Zx8bHRFHbV_questions_created_last_week.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/DuL1yrlkmnqOgz4drGiG4_users_consuming_most_resources.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/DuL1yrlkmnqOgz4drGiG4_users_consuming_most_resources.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/DuL1yrlkmnqOgz4drGiG4_users_consuming_most_resources.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/DuL1yrlkmnqOgz4drGiG4_users_consuming_most_resources.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/FUuJSuFo7wM6EoddmNsHf_most_active_viewers.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/FUuJSuFo7wM6EoddmNsHf_most_active_viewers.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/FUuJSuFo7wM6EoddmNsHf_most_active_viewers.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/FUuJSuFo7wM6EoddmNsHf_most_active_viewers.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Fnu5Kh0gYPN6P8A_fvICu__to_be_replaced_.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Fnu5Kh0gYPN6P8A_fvICu__to_be_replaced_.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Fnu5Kh0gYPN6P8A_fvICu__to_be_replaced_.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Fnu5Kh0gYPN6P8A_fvICu__to_be_replaced_.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/G7fFejjb7cgwYlUXSvf3K_dashboards_created_last_week.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/G7fFejjb7cgwYlUXSvf3K_dashboards_created_last_week.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/G7fFejjb7cgwYlUXSvf3K_dashboards_created_last_week.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/G7fFejjb7cgwYlUXSvf3K_dashboards_created_last_week.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/HyPss6g8k1a6kSx_ErtVg_weekly_cache_hit_rate.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/HyPss6g8k1a6kSx_ErtVg_weekly_cache_hit_rate.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/HyPss6g8k1a6kSx_ErtVg_weekly_cache_hit_rate.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/HyPss6g8k1a6kSx_ErtVg_weekly_cache_hit_rate.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ItdtatOMd0uUEpvx7tDAC_most_viewed_tables.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ItdtatOMd0uUEpvx7tDAC_most_viewed_tables.yaml similarity index 91% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ItdtatOMd0uUEpvx7tDAC_most_viewed_tables.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ItdtatOMd0uUEpvx7tDAC_most_viewed_tables.yaml index 8e870ee1d334d6180b985178ec7ad15926434d76..01b640a3b7b6731dc1e8ba385a8b0d29128525ac 100644 --- a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ItdtatOMd0uUEpvx7tDAC_most_viewed_tables.yaml +++ b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ItdtatOMd0uUEpvx7tDAC_most_viewed_tables.yaml @@ -264,16 +264,12 @@ visualization_settings: show_mini_bar: true '["name","name"]': column_title: Table name - link_url: /question#?db={{entity_id_2}}&table={{entity_id}} + link_url: /question#?db={{entity_id}}&table={{entity_id_2}} view_as: link '["name","name_2"]': column_title: Database - link_url: /browse/{{entity_id}} + link_url: /browse/databases/{{entity_id}} view_as: link - ? '["ref",["field",["Internal Metabase Database","public","v_content","entity_id"],{"base-type":"type/Integer","join-alias":"Content - Entity Qualified"}]]' - : column_title: Dashboard ID - ? '["ref",["field",["Internal Metabase Database","public","v_content","name"],{"base-type":"type/Text","join-alias":"Content - Entity Qualified"}]]' - : column_title: Dashboard name graph.dimensions: - name graph.metrics: @@ -317,15 +313,7 @@ visualization_settings: - 0 name: count - enabled: false - fieldRef: - - field - - - Internal Metabase Database - - public - - v_tables - - entity_id - - base-type: type/Integer - join-alias: Tables - Entity Qualified - name: false_2 + name: entity_id_2 table.pivot: false table.pivot_column: name_2 serdes/meta: diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/JPERH6xYVcj3m2Zw0YVY1_active_alerts.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/JPERH6xYVcj3m2Zw0YVY1_active_alerts.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/JPERH6xYVcj3m2Zw0YVY1_active_alerts.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/JPERH6xYVcj3m2Zw0YVY1_active_alerts.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/KXNGLyYyS1pO_9wMguHIB_total_time_spent_on_sync_per_day.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/KXNGLyYyS1pO_9wMguHIB_total_time_spent_on_sync_per_day.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/KXNGLyYyS1pO_9wMguHIB_total_time_spent_on_sync_per_day.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/KXNGLyYyS1pO_9wMguHIB_total_time_spent_on_sync_per_day.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/LN9YtKcxZk_kybyRjJx1J_total_time_looking_for_field_values_per_day.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/LN9YtKcxZk_kybyRjJx1J_total_time_looking_for_field_values_per_day.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/LN9YtKcxZk_kybyRjJx1J_total_time_looking_for_field_values_per_day.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/LN9YtKcxZk_kybyRjJx1J_total_time_looking_for_field_values_per_day.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/LXu_IZa1EDg3QOhlwunGy_sso_source.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/LXu_IZa1EDg3QOhlwunGy_sso_source.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/LXu_IZa1EDg3QOhlwunGy_sso_source.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/LXu_IZa1EDg3QOhlwunGy_sso_source.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/MOAq881VSlM2BhVUv5e_K_last_activity_on_question.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/MOAq881VSlM2BhVUv5e_K_last_activity_on_question.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/MOAq881VSlM2BhVUv5e_K_last_activity_on_question.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/MOAq881VSlM2BhVUv5e_K_last_activity_on_question.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/MUXrck2HHcd2TIRuPfK0v_most_viewed_dashboards.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/MUXrck2HHcd2TIRuPfK0v_most_viewed_dashboards.yaml similarity index 92% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/MUXrck2HHcd2TIRuPfK0v_most_viewed_dashboards.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/MUXrck2HHcd2TIRuPfK0v_most_viewed_dashboards.yaml index 5d5b3e09eae77b7ac3825f28878c96938e876a2e..b90b417ee05e134a682d17a047e004c0fd130bdf 100644 --- a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/MUXrck2HHcd2TIRuPfK0v_most_viewed_dashboards.yaml +++ b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/MUXrck2HHcd2TIRuPfK0v_most_viewed_dashboards.yaml @@ -129,17 +129,8 @@ result_metadata: - v_content - name name: name - nfc_path: null - parent_id: null - position: 6 semantic_type: type/Name settings: null - source: breakout - source_alias: Content - Entity Qualified - table_id: - - Internal Metabase Database - - public - - v_content visibility_type: normal - base_type: type/Integer coercion_strategy: null @@ -161,27 +152,17 @@ result_metadata: - v_content - entity_id name: entity_id - nfc_path: null - parent_id: null - position: 0 semantic_type: type/PK settings: null - source: breakout - source_alias: Content - Entity Qualified - table_id: - - Internal Metabase Database - - public - - v_content visibility_type: normal -- aggregation_index: 0 - base_type: type/Integer +- base_type: type/BigInteger display_name: Count + effective_type: type/BigInteger field_ref: - aggregation - 0 name: count semantic_type: type/Quantity - source: aggregation visualization_settings: column_settings: '["name","count"]': @@ -226,6 +207,8 @@ visualization_settings: - aggregation - 0 name: count + table.pivot: false + table.pivot_column: name serdes/meta: - id: MUXrck2HHcd2TIRuPfK0v label: most_viewed_dashboards diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/OCkxXZM5KPJ9zOjYVEnqY_slowest_questions.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/OCkxXZM5KPJ9zOjYVEnqY_slowest_questions.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/OCkxXZM5KPJ9zOjYVEnqY_slowest_questions.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/OCkxXZM5KPJ9zOjYVEnqY_slowest_questions.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/P6Ityjj7igswKh4NgZZjz_view_log.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/P6Ityjj7igswKh4NgZZjz_view_log.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/P6Ityjj7igswKh4NgZZjz_view_log.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/P6Ityjj7igswKh4NgZZjz_view_log.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/PKhlEfegdbTozSMfj0aLB_system_tasks.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/PKhlEfegdbTozSMfj0aLB_system_tasks.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/PKhlEfegdbTozSMfj0aLB_system_tasks.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/PKhlEfegdbTozSMfj0aLB_system_tasks.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/PkIueKBME3DfRFwBsYjuE_list_of_dashboards.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/PkIueKBME3DfRFwBsYjuE_list_of_dashboards.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/PkIueKBME3DfRFwBsYjuE_list_of_dashboards.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/PkIueKBME3DfRFwBsYjuE_list_of_dashboards.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/QOtZaiTLf2FDD4AT6Oinb_query_log.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/QOtZaiTLf2FDD4AT6Oinb_query_log.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/QOtZaiTLf2FDD4AT6Oinb_query_log.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/QOtZaiTLf2FDD4AT6Oinb_query_log.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/T1vsVJUAfg30Fqiw4Dywa_most_viewed_cards.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/T1vsVJUAfg30Fqiw4Dywa_most_viewed_cards.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/T1vsVJUAfg30Fqiw4Dywa_most_viewed_cards.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/T1vsVJUAfg30Fqiw4Dywa_most_viewed_cards.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/UEK1JfYa3ZBp6W7F-Ui5i_questions_without_recent_views.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/UEK1JfYa3ZBp6W7F-Ui5i_questions_without_recent_views.yaml similarity index 64% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/UEK1JfYa3ZBp6W7F-Ui5i_questions_without_recent_views.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/UEK1JfYa3ZBp6W7F-Ui5i_questions_without_recent_views.yaml index 8c90a732761d290e969576cab2237a9679dfe699..cb8f3cc3a7e27df03a5055d14cda969c7220329e 100644 --- a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/UEK1JfYa3ZBp6W7F-Ui5i_questions_without_recent_views.yaml +++ b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/UEK1JfYa3ZBp6W7F-Ui5i_questions_without_recent_views.yaml @@ -1,5 +1,5 @@ name: Questions without recent views -description: null +description: '' entity_id: UEK1JfYa3ZBp6W7F-Ui5i created_at: '2023-11-01T11:53:36.842975Z' creator_id: internal@metabase.com @@ -53,6 +53,7 @@ dataset_query: - base-type: type/Text - - expression - Days since last view + - base-type: type/Integer filter: - and - - = @@ -66,6 +67,7 @@ dataset_query: - - not-null - - expression - Days since last view + - base-type: type/Integer - - = - - field - - Internal Metabase Database @@ -99,9 +101,86 @@ dataset_query: - - desc - - expression - Days since last view + - base-type: type/Integer source-table: AxSackBiyXVRUzM_TyyQY type: query -result_metadata: null +result_metadata: +- base_type: type/Integer + coercion_strategy: null + description: null + display_name: Entity ID + effective_type: type/Integer + field_ref: + - field + - - Internal Metabase Database + - public + - v_content + - entity_id + - base-type: type/Integer + fk_target_field_id: null + id: + - Internal Metabase Database + - public + - v_content + - entity_id + name: entity_id + semantic_type: type/PK + settings: null + visibility_type: normal +- base_type: type/DateTimeWithLocalTZ + coercion_strategy: null + description: null + display_name: Created At + effective_type: type/DateTimeWithLocalTZ + field_ref: + - field + - - Internal Metabase Database + - public + - v_content + - created_at + - base-type: type/DateTimeWithLocalTZ + temporal-unit: default + fk_target_field_id: null + id: + - Internal Metabase Database + - public + - v_content + - created_at + name: created_at + semantic_type: type/CreationTimestamp + settings: null + unit: default + visibility_type: normal +- base_type: type/Text + coercion_strategy: null + description: null + display_name: Name + effective_type: type/Text + field_ref: + - field + - - Internal Metabase Database + - public + - v_content + - name + - base-type: type/Text + fk_target_field_id: null + id: + - Internal Metabase Database + - public + - v_content + - name + name: name + semantic_type: type/Name + settings: null + visibility_type: normal +- base_type: type/Integer + display_name: Days since last view + effective_type: type/Integer + field_ref: + - expression + - Days since last view + name: Days since last view + semantic_type: null visualization_settings: column_settings: '["ref",["expression","Days since last view"]]': @@ -116,6 +195,7 @@ visualization_settings: link_url: /question/{{entity_id}} view_as: link table.cell_column: Days since last view + table.pivot_column: created_at serdes/meta: - id: UEK1JfYa3ZBp6W7F-Ui5i label: questions_without_recent_views diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Vm-4GYORvVbGu9jHVLfg1_question_views_per_month.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Vm-4GYORvVbGu9jHVLfg1_question_views_per_month.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Vm-4GYORvVbGu9jHVLfg1_question_views_per_month.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Vm-4GYORvVbGu9jHVLfg1_question_views_per_month.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/W34-Nzp-T3-SPdZwGvLeB_dashboard_metadata.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/W34-Nzp-T3-SPdZwGvLeB_dashboard_metadata.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/W34-Nzp-T3-SPdZwGvLeB_dashboard_metadata.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/W34-Nzp-T3-SPdZwGvLeB_dashboard_metadata.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/WlQ-en2l-iRRCvO2-v5j1_recent_activity.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/WlQ-en2l-iRRCvO2-v5j1_recent_activity.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/WlQ-en2l-iRRCvO2-v5j1_recent_activity.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/WlQ-en2l-iRRCvO2-v5j1_recent_activity.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/WoQnk12nwOaJ1pcb1wsr4_new_dashboards_per_month.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/WoQnk12nwOaJ1pcb1wsr4_new_dashboards_per_month.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/WoQnk12nwOaJ1pcb1wsr4_new_dashboards_per_month.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/WoQnk12nwOaJ1pcb1wsr4_new_dashboards_per_month.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/XIiIsCNMk9gg-eO2hkl8S_dashboard_views_per_month.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/XIiIsCNMk9gg-eO2hkl8S_dashboard_views_per_month.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/XIiIsCNMk9gg-eO2hkl8S_dashboard_views_per_month.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/XIiIsCNMk9gg-eO2hkl8S_dashboard_views_per_month.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/XLzhOnMmk3DkefFAMx_Vg_question_metadata.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/XLzhOnMmk3DkefFAMx_Vg_question_metadata.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/XLzhOnMmk3DkefFAMx_Vg_question_metadata.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/XLzhOnMmk3DkefFAMx_Vg_question_metadata.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Y0ZykgQ64HHwAW_MYx-dW_first_login_date.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Y0ZykgQ64HHwAW_MYx-dW_first_login_date.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Y0ZykgQ64HHwAW_MYx-dW_first_login_date.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Y0ZykgQ64HHwAW_MYx-dW_first_login_date.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/YiHMsA2dv3iQob11DLTIz_alerts_on_this_question.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/YiHMsA2dv3iQob11DLTIz_alerts_on_this_question.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/YiHMsA2dv3iQob11DLTIz_alerts_on_this_question.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/YiHMsA2dv3iQob11DLTIz_alerts_on_this_question.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/YxCC6fQfHOPVvBtUkjFCN_questions_that_don_t_belong_to_a_dashboard.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/YxCC6fQfHOPVvBtUkjFCN_questions_that_don_t_belong_to_a_dashboard.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/YxCC6fQfHOPVvBtUkjFCN_questions_that_don_t_belong_to_a_dashboard.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/YxCC6fQfHOPVvBtUkjFCN_questions_that_don_t_belong_to_a_dashboard.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Z18i0B5CgOe66-YScAZdx_questions_created_per_month.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Z18i0B5CgOe66-YScAZdx_questions_created_per_month.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/Z18i0B5CgOe66-YScAZdx_questions_created_per_month.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/Z18i0B5CgOe66-YScAZdx_questions_created_per_month.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ZmDKwRQBuRwfXfGipg7-k_fields.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ZmDKwRQBuRwfXfGipg7-k_fields.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ZmDKwRQBuRwfXfGipg7-k_fields.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ZmDKwRQBuRwfXfGipg7-k_fields.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/_lfXwss_MckZBidbcJsgk_most_viewed_questions.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/_lfXwss_MckZBidbcJsgk_most_viewed_questions.yaml similarity index 92% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/_lfXwss_MckZBidbcJsgk_most_viewed_questions.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/_lfXwss_MckZBidbcJsgk_most_viewed_questions.yaml index a4758ce38bb9640afa2bda4a61e6c40ba5e002f8..838c0142d4b3911bf82a61bafb3ca54fdebb5ad1 100644 --- a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/_lfXwss_MckZBidbcJsgk_most_viewed_questions.yaml +++ b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/_lfXwss_MckZBidbcJsgk_most_viewed_questions.yaml @@ -139,17 +139,8 @@ result_metadata: - v_content - name name: name - nfc_path: null - parent_id: null - position: 6 semantic_type: type/Name settings: null - source: breakout - source_alias: Content - Entity Qualified - table_id: - - Internal Metabase Database - - public - - v_content visibility_type: normal - base_type: type/Integer coercion_strategy: null @@ -171,27 +162,17 @@ result_metadata: - v_content - entity_id name: entity_id - nfc_path: null - parent_id: null - position: 0 semantic_type: type/PK settings: null - source: breakout - source_alias: Content - Entity Qualified - table_id: - - Internal Metabase Database - - public - - v_content visibility_type: normal -- aggregation_index: 0 - base_type: type/Integer +- base_type: type/BigInteger display_name: Count + effective_type: type/BigInteger field_ref: - aggregation - 0 name: count semantic_type: type/Quantity - source: aggregation visualization_settings: column_settings: '["name","count"]': @@ -237,6 +218,8 @@ visualization_settings: - aggregation - 0 name: count + table.pivot: false + table.pivot_column: name serdes/meta: - id: _lfXwss_MckZBidbcJsgk label: most_viewed_questions diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/_p6UGMWOQn5-yf03uGsaN_most_active_people_on_this_dashboard.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/_p6UGMWOQn5-yf03uGsaN_most_active_people_on_this_dashboard.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/_p6UGMWOQn5-yf03uGsaN_most_active_people_on_this_dashboard.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/_p6UGMWOQn5-yf03uGsaN_most_active_people_on_this_dashboard.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/aC-dZfkgNTINqvVTNKfCV_90th_percentile_of_query_run_time__seconds_.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/aC-dZfkgNTINqvVTNKfCV_90th_percentile_of_query_run_time__seconds_.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/aC-dZfkgNTINqvVTNKfCV_90th_percentile_of_query_run_time__seconds_.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/aC-dZfkgNTINqvVTNKfCV_90th_percentile_of_query_run_time__seconds_.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ezcT88-OmH-5HFOFNqmX7_last_viewed_questions.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ezcT88-OmH-5HFOFNqmX7_last_viewed_questions.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ezcT88-OmH-5HFOFNqmX7_last_viewed_questions.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ezcT88-OmH-5HFOFNqmX7_last_viewed_questions.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/fBr2cU-86t14YWbaS3r6-_most_viewed_dashboards.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/fBr2cU-86t14YWbaS3r6-_most_viewed_dashboards.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/fBr2cU-86t14YWbaS3r6-_most_viewed_dashboards.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/fBr2cU-86t14YWbaS3r6-_most_viewed_dashboards.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/fwkC3nWB_uouifA5kkaBp_alerts.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/fwkC3nWB_uouifA5kkaBp_alerts.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/fwkC3nWB_uouifA5kkaBp_alerts.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/fwkC3nWB_uouifA5kkaBp_alerts.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/g-99yx3A8bwFtrfuWdIgb_active_dashboard_subscriptions.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/g-99yx3A8bwFtrfuWdIgb_active_dashboard_subscriptions.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/g-99yx3A8bwFtrfuWdIgb_active_dashboard_subscriptions.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/g-99yx3A8bwFtrfuWdIgb_active_dashboard_subscriptions.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/gTeYI2eJtQUh63sZurc3z_last_queries.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/gTeYI2eJtQUh63sZurc3z_last_queries.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/gTeYI2eJtQUh63sZurc3z_last_queries.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/gTeYI2eJtQUh63sZurc3z_last_queries.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/gYt6bo9DFKz6tSeDLxYVs_active_users_last_week.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/gYt6bo9DFKz6tSeDLxYVs_active_users_last_week.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/gYt6bo9DFKz6tSeDLxYVs_active_users_last_week.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/gYt6bo9DFKz6tSeDLxYVs_active_users_last_week.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/hFpp3c-7Y-CtMOrs3zeyn_last_login_date.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/hFpp3c-7Y-CtMOrs3zeyn_last_login_date.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/hFpp3c-7Y-CtMOrs3zeyn_last_login_date.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/hFpp3c-7Y-CtMOrs3zeyn_last_login_date.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/kd1-A_wWOvlSuLDTFUpyb_question_views_per_week.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/kd1-A_wWOvlSuLDTFUpyb_question_views_per_week.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/kd1-A_wWOvlSuLDTFUpyb_question_views_per_week.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/kd1-A_wWOvlSuLDTFUpyb_question_views_per_week.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/lTp-ATFsCUFEr9I0fMEaO_group_members.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/lTp-ATFsCUFEr9I0fMEaO_group_members.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/lTp-ATFsCUFEr9I0fMEaO_group_members.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/lTp-ATFsCUFEr9I0fMEaO_group_members.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/lh0sbjKcm9BhiiHPpPxRa_most_viewed_dashboards.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/lh0sbjKcm9BhiiHPpPxRa_most_viewed_dashboards.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/lh0sbjKcm9BhiiHPpPxRa_most_viewed_dashboards.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/lh0sbjKcm9BhiiHPpPxRa_most_viewed_dashboards.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/mm8k37Rgk7ChMNwAUdXK2_slowest_dashboards.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/mm8k37Rgk7ChMNwAUdXK2_slowest_dashboards.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/mm8k37Rgk7ChMNwAUdXK2_slowest_dashboards.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/mm8k37Rgk7ChMNwAUdXK2_slowest_dashboards.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/p-z74CSp1IOs1rXwAcwcS_most_active_creators.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/p-z74CSp1IOs1rXwAcwcS_most_active_creators.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/p-z74CSp1IOs1rXwAcwcS_most_active_creators.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/p-z74CSp1IOs1rXwAcwcS_most_active_creators.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/p8ZML2ebd3ItzCyWsKXLa_weekly_active_users.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/p8ZML2ebd3ItzCyWsKXLa_weekly_active_users.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/p8ZML2ebd3ItzCyWsKXLa_weekly_active_users.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/p8ZML2ebd3ItzCyWsKXLa_weekly_active_users.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/pKdvc0pwu1zDi8NqnyJkt_dashboard_cards.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/pKdvc0pwu1zDi8NqnyJkt_dashboard_cards.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/pKdvc0pwu1zDi8NqnyJkt_dashboard_cards.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/pKdvc0pwu1zDi8NqnyJkt_dashboard_cards.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/r8r6O1VedjAjSD2MPxJs6_dashboards_with_more_questions_in_the_same_tab.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/r8r6O1VedjAjSD2MPxJs6_dashboards_with_more_questions_in_the_same_tab.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/r8r6O1VedjAjSD2MPxJs6_dashboards_with_more_questions_in_the_same_tab.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/r8r6O1VedjAjSD2MPxJs6_dashboards_with_more_questions_in_the_same_tab.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/skoPT2xiuEcUV8vFkHE6S_alerts.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/skoPT2xiuEcUV8vFkHE6S_alerts.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/skoPT2xiuEcUV8vFkHE6S_alerts.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/skoPT2xiuEcUV8vFkHE6S_alerts.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/t-4MnpU-Nn7Ph9GQT6zYb_question_views_last_week.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/t-4MnpU-Nn7Ph9GQT6zYb_question_views_last_week.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/t-4MnpU-Nn7Ph9GQT6zYb_question_views_last_week.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/t-4MnpU-Nn7Ph9GQT6zYb_question_views_last_week.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/tKEl86EXMyTDIoO9nyFTV_last_content_viewed_at.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/tKEl86EXMyTDIoO9nyFTV_last_content_viewed_at.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/tKEl86EXMyTDIoO9nyFTV_last_content_viewed_at.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/tKEl86EXMyTDIoO9nyFTV_last_content_viewed_at.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/uEf4gbDzXkj9q1uvkaTny_person_detail.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/uEf4gbDzXkj9q1uvkaTny_person_detail.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/uEf4gbDzXkj9q1uvkaTny_person_detail.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/uEf4gbDzXkj9q1uvkaTny_person_detail.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/vrez-ciNppijhELOxLJOY_dashboards_with_this_question.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/vrez-ciNppijhELOxLJOY_dashboards_with_this_question.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/vrez-ciNppijhELOxLJOY_dashboards_with_this_question.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/vrez-ciNppijhELOxLJOY_dashboards_with_this_question.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/vsyV8w3Ucl-JGTgeS8bny_50th_and_90th_percentile_of_query_running_time__seconds_.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/vsyV8w3Ucl-JGTgeS8bny_50th_and_90th_percentile_of_query_running_time__seconds_.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/vsyV8w3Ucl-JGTgeS8bny_50th_and_90th_percentile_of_query_running_time__seconds_.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/vsyV8w3Ucl-JGTgeS8bny_50th_and_90th_percentile_of_query_running_time__seconds_.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/x7GwgNdjfzrpQkKTraaqo_tables.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/x7GwgNdjfzrpQkKTraaqo_tables.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/x7GwgNdjfzrpQkKTraaqo_tables.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/x7GwgNdjfzrpQkKTraaqo_tables.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/xrgv7nzXe_v8ORWIbq839_recent_activity_on_dashboard.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/xrgv7nzXe_v8ORWIbq839_recent_activity_on_dashboard.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/xrgv7nzXe_v8ORWIbq839_recent_activity_on_dashboard.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/xrgv7nzXe_v8ORWIbq839_recent_activity_on_dashboard.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/y-_5bBl0fXY9XlEbpJkrj_most_active_people_on_this_question.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/y-_5bBl0fXY9XlEbpJkrj_most_active_people_on_this_question.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/y-_5bBl0fXY9XlEbpJkrj_most_active_people_on_this_question.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/y-_5bBl0fXY9XlEbpJkrj_most_active_people_on_this_question.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/yVr4oMzxq8BPWf5HLbdwL_questions_in_this_dashboard.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/yVr4oMzxq8BPWf5HLbdwL_questions_in_this_dashboard.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/yVr4oMzxq8BPWf5HLbdwL_questions_in_this_dashboard.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/yVr4oMzxq8BPWf5HLbdwL_questions_in_this_dashboard.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ybsNZp-876qEMoA1U51dc_alerts_and_subscriptions_created_last_week.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ybsNZp-876qEMoA1U51dc_alerts_and_subscriptions_created_last_week.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ybsNZp-876qEMoA1U51dc_alerts_and_subscriptions_created_last_week.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ybsNZp-876qEMoA1U51dc_alerts_and_subscriptions_created_last_week.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ycqt74TQ1W0kjmkLXFae5_total_number_of_queries.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ycqt74TQ1W0kjmkLXFae5_total_number_of_queries.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/ycqt74TQ1W0kjmkLXFae5_total_number_of_queries.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/ycqt74TQ1W0kjmkLXFae5_total_number_of_queries.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/zdVQGoMs__No_3l_amEWV_questions_consuming_most_resources.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/zdVQGoMs__No_3l_amEWV_questions_consuming_most_resources.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/zdVQGoMs__No_3l_amEWV_questions_consuming_most_resources.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/zdVQGoMs__No_3l_amEWV_questions_consuming_most_resources.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/zn_VtBXm5-teZmXpwGcNu_member_of.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/zn_VtBXm5-teZmXpwGcNu_member_of.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/cards/zn_VtBXm5-teZmXpwGcNu_member_of.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/cards/zn_VtBXm5-teZmXpwGcNu_member_of.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/BHyad8ZHCbeiBZpQxDwsP_content_with_cobwebs.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/BHyad8ZHCbeiBZpQxDwsP_content_with_cobwebs.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/BHyad8ZHCbeiBZpQxDwsP_content_with_cobwebs.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/BHyad8ZHCbeiBZpQxDwsP_content_with_cobwebs.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/DHMhMa1FYxiyIgM7_xdgR_person_overview.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/DHMhMa1FYxiyIgM7_xdgR_person_overview.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/DHMhMa1FYxiyIgM7_xdgR_person_overview.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/DHMhMa1FYxiyIgM7_xdgR_person_overview.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/Glqmoytsnu0n6rfLUjock_performance_overview.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/Glqmoytsnu0n6rfLUjock_performance_overview.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/Glqmoytsnu0n6rfLUjock_performance_overview.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/Glqmoytsnu0n6rfLUjock_performance_overview.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/IW64bVIFFkpldy410Pe5F_most_viewed_content.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/IW64bVIFFkpldy410Pe5F_most_viewed_content.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/IW64bVIFFkpldy410Pe5F_most_viewed_content.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/IW64bVIFFkpldy410Pe5F_most_viewed_content.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/Zf1jknwhPhazxIIJ2X6mX_dashboard_subscriptions_and_alerts.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/Zf1jknwhPhazxIIJ2X6mX_dashboard_subscriptions_and_alerts.yaml similarity index 78% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/Zf1jknwhPhazxIIJ2X6mX_dashboard_subscriptions_and_alerts.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/Zf1jknwhPhazxIIJ2X6mX_dashboard_subscriptions_and_alerts.yaml index 782b26727a69d556c9ff7247210a6216f6bff602..ba4d2bb5acd7f7c2aa7b8d63f90b20cc73b06629 100644 --- a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/Zf1jknwhPhazxIIJ2X6mX_dashboard_subscriptions_and_alerts.yaml +++ b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/Zf1jknwhPhazxIIJ2X6mX_dashboard_subscriptions_and_alerts.yaml @@ -15,7 +15,12 @@ public_uuid: null show_in_getting_started: false caveats: null points_of_interest: null -parameters: [] +parameters: +- id: 1f56492a + name: Creator + sectionId: string + slug: creator + type: string/= serdes/meta: - id: Zf1jknwhPhazxIIJ2X6mX label: dashboard_subscriptions_and_alerts @@ -31,7 +36,18 @@ dashcards: size_y: 13 action_id: null dashboard_tab_id: null - parameter_mappings: [] + parameter_mappings: + - card_id: g-99yx3A8bwFtrfuWdIgb + parameter_id: 1f56492a + target: + - dimension + - - field + - - Internal Metabase Database + - public + - v_users + - full_name + - base-type: type/Text + join-alias: People - Creator series: [] visualization_settings: column_settings: @@ -71,7 +87,18 @@ dashcards: size_y: 13 action_id: null dashboard_tab_id: null - parameter_mappings: [] + parameter_mappings: + - card_id: fwkC3nWB_uouifA5kkaBp + parameter_id: 1f56492a + target: + - dimension + - - field + - - Internal Metabase Database + - public + - v_users + - full_name + - base-type: type/Text + join-alias: People - Creator series: [] visualization_settings: column_settings: diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/bJEYb0o5CXlfWFcIztDwJ_dashboard_overview.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/bJEYb0o5CXlfWFcIztDwJ_dashboard_overview.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/bJEYb0o5CXlfWFcIztDwJ_dashboard_overview.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/bJEYb0o5CXlfWFcIztDwJ_dashboard_overview.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/jm7KgY6IuS6pQjkBZ7WUI_question_overview.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/jm7KgY6IuS6pQjkBZ7WUI_question_overview.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/jm7KgY6IuS6pQjkBZ7WUI_question_overview.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/jm7KgY6IuS6pQjkBZ7WUI_question_overview.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/vFnGZMNN2K_KW1I0B52bq_metabase_metrics.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/vFnGZMNN2K_KW1I0B52bq_metabase_metrics.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/dashboards/vFnGZMNN2K_KW1I0B52bq_metabase_metrics.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/dashboards/vFnGZMNN2K_KW1I0B52bq_metabase_metrics.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/okNLSZKdSxaoG58JSQY54_custom_reports/okNLSZKdSxaoG58JSQY54_custom_reports.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/okNLSZKdSxaoG58JSQY54_custom_reports/okNLSZKdSxaoG58JSQY54_custom_reports.yaml similarity index 100% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/okNLSZKdSxaoG58JSQY54_custom_reports/okNLSZKdSxaoG58JSQY54_custom_reports.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/okNLSZKdSxaoG58JSQY54_custom_reports/okNLSZKdSxaoG58JSQY54_custom_reports.yaml diff --git a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/vG58R8k-QddHWA7_47umn_metabase_analytics.yaml b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/vG58R8k-QddHWA7_47umn_usage_analytics.yaml similarity index 85% rename from resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/vG58R8k-QddHWA7_47umn_metabase_analytics.yaml rename to resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/vG58R8k-QddHWA7_47umn_usage_analytics.yaml index cf6c89b8d89cc9fa60714279e29ca99235e601db..fdbe511cf273b326c9b93d36022b6a5918bd9f4a 100644 --- a/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_metabase_analytics/vG58R8k-QddHWA7_47umn_metabase_analytics.yaml +++ b/resources/instance_analytics/collections/vG58R8k-QddHWA7_47umn_usage_analytics/vG58R8k-QddHWA7_47umn_usage_analytics.yaml @@ -1,7 +1,7 @@ -name: Metabase analytics +name: Usage analytics description: Your instance data. To customize these questions and dashboards, you can duplicate them and save them in the custom reports collection. entity_id: vG58R8k-QddHWA7_47umn -slug: metabase_analytics +slug: usage_analytics created_at: '2023-06-08T14:12:32.4457Z' archived: false type: instance-analytics @@ -11,7 +11,7 @@ namespace: analytics authority_level: null serdes/meta: - id: vG58R8k-QddHWA7_47umn - label: metabase_analytics + label: usage_analytics model: Collection archive_operation_id: null archived_directly: null diff --git a/snowplow/docker-compose.yml b/snowplow/docker-compose.yml index 90fbe0dfaccf44ed244923e831a23f3d211853d2..5feab04f4bbd71c1e43d21aaeb4e75ed864b2d38 100644 --- a/snowplow/docker-compose.yml +++ b/snowplow/docker-compose.yml @@ -8,3 +8,8 @@ services: volumes: - .:/config command: "--collector-config /config/micro.conf --iglu /config/iglu.json" + + iglu: + image: halverneus/static-file-server + volumes: + - ./iglu-client-embedded:/web diff --git a/snowplow/iglu-client-embedded/schemas/com.metabase/instance_stats/jsonschema/1-0-0 b/snowplow/iglu-client-embedded/schemas/com.metabase/instance_stats/jsonschema/1-0-0 new file mode 100644 index 0000000000000000000000000000000000000000..1699ee1cdbe4ba2200d7ad0915432dc7ef3d3850 --- /dev/null +++ b/snowplow/iglu-client-embedded/schemas/com.metabase/instance_stats/jsonschema/1-0-0 @@ -0,0 +1,96 @@ +{ + "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#", + "description": "Schema for daily stats ping, tracking instance metrics and settings", + "self": { + "vendor": "com.metabase", + "name": "instance_stats", + "format": "jsonschema", + "version": "1-0-0" + }, + "type": "object", + "properties": { + "instance_attributes": { + "description": "Key-value pairs of instance attributes", + "type": "array", + "items": { + "type": "object", + "description": "A single instance attribute", + "properties": { + "key": { + "description": "The key for this attribute", + "type": "string", + "maxLength": 255 + }, + "value": { + "description": "The value of this attribute", + "type": ["string", "boolean", "integer", "null"], + "maxLength": 255, + "minimum": 0, + "maximum": 2147483647 + } + }, + "required": [ + "key", + "value" + ] + } + }, + "features": { + "description": "Features", + "type": "array", + "items": { + "type": "object", + "description": "A single instance feature", + "properties": { + "name": { + "description": "The unique name of the feature", + "type": "string", + "maxLength": 255 + }, + "available": { + "description": "Whether the feature is available, i.e. can it be enabled/disabled or is it always on", + "type": "boolean" + }, + "enabled": { + "description": "Whether the feature is enabled, i.e. can it be used by the users/instance", + "type": "boolean" + } + }, + "required": [ + "name", + "available", + "enabled" + ], + "additionalProperties": true + } + }, + "metadata": { + "description": "Metadata about the anonymous stats collection", + "type": "array", + "items": { + "type": "object", + "description": "A single metadata key/value", + "properties": { + "key": { + "description": "The key for this metadata", + "type": "string", + "maxLength": 255 + }, + "value": { + "description": "The value of this metadata", + "type": ["string", "boolean", "integer", "null"], + "maxLength": 255, + "minimum": 0, + "maximum": 2147483647 + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "additionalProperties": false, + "required": ["instance_attributes", "features", "metadata"] +} diff --git a/snowplow/iglu.json b/snowplow/iglu.json index e8c871e5f35012d6071cb023f84a65aeeb174fea..28ad9c46d7f65bdcbb43597029e980fce13ff3ac 100644 --- a/snowplow/iglu.json +++ b/snowplow/iglu.json @@ -6,14 +6,22 @@ { "name": "Iglu Central", "priority": 0, - "vendorPrefixes": [ - "com.snowplowanalytics" - ], + "vendorPrefixes": [ "com.snowplowanalytics" ], "connection": { "http": { "uri": "http://iglucentral.com" } } + }, + { + "name": "local Iglu repository", + "priority": 10, + "vendorPrefixes": [ "com.snowplowanalytics" ], + "connection": { + "http": { + "uri": "http://iglu:80" + } + } } ] } diff --git a/src/metabase/analytics/snowplow.clj b/src/metabase/analytics/snowplow.clj index 31e6d25358fae887c00a120ea283548474518f4b..74e2e6178bf1f8856d1e4386b33f252e829439de 100644 --- a/src/metabase/analytics/snowplow.clj +++ b/src/metabase/analytics/snowplow.clj @@ -35,24 +35,25 @@ (def ^:private schema->version "The most recent version for each event schema. This should be updated whenever a new version of a schema is added to SnowcatCloud, at the same time that the data sent to the collector is updated." - {::account "1-0-1" - ::browse_data "1-0-0" - ::invite "1-0-1" - ::csvupload "1-0-3" - ::dashboard "1-1-4" - ::database "1-0-1" - ::instance "1-1-2" - ::metabot "1-0-1" - ::search "1-0-1" - ::model "1-0-0" - ::timeline "1-0-0" - ::task "1-0-0" - ::upsell "1-0-0" - ::action "1-0-0" - ::embed_share "1-0-0" - ::llm_usage "1-0-0" - ::serialization "1-0-1" - ::cleanup "1-0-0"}) + {::account "1-0-1" + ::browse_data "1-0-0" + ::invite "1-0-1" + ::instance_stats "1-0-0" + ::csvupload "1-0-3" + ::dashboard "1-1-4" + ::database "1-0-1" + ::instance "1-1-2" + ::metabot "1-0-1" + ::search "1-0-1" + ::model "1-0-0" + ::timeline "1-0-0" + ::task "1-0-0" + ::upsell "1-0-0" + ::action "1-0-0" + ::embed_share "1-0-0" + ::llm_usage "1-0-0" + ::serialization "1-0-1" + ::cleanup "1-0-0"}) (def ^:private SnowplowSchema "Malli enum for valid Snowplow schemas" @@ -62,7 +63,7 @@ (deferred-tru (str "Unique identifier to be used in Snowplow analytics, to identify this instance of Metabase. " "This is a public setting since some analytics events are sent prior to initial setup.")) - :encryption :never + :encryption :no :visibility :public :base setting/uuid-nonce-base :doc false) @@ -90,6 +91,7 @@ (defsetting snowplow-url (deferred-tru "The URL of the Snowplow collector to send analytics events to.") + :encryption :no :default (if config/is-prod? "https://sp.metabase.com" ;; See the iglu-schema-registry repo for instructions on how to run Snowplow Micro locally for development diff --git a/src/metabase/analytics/stats.clj b/src/metabase/analytics/stats.clj index 4d91f1355b7915715bd18b9015188656d8242be1..93d0f472422fe84d780f232576e5b829b55daf62 100644 --- a/src/metabase/analytics/stats.clj +++ b/src/metabase/analytics/stats.clj @@ -3,7 +3,10 @@ (:require [cheshire.core :as json] [clj-http.client :as http] + [clojure.java.io :as io] [clojure.string :as str] + [clojure.walk :as walk] + [environ.core :as env] [java-time.api :as t] [medley.core :as m] [metabase.analytics.snowplow :as snowplow] @@ -16,12 +19,15 @@ [metabase.integrations.google :as google] [metabase.integrations.slack :as slack] [metabase.models - :refer [Card Collection Dashboard DashboardCard Database Field LegacyMetric - PermissionsGroup Pulse PulseCard PulseChannel QueryCache Segment - Table User]] + :refer [Card Collection Dashboard DashboardCard Database Field + LegacyMetric PermissionsGroup Pulse PulseCard PulseChannel + QueryCache Segment Table User]] [metabase.models.humanization :as humanization] [metabase.models.interface :as mi] + [metabase.models.setting :as setting] [metabase.public-settings :as public-settings] + [metabase.public-settings.premium-features :as premium-features :refer [defenterprise]] + [metabase.util :as u] [metabase.util.honey-sql-2 :as h2x] [metabase.util.log :as log] [toucan2.core :as t2])) @@ -115,7 +121,7 @@ [] {:version (config/mb-version-info :tag) :running_on (environment-type) - :startup_time_millis (public-settings/startup-time-millis) + :startup_time_millis (int (public-settings/startup-time-millis)) :application_database (config/config-str :mb-db-type) :check_for_updates (public-settings/check-for-updates) :report_timezone (driver/report-timezone) @@ -348,16 +354,25 @@ ;;; Execution Metrics (defn- execution-metrics-sql [] + ;; Postgres automatically adjusts for daylight saving time when performing time calculations on TIMESTAMP WITH TIME + ;; ZONE. This can cause discrepancies when subtracting 30 days if the calculation crosses a DST boundary (e.g., in the + ;; Pacific/Auckland timezone). To avoid this, we ensure all date computations are done in UTC on Postgres to prevent + ;; any time shifts due to DST. See PR #48204 (let [thirty-days-ago (case (db/db-type) - :postgres "CURRENT_TIMESTAMP - INTERVAL '30 days'" + :postgres "CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - INTERVAL '30 days'" :h2 "DATEADD('DAY', -30, CURRENT_TIMESTAMP)" - :mysql "CURRENT_TIMESTAMP - INTERVAL 30 DAY")] + :mysql "CURRENT_TIMESTAMP - INTERVAL 30 DAY") + started-at (case (db/db-type) + :postgres "started_at AT TIME ZONE 'UTC'" + :h2 "started_at" + :mysql "started_at") + timestamp-where (str started-at " > " thirty-days-ago)] (str/join "\n" ["WITH user_executions AS (" " SELECT executor_id, COUNT(*) AS num_executions" " FROM query_execution" - " WHERE started_at > " thirty-days-ago + " WHERE " timestamp-where " GROUP BY executor_id" ")," "query_stats_1 AS (" @@ -374,7 +389,7 @@ " COALESCE(SUM(CASE WHEN running_time >= 1000000 AND running_time < 10000000 THEN 1 ELSE 0 END), 0) AS num_by_latency__1001_10000," " COALESCE(SUM(CASE WHEN running_time >= 10000000 THEN 1 ELSE 0 END), 0) AS num_by_latency__10000_plus" " FROM query_execution" - " WHERE started_at > " thirty-days-ago + " WHERE " timestamp-where ")," "query_stats_2 AS (" " SELECT" @@ -448,7 +463,7 @@ ;;; Combined Stats & Logic for sending them in -(defn anonymous-usage-stats +(defn legacy-anonymous-usage-stats "generate a map of the usage stats for this instance" [] (merge (instance-settings) @@ -470,16 +485,262 @@ :table (table-metrics) :user (user-metrics)}})) -(defn- send-stats! - "send stats to Metabase tracking server" +(defn- ^:deprecated send-stats-deprecated! + "Send stats to Metabase tracking server." [stats] (try (http/post metabase-usage-url {:form-params stats, :content-type :json, :throw-entire-message? true}) (catch Throwable e (log/error e "Sending usage stats FAILED")))) +(defn- in-docker? + "Is the current Metabase process running in a Docker container?" + [] + (boolean + (or (.exists (io/file "/.dockerenv")) + (when (.exists (io/file "/proc/self/cgroup")) + (some #(re-find #"docker" %) + (line-seq (io/reader "/proc/self/cgroup"))))))) + +(defn- deployment-model + [] + (case + (premium-features/is-hosted?) "cloud" + (in-docker?) "docker" + :else "jar")) + +(def ^:private activation-days 3) + +(defn- sufficient-users? + "Returns a Boolean indicating whether the number of non-internal users created within `activation-days` is greater + than or equal to `num-users`" + [num-users] + (let [users-in-activation-period + (t2/count :model/User {:where [:and + [:<= + :date_joined + (t/plus (t/offset-date-time (setting/get :instance-creation)) + (t/days activation-days))] + (mi/exclude-internal-content-hsql :model/User)] + :limit (inc num-users)})] + (>= users-in-activation-period num-users))) + +(defn- sufficient-queries? + "Returns a Boolean indicating whether the number of queries recorded over non-sample content is greater than or equal + to `num-queries`" + [num-queries] + (let [sample-db-id (t2/select-one-pk :model/Database :is_sample true) + ;; QueryExecution can be large, so let's avoid counting everything + queries (t2/select-fn-set :id :model/QueryExecution + {:where [:or + [:not= :database_id sample-db-id] + [:= :database_id nil]] + :limit (inc num-queries)})] + (>= (count queries) num-queries))) + +(defn- completed-activation-signals? + "If the current plan is Pro or Starter, returns a Boolean indicating whether the instance should be considered to have + completed activation signals. Returns nil for non-Pro or Starter plans." + [] + (let [plan (premium-features/plan-alias) + pro? (when plan (str/starts-with? plan "pro")) + starter? (when plan (str/starts-with? plan "starter"))] + (cond + pro? + (or (sufficient-users? 4) (sufficient-queries? 201)) + + starter? + (or (sufficient-users? 2) (sufficient-queries? 101)) + + :else + nil))) + +(defn- snowplow-instance-attributes + [stats] + (let [system-stats (-> stats :stats :system) + instance-attributes + (merge + (dissoc system-stats :user_language) + {:metabase_plan (premium-features/plan-alias) + :metabase_version (-> stats :version) + :language (-> system-stats :user_language) + :report_timezone (-> stats :report_timezone) + :deployment_model (deployment-model) + :startup_time_millis (-> stats :startup_time_millis) + :has_activation_signals_completed (completed-activation-signals?)})] + (mapv + (fn [[k v]] + {"key" (name k) + "value" v}) + instance-attributes))) + +(defn- whitelabeling-in-use? + "Are any whitelabeling settings set to values other than their default?" + [] + (let [whitelabel-settings (filter + (fn [setting] (= (:feature setting) :whitelabel)) + (vals @setting/registered-settings))] + (boolean + (some + (fn [setting] + (not= ((:getter setting)) + (:default setting))) + whitelabel-settings)))) + +(def csv-upload-version-availability + "Map from driver engines to the first version ([major minor]) which introduced support for CSV uploads" + {:postgres [47 0] + :mysql [47 0] + :redshift [49 6] + :clickhouse [50 0]}) + +(defn- csv-upload-available? + "Is CSV upload currently available to be used on this instance?" + [] + (boolean + (let [major-version (config/current-major-version) + minor-version (config/current-minor-version) + engines (t2/select-fn-set :engine :model/Database + {:where [:in :engine (map name (keys csv-upload-version-availability))]})] + (when (and major-version minor-version) + (some + (fn [engine] + (when-let [[required-major required-minor] (csv-upload-version-availability engine)] + (and (>= major-version required-major) + (>= minor-version required-minor)))) + engines))))) + +(defn- ee-snowplow-features-data' + [] + (let [features [:sso-jwt :sso-saml :scim :sandboxes :email-allow-list]] + (map + (fn [feature] + {:name feature + :available false + :enabled false}) + features))) + +(defenterprise ee-snowplow-features-data + "OSS values to use for features which require calling EE code to check whether they are available/enabled." + metabase-enterprise.stats + [] + (ee-snowplow-features-data')) + +(defn- snowplow-features-data + [] + [{:name :email + :available true + :enabled (email/email-configured?)} + {:name :slack + :available true + :enabled (slack/slack-configured?)} + {:name :sso-google + :available (premium-features/enable-sso-google?) + :enabled (google/google-auth-configured)} + {:name :sso-ldap + :available (premium-features/enable-sso-ldap?) + :enabled (public-settings/ldap-enabled?)} + {:name :sample-data + :available true + :enabled (t2/exists? Database, :is_sample true)} + {:name :interactive-embedding + :available (premium-features/hide-embed-branding?) + :enabled (and + (embed.settings/enable-embedding) + (boolean (embed.settings/embedding-app-origin)) + (public-settings/sso-enabled?))} + {:name :static-embedding + :available true + :enabled (and + (embed.settings/enable-embedding) + (or + (t2/exists? :model/Dashboard :enable_embedding true) + (t2/exists? :model/Card :enable_embedding true)))} + {:name :public-sharing + :available true + :enabled (and + (public-settings/enable-public-sharing) + (or + (t2/exists? :model/Dashboard :public_uuid [:not= nil]) + (t2/exists? :model/Card :public_uuid [:not= nil])))} + {:name :whitelabel + :available (premium-features/enable-whitelabeling?) + :enabled (whitelabeling-in-use?)} + {:name :csv-upload + :available (csv-upload-available?) + :enabled (t2/exists? :model/Database :uploads_enabled true)} + {:name :mb-analytics + :available (premium-features/enable-audit-app?) + :enabled true} + {:name :advanced-permissions + :available (premium-features/enable-advanced-permissions?) + :enabled true} + {:name :serialization + :available (premium-features/enable-serialization?) + :enabled true} + {:name :official-collections + :available (premium-features/enable-official-collections?) + :enabled (t2/exists? :model/Collection :authority_level "official")} + {:name :cache-granular-controls + :available (premium-features/enable-cache-granular-controls?) + :enabled (t2/exists? :model/CacheConfig)} + {:name :attached-dwh + :available (premium-features/has-attached-dwh?) + :enabled (premium-features/has-attached-dwh?)} + {:name :config-text-file + :available (premium-features/enable-config-text-file?) + :enabled (some? (get env/env :mb-config-file-path))} + {:name :content-verification + :available (premium-features/enable-content-verification?) + :enabled (t2/exists? :model/ModerationReview)} + {:name :dashboard-subscription-filters + :available (premium-features/enable-content-verification?) + :enabled (t2/exists? :model/Pulse {:where [:not= :parameters "[]"]})} + {:name :disable-password-login + :available (premium-features/can-disable-password-login?) + :enabled (not (public-settings/enable-password-login))} + {:name :email-restrict-recipients + :available (premium-features/enable-email-restrict-recipients?) + :enabled (not= (setting/get-value-of-type :keyword :user-visibility) :all)} + {:name :upload-management + :available (premium-features/enable-upload-management?) + :enabled (t2/exists? :model/Table :is_upload true)} + {:name :snippet-collections + :available (premium-features/enable-snippet-collections?) + :enabled (t2/exists? :model/Collection :namespace "snippets")}]) + +(defn- snowplow-features + [] + (let [features (concat (snowplow-features-data) (ee-snowplow-features-data))] + (mapv + ;; Convert keys and feature names to strings to match expected Snowplow scheml + (fn [feature] + (-> (update feature :name name) + (update :name u/->snake_case_en) + (walk/stringify-keys))) + features))) + +(defn- snowplow-anonymous-usage-stats + "Send stats to Metabase's snowplow collector. Transforms stats into the format required by the Snowplow schema." + [stats] + (let [instance-attributes (snowplow-instance-attributes stats) + features (snowplow-features)] + {:instance-attributes instance-attributes + :features features})) + (defn phone-home-stats! "Collect usage stats and phone them home" [] (when (public-settings/anon-tracking-enabled) - (send-stats! (anonymous-usage-stats)))) + (let [start-time-ms (System/currentTimeMillis) + stats (legacy-anonymous-usage-stats) + snowplow-stats (snowplow-anonymous-usage-stats stats) + end-time-ms (System/currentTimeMillis) + elapsed-secs (quot (- end-time-ms start-time-ms) 1000)] + #_{:clj-kondo/ignore [:deprecated-var]} + (send-stats-deprecated! stats) + (snowplow/track-event! ::snowplow/instance_stats + (assoc snowplow-stats + :metadata + [{"key" "stats_export_time_seconds" + "value" elapsed-secs}]))))) diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj index e450e73d833ff34448a177ff1536ae41b3a84cfa..ff86efaf275296b19cb9008a135bdd705e21255b 100644 --- a/src/metabase/api/collection.clj +++ b/src/metabase/api/collection.clj @@ -637,7 +637,7 @@ :id {:include-archived-items :all})) - descendant-collection-ids (map u/the-id descendant-collections) + descendant-collection-ids (mapv u/the-id descendant-collections) child-type->coll-id-set (reduce (fn [acc {collection-id :collection_id, card-type :type, :as _card}] diff --git a/src/metabase/api/embed/common.clj b/src/metabase/api/embed/common.clj index 0700d678163acd7bb7f7434065c38afa606a4472..725950d5b57cde163b1ee76a5a2695113ba57b38 100644 --- a/src/metabase/api/embed/common.clj +++ b/src/metabase/api/embed/common.clj @@ -336,6 +336,7 @@ (defsetting entity-id-translation-counter (deferred-tru "A counter for tracking the number of entity_id -> id translations. Whenever we call [[model->entity-ids->ids]], we increment this counter by the number of translations.") + :encryption :no :visibility :internal :export? false :audit :never diff --git a/src/metabase/api/geojson.clj b/src/metabase/api/geojson.clj index 1a1ab46667a83a156c4ccb595df22d8263a591e6..ead44a8e2d93f495254ce7c83f4b6ebe9cac2ae6 100644 --- a/src/metabase/api/geojson.clj +++ b/src/metabase/api/geojson.clj @@ -112,14 +112,15 @@ (defsetting custom-geojson (deferred-tru "JSON containing information about custom GeoJSON files for use in map visualizations instead of the default US State or World GeoJSON.") - :type :json - :getter (fn [] (merge (setting/get-value-of-type :json :custom-geojson) (builtin-geojson))) - :setter (fn [new-value] - ;; remove the built-in keys you can't override them and we don't want those to be subject to validation. - (let [new-value (not-empty (reduce dissoc new-value (keys (builtin-geojson))))] - (when new-value - (validate-geojson new-value)) - (setting/set-value-of-type! :json :custom-geojson new-value))) + :encryption :no + :type :json + :getter (fn [] (merge (setting/get-value-of-type :json :custom-geojson) (builtin-geojson))) + :setter (fn [new-value] + ;; remove the built-in keys you can't override them and we don't want those to be subject to validation. + (let [new-value (not-empty (reduce dissoc new-value (keys (builtin-geojson))))] + (when new-value + (validate-geojson new-value)) + (setting/set-value-of-type! :json :custom-geojson new-value))) :visibility :public :export? true :audit :raw-value) diff --git a/src/metabase/api/search.clj b/src/metabase/api/search.clj index 404f84531ff66f914b11e39c3875cd3fc3427a9f..0e9a06e5eab0dadda89e666f236acb03d7595b69 100644 --- a/src/metabase/api/search.clj +++ b/src/metabase/api/search.clj @@ -6,6 +6,8 @@ [metabase.public-settings :as public-settings] [metabase.search :as search] [metabase.server.middleware.offset-paging :as mw.offset-paging] + [metabase.task :as task] + [metabase.task.search-index :as task.search-index] [metabase.util :as u] [metabase.util.malli.schema :as ms] [ring.util.response :as response])) @@ -47,7 +49,9 @@ (search/supports-index?) (do - (search/reindex!) + (if (task/job-exists? task.search-index/job-key) + (task/trigger-now! task.search-index/job-key) + (search/reindex!)) {:status-code 200}) :else @@ -68,7 +72,7 @@ search_engine [:maybe string?] search_native_query [:maybe true?] verified [:maybe true?]} - (search/query-model-set + (search/model-set (search/search-context {:archived archived :created-at created_at :created-by (set (u/one-or-many created_by)) diff --git a/src/metabase/api/testing.clj b/src/metabase/api/testing.clj index b2e3f9fff05ab3bfb15e2d5bb06251ece25f1cd3..9735acb32bd198b4b6b32217bdd7f85b9faae69b 100644 --- a/src/metabase/api/testing.clj +++ b/src/metabase/api/testing.clj @@ -7,6 +7,7 @@ [compojure.core :refer [POST]] [java-time.api :as t] [java-time.clock] + [metabase.analytics.stats :as stats] [metabase.api.common :as api] [metabase.config :as config] [metabase.db :as mdb] @@ -174,4 +175,10 @@ "card" (t2/update! :model/Card :id id {:last_used_at date}) "dashboard" (t2/update! :model/Dashboard :id id {:last_viewed_at date})))) +(api/defendpoint POST "/stats" + "Triggers a send of instance usage stats" + [] + (stats/phone-home-stats!) + {:success true}) + (api/define-routes) diff --git a/src/metabase/api/util.clj b/src/metabase/api/util.clj index 73984c929072243f9655c660946273d26894d973..210f52d4d9105e6ad9dc3744b1ab2a35a4eb045b 100644 --- a/src/metabase/api/util.clj +++ b/src/metabase/api/util.clj @@ -38,7 +38,7 @@ what is being phoned home." [] (validation/check-has-application-permission :monitoring) - (stats/anonymous-usage-stats)) + (stats/legacy-anonymous-usage-stats)) (api/defendpoint GET "/random_token" "Return a cryptographically secure random 32-byte token, encoded as a hexadecimal string. diff --git a/src/metabase/config.clj b/src/metabase/config.clj index bce0922773bb86e9381b9b77e4e9db73ae29589c..a61a1338185835765839be4498ee0a764944471f 100644 --- a/src/metabase/config.clj +++ b/src/metabase/config.clj @@ -137,6 +137,13 @@ [] (major-version (:tag mb-version-info))) +(defn current-minor-version + "Returns the minor version of the running Metabase JAR. + When the version.properties file is missing (e.g., running in local dev), returns nil." + [] + (some-> (second (re-find #"\d+\.\d+\.(\d+)" (:tag mb-version-info))) + parse-long)) + (defonce ^{:doc "This UUID is randomly-generated upon launch and used to identify this specific Metabase instance during this specifc run. Restarting the server will change this UUID, and each server in a horizontal cluster will have its own ID, making this different from the `site-uuid` Setting."} diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index ae3656917d6369112f488e78f3fa25c7b6132f11..e6eaf52ab5101e9e8f05c28ee01d4ce9ba7cb689 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -61,6 +61,7 @@ (defsetting report-timezone (deferred-tru "Connection timezone to use when executing queries. Defaults to system timezone.") + :encryption :no :visibility :settings-manager :export? true :audit :getter @@ -676,6 +677,11 @@ ;; Does this driver support UUID type :uuid-type + ;; True if this driver requires `:temporal-unit :default` on all temporal field refs, even if no temporal + ;; bucketing was specified in the query. + ;; Generally false, but a few time-series based analytics databases (eg. Druid) require it. + :temporal/requires-default-unit + ;; Does this driver support window functions like cumulative count and cumulative sum? (default: false) :window-functions/cumulative @@ -684,7 +690,12 @@ :window-functions/offset ;; Does this driver support parameterized sql, eg. in prepared statements? - :parameterized-sql}) + :parameterized-sql + + ;; Whether the driver supports loading dynamic test datasets on each test run. Eg. datasets with names like + ;; `checkins:4-per-minute` are created dynamically in each test run. This should be truthy for every driver we test + ;; against except for Athena and Databricks which currently require test data to be loaded separately. + :test/dynamic-dataset-loading}) (defmulti database-supports? "Does this driver and specific instance of a database support a certain `feature`? diff --git a/src/metabase/driver/sql_jdbc/sync.clj b/src/metabase/driver/sql_jdbc/sync.clj index e492afe25b0c3032544143f2c2d7852a3189941a..c51a65517e0d26833ce4da7b0da4a3ac54fdca1e 100644 --- a/src/metabase/driver/sql_jdbc/sync.clj +++ b/src/metabase/driver/sql_jdbc/sync.clj @@ -34,6 +34,7 @@ describe-fks-sql describe-table describe-table-fields + describe-table-fields-xf describe-table-fks describe-table-indexes get-catalogs diff --git a/src/metabase/driver/sql_jdbc/sync/describe_database.clj b/src/metabase/driver/sql_jdbc/sync/describe_database.clj index dfa45572c455f99213865452e70494321a66ef7c..595d6ff3b89ccbe3819b1c9c274e3adc7863b825 100644 --- a/src/metabase/driver/sql_jdbc/sync/describe_database.clj +++ b/src/metabase/driver/sql_jdbc/sync/describe_database.clj @@ -190,7 +190,9 @@ (map #(dissoc % :type))) (db-tables driver (.getMetaData conn) nil db-name-or-nil)))) -(defn- db-or-id-or-spec->database [db-or-id-or-spec] +(defn db-or-id-or-spec->database + "Get database instance from `db-or-id-or-spec`." + [db-or-id-or-spec] (cond (mi/instance-of? :model/Database db-or-id-or-spec) db-or-id-or-spec diff --git a/src/metabase/driver/util.clj b/src/metabase/driver/util.clj index 492969d89c99e58320657571ba91f8f8b5cbd0f1..070bb936c84b50235449859c7a5e7e2d573e934b 100644 --- a/src/metabase/driver/util.clj +++ b/src/metabase/driver/util.clj @@ -524,6 +524,7 @@ "The set of all official drivers" #{"athena" "bigquery-cloud-sdk" + "databricks" "druid" "druid-jdbc" "h2" diff --git a/src/metabase/email.clj b/src/metabase/email.clj index 9fad6f2a1007bb6e1a329500655aaeaedae65dd4..f629273c9c23f4a66185899ee628bd82f6a2274b 100644 --- a/src/metabase/email.clj +++ b/src/metabase/email.clj @@ -23,12 +23,14 @@ (defsetting email-from-address (deferred-tru "The email address you want to use for the sender of emails.") + :encryption :no :default "notifications@metabase.com" :visibility :settings-manager :audit :getter) (defsetting email-from-name (deferred-tru "The name you want to use for the sender of emails.") + :encryption :no :visibility :settings-manager :audit :getter) @@ -46,6 +48,7 @@ (defsetting email-reply-to (deferred-tru "The email address you want the replies to go to, if different from the from address.") + :encryption :no :type :json :visibility :settings-manager :audit :getter @@ -56,28 +59,33 @@ (defsetting email-smtp-host (deferred-tru "The address of the SMTP server that handles your emails.") + :encryption :when-encryption-key-set :visibility :settings-manager :audit :getter) (defsetting email-smtp-username (deferred-tru "SMTP username.") + :encryption :when-encryption-key-set :visibility :settings-manager :audit :getter) (defsetting email-smtp-password (deferred-tru "SMTP password.") + :encryption :when-encryption-key-set :visibility :settings-manager :sensitive? true :audit :getter) (defsetting email-smtp-port (deferred-tru "The port your SMTP server uses for outgoing emails.") + :encryption :when-encryption-key-set :type :integer :visibility :settings-manager :audit :getter) (defsetting email-smtp-security (deferred-tru "SMTP secure connection protocol. (tls, ssl, starttls, or none)") + :encryption :when-encryption-key-set :type :keyword :default :none :visibility :settings-manager diff --git a/src/metabase/embed/settings.clj b/src/metabase/embed/settings.clj index 95daad375c2018d636dc944362ecc64a669b643c..7c228af0f26fd0de388e4d0314b2a502f724e37d 100644 --- a/src/metabase/embed/settings.clj +++ b/src/metabase/embed/settings.clj @@ -15,7 +15,8 @@ (public-settings/application-name-for-setting-descriptions)) :feature :embedding :visibility :public - :audit :getter) + :audit :getter + :encryption :no) (defsetting enable-embedding (deferred-tru "Allow admins to securely embed questions and dashboards within other applications?") diff --git a/src/metabase/integrations/common.clj b/src/metabase/integrations/common.clj index 5e0ea7ad350bf48bd3f1ac8660b1ad5f7da50c64..c349f2659a615b3bfb9bdd6e86600240a60108d2 100644 --- a/src/metabase/integrations/common.clj +++ b/src/metabase/integrations/common.clj @@ -61,7 +61,8 @@ (deferred-tru "Should new email notifications be sent to admins, for all new SSO users?") (fn [] (if (premium-features/enable-any-sso?) :ee - :oss))) + :oss)) + :type :boolean) (define-multi-setting-impl send-new-sso-user-admin-email? :oss :getter (fn [] (constantly true)) diff --git a/src/metabase/integrations/google.clj b/src/metabase/integrations/google.clj index aa9df64dd9edb2c4412b637f9b05d92a9bd13648..64fcc5bb828f923d668a8930000a3799a53a0c5c 100644 --- a/src/metabase/integrations/google.clj +++ b/src/metabase/integrations/google.clj @@ -27,6 +27,7 @@ (defsetting google-auth-client-id (deferred-tru "Client ID for Google Sign-In.") + :encryption :when-encryption-key-set :visibility :public :audit :getter :setter (fn [client-id] diff --git a/src/metabase/integrations/google/interface.clj b/src/metabase/integrations/google/interface.clj index dd662e5c01340593fd3e874dcf0c9aded91d6b74..54de44d96be58dea61323976e8abd6b5ff9ac6d9 100644 --- a/src/metabase/integrations/google/interface.clj +++ b/src/metabase/integrations/google/interface.clj @@ -7,4 +7,5 @@ #_{:clj-kondo/ignore [:missing-docstring]} (define-multi-setting google-auth-auto-create-accounts-domain (deferred-tru "When set, allow users to sign up on their own if their Google account email address is from this domain.") - (fn [] (if (premium-features/enable-sso-google?) :ee :oss))) + (fn [] (if (premium-features/enable-sso-google?) :ee :oss)) + :encryption :when-encryption-key-set) diff --git a/src/metabase/integrations/ldap.clj b/src/metabase/integrations/ldap.clj index f10219bb3228b11d678b99b32b1b4b82751677a0..df131aefa7e629a57fb35e736358777e746ae44d 100644 --- a/src/metabase/integrations/ldap.clj +++ b/src/metabase/integrations/ldap.clj @@ -24,13 +24,15 @@ (defsetting ldap-host (deferred-tru "Server hostname.") + :encryption :when-encryption-key-set :audit :getter) (defsetting ldap-port (deferred-tru "Server port, usually 389 or 636 if SSL is used.") - :type :integer - :default 389 - :audit :getter) + :encryption :when-encryption-key-set + :type :integer + :default 389 + :audit :getter) (defsetting ldap-security (deferred-tru "Use SSL, TLS or plain text.") @@ -44,39 +46,46 @@ (defsetting ldap-bind-dn (deferred-tru "The Distinguished Name to bind as (if any), this user will be used to lookup information about other users.") + :encryption :when-encryption-key-set :audit :getter) (defsetting ldap-password (deferred-tru "The password to bind with for the lookup user.") + :encryption :when-encryption-key-set :sensitive? true :audit :getter) (defsetting ldap-user-base (deferred-tru "Search base for users. (Will be searched recursively)") - :audit :getter) + :encryption :no + :audit :getter) (defsetting ldap-user-filter (deferred-tru "User lookup filter. The placeholder '{login'} will be replaced by the user supplied login.") - :default "(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))" - :audit :getter) + :default "(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))" + :encryption :no + :audit :getter) (defsetting ldap-attribute-email (deferred-tru "Attribute to use for the user''s email. (usually ''mail'', ''email'' or ''userPrincipalName'')") - :default "mail" - :getter (fn [] (u/lower-case-en (setting/get-value-of-type :string :ldap-attribute-email))) - :audit :getter) + :default "mail" + :encryption :no + :getter (fn [] (u/lower-case-en (setting/get-value-of-type :string :ldap-attribute-email))) + :audit :getter) (defsetting ldap-attribute-firstname (deferred-tru "Attribute to use for the user''s first name. (usually ''givenName'')") - :default "givenName" - :getter (fn [] (u/lower-case-en (setting/get-value-of-type :string :ldap-attribute-firstname))) - :audit :getter) + :default "givenName" + :getter (fn [] (u/lower-case-en (setting/get-value-of-type :string :ldap-attribute-firstname))) + :encryption :no + :audit :getter) (defsetting ldap-attribute-lastname (deferred-tru "Attribute to use for the user''s last name. (usually ''sn'')") - :default "sn" - :getter (fn [] (u/lower-case-en (setting/get-value-of-type :string :ldap-attribute-lastname))) - :audit :getter) + :encryption :no + :default "sn" + :getter (fn [] (u/lower-case-en (setting/get-value-of-type :string :ldap-attribute-lastname))) + :audit :getter) (defsetting ldap-group-sync (deferred-tru "Enable group membership synchronization with LDAP.") @@ -86,28 +95,30 @@ (defsetting ldap-group-base (deferred-tru "Search base for groups. Not required for LDAP directories that provide a ''memberOf'' overlay, such as Active Directory. (Will be searched recursively)") - :audit :getter) + :audit :getter + :encryption :no) (defsetting ldap-group-mappings ;; Should be in the form: {"cn=Some Group,dc=...": [1, 2, 3]} where keys are LDAP group DNs and values are lists of ;; MB groups IDs (deferred-tru "JSON containing LDAP to Metabase group mappings.") - :type :json - :cache? false - :default {} - :audit :getter - :getter (fn [] - (json/parse-string (setting/get-value-of-type :string :ldap-group-mappings) #(DN. (str %)))) - :setter (fn [new-value] - (cond - (string? new-value) - (recur (json/parse-string new-value)) - - (map? new-value) - (do (doseq [k (keys new-value)] - (when-not (DN/isValidDN (u/qualified-name k)) - (throw (IllegalArgumentException. (tru "{0} is not a valid DN." (u/qualified-name k)))))) - (setting/set-value-of-type! :json :ldap-group-mappings new-value))))) + :encryption :no + :type :json + :cache? false + :default {} + :audit :getter + :getter (fn [] + (json/parse-string (setting/get-value-of-type :string :ldap-group-mappings) #(DN. (str %)))) + :setter (fn [new-value] + (cond + (string? new-value) + (recur (json/parse-string new-value)) + + (map? new-value) + (do (doseq [k (keys new-value)] + (when-not (DN/isValidDN (u/qualified-name k)) + (throw (IllegalArgumentException. (tru "{0} is not a valid DN." (u/qualified-name k)))))) + (setting/set-value-of-type! :json :ldap-group-mappings new-value))))) (defsetting ldap-configured? (deferred-tru "Have the mandatory LDAP settings (host and user search base) been validated and saved?") diff --git a/src/metabase/integrations/slack.clj b/src/metabase/integrations/slack.clj index a367e5dce74d417e928c100fbb15c7e13c7bd300..b9b201d63f684b677e4c816b0748386c9fa37f10 100644 --- a/src/metabase/integrations/slack.clj +++ b/src/metabase/integrations/slack.clj @@ -23,6 +23,7 @@ (str "Deprecated Slack API token for connecting the Metabase Slack bot. " "Please use a new Slack app integration instead.")) :deprecated "0.42.0" + :encryption :when-encryption-key-set :visibility :settings-manager :doc false :audit :never) @@ -31,6 +32,7 @@ (deferred-tru (str "Bot user OAuth token for connecting the Metabase Slack app. " "This should be used for all new Slack integrations starting in Metabase v0.42.0.")) + :encryption :when-encryption-key-set :visibility :settings-manager :getter (fn [] (-> (setting/get-value-of-type :string :slack-app-token) @@ -57,6 +59,7 @@ (defsetting slack-cached-channels-and-usernames "A cache shared between instances for storing an instance's slack channels and users." + :encryption :when-encryption-key-set :visibility :internal :type :json :doc false @@ -76,6 +79,7 @@ (defsetting slack-files-channel (deferred-tru "The name of the channel to which Metabase files should be initially uploaded") :default "metabase_files" + :encryption :no :visibility :settings-manager :audit :getter :setter (fn [channel-name] diff --git a/src/metabase/lib/expression.cljc b/src/metabase/lib/expression.cljc index 635a87706fcf8249e482bcdddbaed8c11815a31d..476303fcb099569dec2f7baf5c14545d6541b607 100644 --- a/src/metabase/lib/expression.cljc +++ b/src/metabase/lib/expression.cljc @@ -70,6 +70,13 @@ (when-let [unit (lib.temporal-bucket/raw-temporal-bucket expression-ref-clause)] {:metabase.lib.field/temporal-unit unit}))) +(defmethod lib.temporal-bucket/available-temporal-buckets-method :expression + [query stage-number [_expression opts _expr-name, :as expr-clause]] + (lib.temporal-bucket/available-temporal-buckets-for-type + (lib.metadata.calculation/type-of query stage-number expr-clause) + :month + (:temporal-unit opts))) + (defmethod lib.metadata.calculation/display-name-method :dispatch-type/integer [_query _stage-number n _style] (str n)) diff --git a/src/metabase/lib/field.cljc b/src/metabase/lib/field.cljc index 9e14b4219b5fe234bebdd9faff67a6470e8205e6..34ac4d3234e15ca2dc68995b4114388e6206d015 100644 --- a/src/metabase/lib/field.cljc +++ b/src/metabase/lib/field.cljc @@ -348,25 +348,13 @@ 365 :week :month)))))) -(defn- mark-unit [options option-key unit] - (cond->> options - (some #(= (:unit %) unit) options) - (mapv (fn [option] - (cond-> option - (contains? option option-key) (dissoc option option-key) - (= (:unit option) unit) (assoc option-key true)))))) - (defmethod lib.temporal-bucket/available-temporal-buckets-method :metadata/column [_query _stage-number field-metadata] - (let [effective-type ((some-fn :effective-type :base-type) field-metadata) - fingerprint-default (some-> field-metadata :fingerprint fingerprint-based-default-unit)] - (cond-> (cond - (isa? effective-type :type/DateTime) lib.temporal-bucket/datetime-bucket-options - (isa? effective-type :type/Date) lib.temporal-bucket/date-bucket-options - (isa? effective-type :type/Time) lib.temporal-bucket/time-bucket-options - :else []) - fingerprint-default (mark-unit :default fingerprint-default) - (::temporal-unit field-metadata) (mark-unit :selected (::temporal-unit field-metadata))))) + (lib.temporal-bucket/available-temporal-buckets-for-type + ((some-fn :effective-type :base-type) field-metadata) + (or (some-> field-metadata :fingerprint fingerprint-based-default-unit) + :month) + (::temporal-unit field-metadata))) ;;; ---------------------------------------- Binning --------------------------------------------- diff --git a/src/metabase/lib/temporal_bucket.cljc b/src/metabase/lib/temporal_bucket.cljc index 01fbe95f0ca7a9412fcbd04f8d6ae073d8f733cc..9a48aab6066f3eb625bb53f3ef31d389d1132538 100644 --- a/src/metabase/lib/temporal_bucket.cljc +++ b/src/metabase/lib/temporal_bucket.cljc @@ -227,6 +227,30 @@ [_query _stage-number _x] #{}) +(defn- mark-unit [options option-key unit] + (cond->> options + (some #(= (:unit %) unit) options) + (mapv (fn [option] + (cond-> option + (contains? option option-key) (dissoc option option-key) + (= (:unit option) unit) (assoc option-key true)))))) + +(defn available-temporal-buckets-for-type + "Given the type of this column and nillable `default-unit` and `selected-unit`s, return the correct list of buckets." + [column-type default-unit selected-unit] + (let [options (cond + (isa? column-type :type/DateTime) datetime-bucket-options + (isa? column-type :type/Date) date-bucket-options + (isa? column-type :type/Time) time-bucket-options + :else []) + fallback-unit (if (isa? column-type :type/Time) + :hour + :month) + default-unit (or default-unit fallback-unit)] + (cond-> options + default-unit (mark-unit :default default-unit) + selected-unit (mark-unit :selected selected-unit)))) + (mu/defn available-temporal-buckets :- [:sequential [:ref ::lib.schema.temporal-bucketing/option]] "Get a set of available temporal bucketing units for `x`. Returns nil if no units are available." ([query x] diff --git a/src/metabase/metabot/settings.clj b/src/metabase/metabot/settings.clj index a7fda854ace10a8372023188879d752208ea813b..62f3270c8120af785eb657205e4d79d882c2cd1f 100644 --- a/src/metabase/metabot/settings.clj +++ b/src/metabase/metabot/settings.clj @@ -8,29 +8,35 @@ (defsetting openai-model (deferred-tru "The OpenAI Model (e.g. 'gpt-4-turbo-preview', 'gpt-4', 'gpt-3.5-turbo')") + :encryption :no :visibility :settings-manager :default "gpt-4-turbo-preview") (defsetting openai-api-key (deferred-tru "The OpenAI API Key.") + :encryption :when-encryption-key-set :visibility :settings-manager) (defsetting openai-organization (deferred-tru "The OpenAI Organization ID.") + :encryption :when-encryption-key-set :visibility :settings-manager) (defsetting metabot-default-embedding-model (deferred-tru "The default embeddings model to be used for metabot.") + :encryption :no :visibility :internal :default "text-embedding-ada-002") (defsetting metabot-get-prompt-templates-url (deferred-tru "The URL in which metabot versioned prompt templates are stored.") + :encryption :when-encryption-key-set :visibility :settings-manager :default "https://stkxezsr2kcnkhusi3fgcc5nqm0ttgfx.lambda-url.us-east-1.on.aws/") (defsetting metabot-feedback-url (deferred-tru "The URL to which metabot feedback is posted.") + :encryption :when-encryption-key-set :visibility :settings-manager :default "https://amtix3l3qvitb2qxstaqtcoqby0monuf.lambda-url.us-east-1.on.aws/") diff --git a/src/metabase/models/cloud_migration.clj b/src/metabase/models/cloud_migration.clj index 43e7be9f254ae64f1a681dd3b5629da48cc6ddfa..04685651c79fed05d0d2435032f0fa481a7ede70 100644 --- a/src/metabase/models/cloud_migration.clj +++ b/src/metabase/models/cloud_migration.clj @@ -43,6 +43,7 @@ (defsetting store-url (deferred-tru "Store URL.") + :encryption :no :visibility :admin ;; should be :internal, but FE doesn't get internal settings :default (str "https://store" (when (store-use-staging) ".staging") ".metabase.com") :doc false @@ -50,6 +51,7 @@ (defsetting store-api-url (deferred-tru "Store API URL.") + :encryption :no :visibility :internal :default (str "https://store-api" (when (store-use-staging) ".staging") ".metabase.com") :doc false @@ -57,6 +59,7 @@ (defsetting migration-dump-file (deferred-tru "Dump file for migrations.") + :encryption :no :visibility :internal :default nil :doc false @@ -64,6 +67,7 @@ (defsetting migration-dump-version (deferred-tru "Custom dump version for migrations.") + :encryption :no :visibility :internal ;; Use a known version on staging when there's no real version. ;; This will cause the restore to fail on cloud unless you also set `migration-dump-file` to diff --git a/src/metabase/models/collection.clj b/src/metabase/models/collection.clj index f3775234d376fb40c0b6ae8d6b1f3903b3faf283..006eeafc628f9a7df5f438cc1704a91ccbc1dd45 100644 --- a/src/metabase/models/collection.clj +++ b/src/metabase/models/collection.clj @@ -83,8 +83,10 @@ ;; in some circumstances we don't have a `:type` on the collection (e.g. search or collection items lists, where we ;; select a subset of columns). Use the type if it's there, but fall back to the ID to be sure. ;; We can't *only* use the id because getting that requires selecting a collection :sweat-smile: - (or (= (:type collection-or-id) trash-collection-type) - (some-> collection-or-id u/id (= (trash-collection-id))))) + (let [type (:type collection-or-id ::not-found)] + (if (identical? type ::not-found) + (some-> collection-or-id u/id (= (trash-collection-id))) + (= type trash-collection-type)))) (defn is-trash-or-descendant? "Is this the trash collection, or a descendant of it?" @@ -179,8 +181,9 @@ 'Explode' a `location-path` into a sequence of Collection IDs, and parse them as integers. THIS DOES NOT VALIDATE THAT THE PATH OR RESULTS ARE VALID. This unchecked version exists solely to power the other version below." [location-path] - (for [^String id-str (rest (str/split location-path #"/"))] - (Integer/parseInt id-str))) + (if (= location-path "/") + [] + (mapv parse-long (rest (str/split location-path #"/"))))) (defn- valid-location-path? [s] (boolean @@ -1552,19 +1555,23 @@ m child-type->parent-ids))) (zipmap (keys child-type->parent-ids) (repeat #{})) - collections)] - (map (fn [{:keys [id] :as collection}] - (let [below (apply set/union - (for [[child-type coll-id-set] child-type->ancestor-ids] - (when (contains? coll-id-set id) - #{child-type}))) - here (into #{} (for [[child-type coll-id-set] child-type->parent-ids - :when (contains? coll-id-set id)] - child-type))] - (cond-> collection - (seq below) (assoc :below below) - (seq here) (assoc :here here)))) - collections))) + collections) + + collect-present-child-types + (fn [child-type-map id] + (persistent! + (reduce-kv (fn [acc child-type coll-id-set] + (cond-> acc + (contains? coll-id-set id) (conj! child-type))) + (transient #{}) + child-type-map)))] + (mapv (fn [{:keys [id] :as collection}] + (let [below (collect-present-child-types child-type->ancestor-ids id) + here (collect-present-child-types child-type->parent-ids id)] + (cond-> collection + (seq below) (assoc :below below) + (seq here) (assoc :here here)))) + collections))) (defn collections->tree "Convert a flat sequence of Collections into a tree structure e.g. @@ -1606,11 +1613,16 @@ ;; effectively "pulling" a Collection up to a higher level. e.g. if we have A > B > C and we can't see B then ;; the tree should come back as A > C. ([m collection] - (let [path (as-> (location-path->ids (:location collection)) ids - (filter all-visible-ids ids) - (concat ids [(:id collection)]) - (interpose :children ids))] - (update-in m path merge collection))) + (let [ids (location-path->ids (:location collection)) + path (if (empty? ids) + [(:id collection)] + (as-> ids ids + (filterv all-visible-ids ids) + (conj ids (:id collection)) + (interpose :children ids) + (vec ids)))] + ;; Using conj instead of merge because the latter is inefficient with its varargs and reduce1. + (update-in m path #(if %1 (conj %1 %2) %2) collection))) ;; 3. Once we've build the entire tree structure, go in and convert each ID->Collection map into a flat sequence, ;; sorted by the lowercased Collection name. Do this recursively for the `:children` of each Collection e.g. ;; diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj index 80e755f7b1261ea1f3bb4892fa8bdb18562a5482..e65681ed0df4bb79d795fef46a2f2909b671ae72 100644 --- a/src/metabase/models/permissions.clj +++ b/src/metabase/models/permissions.clj @@ -182,6 +182,7 @@ [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] + [metabase.util.performance :as perf] [methodical.core :as methodical] [toucan2.core :as t2])) @@ -277,25 +278,27 @@ (defn set-has-full-permissions? "Does `permissions-set` grant *full* access to object with `path`?" ^Boolean [permissions-set path] - (boolean (some #(is-permissions-for-object? % path) permissions-set))) + (boolean (perf/some #(is-permissions-for-object? % path) permissions-set))) (defn set-has-partial-permissions? "Does `permissions-set` grant access full access to object with `path` *or* to a descendant of it?" ^Boolean [permissions-set path] - (boolean (some #(is-partial-permissions-for-object? % path) permissions-set))) + (boolean (perf/some #(is-partial-permissions-for-object? % path) permissions-set))) (mu/defn set-has-full-permissions-for-set? :- :boolean "Do the permissions paths in `permissions-set` grant *full* access to all the object paths in `paths-set`?" [permissions-set paths-set] - (every? (partial set-has-full-permissions? permissions-set) - paths-set)) + (let [permissions (or (:as-vec (meta permissions-set)) + permissions-set)] + (every? (partial set-has-full-permissions? permissions) paths-set))) (mu/defn set-has-partial-permissions-for-set? :- :boolean "Do the permissions paths in `permissions-set` grant *partial* access to all the object paths in `paths-set`? (`permissions-set` must grant partial access to *every* object in `paths-set` set)." [permissions-set paths-set] - (every? (partial set-has-partial-permissions? permissions-set) - paths-set)) + (let [permissions (or (:as-vec (meta permissions-set)) + permissions-set)] + (every? (partial set-has-partial-permissions? permissions) paths-set))) (mu/defn set-has-application-permission-of-type? :- :boolean "Does `permissions-set` grant *full* access to a application permission of type `perm-type`?" diff --git a/src/metabase/models/setting.clj b/src/metabase/models/setting.clj index a698cc69ebc7082cd2f0ad898063d98d29c15bb2..0d394b96f9de9f62b6a1cbd588924c23e3834bae 100644 --- a/src/metabase/models/setting.clj +++ b/src/metabase/models/setting.clj @@ -271,9 +271,10 @@ ;; where this setting should be visible (default: :admin) [:visibility Visibility] - ;; should this setting be encrypted `:never` or `:maybe` (when `MB_ENCRYPTION_SECRET_KEY` is set). - ;; Defaults to `:maybe` (except for `:boolean` typed settings, where it defaults to `:never`) - [:encryption [:enum :never :maybe]] + ;; should this setting be encrypted. Available options are `:no` or `:when-encryption-key-set` (the setting will be + ;; encrypted when `MB_ENCRYPTION_SECRET_KEY` is set, otherwise we can't encrypt). This is required for `:timestamp`, + ;; `:json`, and `:csv`-typed settings. Defaults to `:no` for all other types. + [:encryption [:enum :no :when-encryption-key-set]] ;; should this setting be serialized? [:export? :boolean] @@ -388,7 +389,7 @@ (core/get *database-local-values* setting-name)))) (defn- prohibits-encryption? [setting-or-name] - (= :never (:encryption (resolve-setting setting-or-name)))) + (= :no (:encryption (resolve-setting setting-or-name)))) (defn- allows-user-local-values? [setting] (#{:only :allowed} (:user-local (resolve-setting setting)))) @@ -953,6 +954,42 @@ (binding [config/*disable-setting-cache* (not cache?)] (set-with-audit-logging! setting new-value bypass-read-only?)))) +(defn- extract-encryption-or-default + "Encryption is turned off or on according to (in order of preference): + + - the value you specify in `defsetting`, + + - ON for settings marked as `sensitive?` + + - ON for settings with a setter of `:none` (the specific value here doesn't really matter, we just don't want the + caller to need to provide a value) + + - OFF for types unlikely to contain secrets. As of this writing, that's booleans, numbers, keywords, and timestamps + + If none of these conditions are met (a non-`:sensitive?` string/json/csv value you're storing in the database, and + you didn't provide a value) then we'll throw an exception telling you that you need to provide it. This way, when we + add new settings, we'll think about their sensitivity level and make a conscious decision about whether they need to + be encrypted or not." + [setting] + (or + (:encryption setting) + ;; NOTE: if none of the below conditions is met, users of `defsetting` will be required to + ;; provide a value for `:encryption`. + ;; + ;; if a setting is `:sensitive?`, default to encrypting it + (when (:sensitive? setting) + :when-encryption-key-set) + ;; if a setting isn't stored in the DB, the value doesn't really matter, but provide + ;; a default so the caller doesn't have to + (when (= (:setter setting) :none) + :when-encryption-key-set) + ;; if the setting isn't a type likely to contain secrets, default to plaintext + (when (contains? #{:boolean :integer :positive-integer :double :keyword :timestamp} (:type setting)) + :no) + + (throw (ex-info (trs "`:encryption` is a required option for setting {0}" (:name setting)) + {:setting setting})))) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | register-setting! | ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -977,7 +1014,7 @@ :init nil :tag (default-tag-for-type setting-type) :visibility :admin - :encryption (if (= setting-type :boolean) :never :maybe) + :encryption (extract-encryption-or-default setting) :export? false :sensitive? false :cache? true diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj index d80a08a2457a3c8e8f989d91fec4e69615bcd201..13989ed56f847cd5075a87a37f545bbdd41050ca 100644 --- a/src/metabase/models/user.clj +++ b/src/metabase/models/user.clj @@ -250,16 +250,19 @@ (defn permissions-set "Return a set of all permissions object paths that `user-or-id` has been granted access to. (2 DB Calls)" [user-or-id] - (set (when-let [user-id (u/the-id user-or-id)] - (concat - ;; Current User always gets readwrite perms for their Personal Collection and for its descendants! (1 DB Call) - (map perms/collection-readwrite-path (collection/user->personal-collection-and-descendant-ids user-or-id)) - ;; include the other Perms entries for any Group this User is in (1 DB Call) - (map :object (mdb.query/query {:select [:p.object] - :from [[:permissions_group_membership :pgm]] - :join [[:permissions_group :pg] [:= :pgm.group_id :pg.id] - [:permissions :p] [:= :p.group_id :pg.id]] - :where [:= :pgm.user_id user-id]})))))) + (let [s + (set (when-let [user-id (u/the-id user-or-id)] + (concat + ;; Current User always gets readwrite perms for their Personal Collection and for its descendants! (1 DB Call) + (map perms/collection-readwrite-path (collection/user->personal-collection-and-descendant-ids user-or-id)) + ;; include the other Perms entries for any Group this User is in (1 DB Call) + (map :object (mdb.query/query {:select [:p.object] + :from [[:permissions_group_membership :pgm]] + :join [[:permissions_group :pg] [:= :pgm.group_id :pg.id] + [:permissions :p] [:= :p.group_id :pg.id]] + :where [:= :pgm.user_id user-id]})))))] + ;; Append permissions as a vector for more efficient iteration in checks that go over each permission linearly. + (with-meta s {:as-vec (vec s)}))) ;;; --------------------------------------------------- Hydration ---------------------------------------------------- @@ -454,6 +457,7 @@ (defsetting last-acknowledged-version (deferred-tru "The last version for which a user dismissed the 'What's new?' modal.") + :encryption :no :user-local :only :type :string) diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj index 541331ab282d2e22334dea71743f75f0a106ca07..fa86f870dbbdc2383f3999cfc927f4fb62bb8f3d 100644 --- a/src/metabase/public_settings.clj +++ b/src/metabase/public_settings.clj @@ -26,6 +26,7 @@ (defsetting application-name (deferred-tru "Replace the word “Metabase†wherever it appears.") + :encryption :no :visibility :public :export? true :type :string @@ -47,7 +48,9 @@ (defn- google-auth-enabled? [] (boolean (setting/get :google-auth-enabled))) -(defn- ldap-enabled? [] +(defn ldap-enabled? + "Is LDAP enabled?" + [] (classloader/require 'metabase.api.ldap) ((resolve 'metabase.api.ldap/ldap-enabled))) @@ -74,6 +77,7 @@ ;; Don't i18n this docstring because it's not user-facing! :) "Unique identifier used for this instance of {0}. This is set once and only once the first time it is fetched via its magic getter. Nice!" + :encryption :no :visibility :authenticated :base setting/uuid-nonce-base :doc false) @@ -118,14 +122,15 @@ (defsetting version-info (deferred-tru "Information about available versions of Metabase.") - :type :json - :audit :never - :default {} - :doc false - :getter (fn [] - (let [raw-vi (setting/get-value-of-type :json :version-info) - current-major (config/current-major-version)] - (version-info* raw-vi {:current-major current-major :upgrade-threshold-value (upgrade-threshold)})))) + :encryption :no + :type :json + :audit :never + :default {} + :doc false + :getter (fn [] + (let [raw-vi (setting/get-value-of-type :json :version-info) + current-major (config/current-major-version)] + (version-info* raw-vi {:current-major current-major :upgrade-threshold-value (upgrade-threshold)})))) (defsetting version-info-last-checked (deferred-tru "Indicates when Metabase last checked for new versions.") @@ -146,6 +151,7 @@ (defsetting site-name (deferred-tru "The name used for this instance of {0}." (application-name-for-setting-descriptions)) + :encryption :no :default "Metabase" :audit :getter :visibility :settings-manager @@ -153,6 +159,7 @@ (defsetting custom-homepage (deferred-tru "Pick one of your dashboards to serve as homepage. Users without dashboard access will be directed to the default homepage.") + :encryption :no :default false :type :boolean :audit :getter @@ -160,6 +167,7 @@ (defsetting custom-homepage-dashboard (deferred-tru "ID of dashboard to use as a homepage") + :encryption :no :type :integer :visibility :public :audit :getter) @@ -171,6 +179,7 @@ in [[metabase.public-settings.premium-features/fetch-token-status]]. (`site-uuid` is used for anonymous analytics/stats and if we sent it along with the premium features token check API request it would no longer be anonymous.)" + :encryption :when-encryption-key-set :visibility :internal :base setting/uuid-nonce-base :doc false) @@ -178,12 +187,14 @@ (defsetting site-uuid-for-version-info-fetching "A *different* site-wide UUID that we use for the version info fetching API calls. Do not use this for any other applications. (See [[site-uuid-for-premium-features-token-checks]] for more reasoning.)" + :encryption :when-encryption-key-set :visibility :internal :base setting/uuid-nonce-base) (defsetting site-uuid-for-unsubscribing-url "UUID that we use for generating urls users to unsubscribe from alerts. The hash is generated by hash(secret_uuid + email + subscription_id) = url. Do not use this for any other applications. (See #29955)" + :encryption :when-encryption-key-set :visibility :internal :base setting/uuid-nonce-base) @@ -207,6 +218,7 @@ (deferred-tru (str "This URL is used for things like creating links in emails, auth redirects, and in some embedding scenarios, " "so changing it could break functionality or get you locked out of this instance.")) + :encryption :when-encryption-key-set :visibility :public :audit :getter :getter (fn [] @@ -234,7 +246,7 @@ :visibility :public :export? true :audit :getter - :encryption :never + :encryption :no :getter (fn [] (let [value (setting/get-value-of-type :string :site-locale)] (when (i18n/available-locale? value) @@ -248,6 +260,7 @@ (defsetting admin-email (deferred-tru "The email address users should be referred to if they encounter a problem.") :visibility :authenticated + :encryption :when-encryption-key-set :audit :getter) (defsetting anon-tracking-enabled @@ -260,6 +273,7 @@ (defsetting map-tile-server-url (deferred-tru "The map tile server URL template used in map visualizations, for example from OpenStreetMaps or MapBox.") + :encryption :no :default "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" :visibility :public :audit :getter) @@ -276,6 +290,7 @@ (defsetting landing-page (deferred-tru "Enter a URL of the landing page to show the user. This overrides the custom homepage setting above.") + :encryption :no :visibility :public :export? true :type :string @@ -325,6 +340,7 @@ (defsetting persisted-model-refresh-cron-schedule (deferred-tru "cron syntax string to schedule refreshing persisted models.") + :encryption :no :type :string :default "0 0 0/6 * * ? *" :visibility :admin @@ -366,6 +382,7 @@ (defsetting notification-link-base-url (deferred-tru "By default \"Site Url\" is used in notification links, but can be overridden.") + :encryption :no :visibility :internal :type :string :feature :whitelabel @@ -375,12 +392,14 @@ (defsetting deprecation-notice-version (deferred-tru "Metabase version for which a notice about usage of deprecated features has been shown.") + :encryption :no :visibility :admin :doc false :audit :never) (defsetting loading-message (deferred-tru "Choose the message to show while a query is running.") + :encryption :no :visibility :public :export? true :feature :whitelabel @@ -390,6 +409,7 @@ (defsetting application-colors (deferred-tru "Choose the colors used in the user interface throughout Metabase and others specifically for the charts. You need to refresh your browser to see your changes take effect.") + :encryption :no :visibility :public :export? true :type :json @@ -423,6 +443,7 @@ To change the chart colors: (defsetting application-font (deferred-tru "Replace “Lato†as the font family.") + :encryption :no :visibility :public :export? true :type :string @@ -437,6 +458,7 @@ To change the chart colors: (defsetting application-font-files (deferred-tru "Tell us where to find the file for each font weight. You don’t need to include all of them, but it’ll look better if you do.") + :encryption :no :visibility :public :export? true :type :json @@ -473,6 +495,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting application-logo-url (deferred-tru "Upload a file to replace the Metabase logo on the top bar.") + :encryption :no :visibility :public :export? true :type :string @@ -483,6 +506,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting application-favicon-url (deferred-tru "Upload a file to use as the favicon.") + :encryption :no :visibility :public :export? true :type :string @@ -501,6 +525,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting login-page-illustration (deferred-tru "Options for displaying the illustration on the login page.") + :encryption :no :visibility :public :export? true :type :string @@ -510,6 +535,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting login-page-illustration-custom (deferred-tru "The custom illustration for the login page.") + :encryption :no :visibility :public :export? true :type :string @@ -518,6 +544,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting landing-page-illustration (deferred-tru "Options for displaying the illustration on the landing page.") + :encryption :no :visibility :public :export? true :type :string @@ -527,6 +554,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting landing-page-illustration-custom (deferred-tru "The custom illustration for the landing page.") + :encryption :no :visibility :public :export? true :type :string @@ -535,6 +563,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting no-data-illustration (deferred-tru "Options for displaying the illustration when there are no results after running a question.") + :encryption :no :visibility :public :export? true :type :string @@ -544,6 +573,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting no-data-illustration-custom (deferred-tru "The custom illustration for when there are no results after running a question.") + :encryption :no :visibility :public :export? true :type :string @@ -552,6 +582,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting no-object-illustration (deferred-tru "Options for displaying the illustration when there are no results after searching.") + :encryption :no :visibility :public :export? true :type :string @@ -561,6 +592,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting no-object-illustration-custom (deferred-tru "The custom illustration for when there are no results after searching.") + :encryption :no :visibility :public :export? true :type :string @@ -602,6 +634,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting help-link-custom-destination (deferred-tru "Custom URL for the help link.") + :encryption :no :visibility :public :type :string :audit :getter @@ -654,6 +687,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting custom-formatting (deferred-tru "Object keyed by type, containing formatting settings") + :encryption :no :type :json :export? true :default {} @@ -701,6 +735,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting source-address-header (deferred-tru "Identify the source of HTTP requests by this header's value, instead of its remote address.") + :encryption :no :default "X-Forwarded-For" :export? true :audit :getter @@ -742,6 +777,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting has-sample-database? "Whether this instance has a Sample Database database" + :type :boolean :visibility :authenticated :setter :none :getter (fn [] (t2/exists? :model/Database, :is_sample true)) @@ -873,6 +909,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting uploads-settings (deferred-tru "Upload settings") + :encryption :when-encryption-key-set ; this doesn't really have an effect as this setting is not stored as a setting model :visibility :authenticated :export? false ; the data is exported with a database export, so we don't need to export a setting :type :json @@ -979,6 +1016,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting uploads-schema-name (deferred-tru "Schema name for uploads") :deprecated "0.50.0" + :encryption :no :visibility :internal :export? false :type :string @@ -987,6 +1025,7 @@ See [fonts](../configuring-metabase/fonts.md).") (defsetting uploads-table-prefix (deferred-tru "Prefix for upload table names") + :encryption :no :deprecated "0.50.0" :visibility :internal :export? false diff --git a/src/metabase/public_settings/premium_features.clj b/src/metabase/public_settings/premium_features.clj index ea4587b0bdaf48bde325941e552722f1d1ad9ceb..607a0c63cf6e3a14250dbf8ee62a7ade3a7b1044 100644 --- a/src/metabase/public_settings/premium_features.clj +++ b/src/metabase/public_settings/premium_features.clj @@ -6,6 +6,8 @@ [clojure.core.memoize :as memoize] [clojure.spec.alpha :as s] [clojure.string :as str] + [diehard.circuit-breaker :as dh.cb] + [diehard.core :as dh] [environ.core :refer [env]] [malli.core :as mc] [metabase.api.common :as api] @@ -110,26 +112,74 @@ [:max-users {:optional true} pos-int?] [:company {:optional true} [:string {:min 1}]]]) -(defn- fetch-token-and-parse-body* - [token base-url site-uuid] - (some-> (token-status-url token base-url) - (http/get {:query-params {:users (cached-active-users-count) - :site-uuid site-uuid - :mb-version (:tag config/mb-version-info)}}) - :body - (json/parse-string keyword))) +(def ^{:arglists '([token base-url site-uuid active-users-count])} fetch-token-and-parse-body* + "Caches API responses for 5 minutes. This is important to avoid making too many API calls to the Store, which will + throttle us if we make too many requests; putting in a bad token could otherwise put us in a state where + `valid-token->features*` made API calls over and over, never itself getting cached because checks failed. + + Note that we only cache successful responses, or 4XX responses! + + 5XX errors, timeouts, etc. may be transient and will NOT be cached." + (memoize/ttl + ^{::memoize/args-fn (fn [[token base-url site-uuid _active-users-count]] + [token base-url site-uuid])} + (fn [token base-url site-uuid active-users-count] + (let [{:keys [body status] :as resp} (some-> (token-status-url token base-url) + (http/get {:query-params {:users active-users-count + :site-uuid site-uuid + :mb-version (:tag config/mb-version-info)} + :throw-exceptions false}))] + (cond + (http/success? resp) (some-> body (json/parse-string keyword)) + + (<= 400 status 499) (some-> body (json/parse-string keyword)) + + ;; exceptions are not cached. + :else (throw (ex-info "An unknown error occurred when validating token." {:status status + :body body}))))) + + :ttl/threshold (u/minutes->ms 5))) + +(def ^:private store-circuit-breaker-config + {;; if 10 requests within 10 seconds fail, open the circuit breaker. + ;; (a lower threshold ratio wouldn't make sense here because successful results are cached, so as soon as we get + ;; one successful response we're guaranteed to only get successes until cache expiration) + :failure-threshold-ratio-in-period [10 10 (u/seconds->ms 10)] + ;; after the circuit is opened, wait 30 seconds before making any more requests to the store + :delay-ms (u/seconds->ms 30) + ;; when the circuit breaker is half-open, one request will be permitted. if it's successful, return to normal. + ;; otherwise we'll wait another 30 seconds. + :success-threshold 1}) + +(def ^:dynamic *store-circuit-breaker* + "A circuit breaker that short-circuits when requests to the API have repeatedly failed. + + This prevents a pathological scenario where the store has a temporary outage (long enough for the cache to expire) + and then all instances everywhere fire off constant requests to get token status. Instead, execution will constantly + fail instantly until the circuit breaker is closed." + (dh.cb/circuit-breaker store-circuit-breaker-config)) (defn- fetch-token-and-parse-body [token base-url site-uuid] - (let [fut (future (fetch-token-and-parse-body* token base-url site-uuid)) - result (deref fut fetch-token-status-timeout-ms ::timed-out)] - (if (not= result ::timed-out) - result - (do - (future-cancel fut) + (let [active-user-count (cached-active-users-count)] + (try + (dh/with-circuit-breaker *store-circuit-breaker* + (dh/with-timeout {:timeout-ms fetch-token-status-timeout-ms + :interrupt? true} + (try (fetch-token-and-parse-body* token base-url site-uuid active-user-count) + (catch Exception e + (throw e))))) + (catch dev.failsafe.TimeoutExceededException _e + {:valid false + :status (tru "Unable to validate token") + :error-details (tru "Token validation timed out.")}) + (catch dev.failsafe.CircuitBreakerOpenException _e {:valid false :status (tru "Unable to validate token") - :error-details (tru "Token validation timed out.")})))) + :error-details (tru "Token validation is currently unavailable.")}) + ;; other exceptions are wrapped by Diehard in a FailsafeException. Unwrap them before rethrowing. + (catch dev.failsafe.FailsafeException e + (throw (.getCause e)))))) ;;;;;;;;;;;;;;;;;;;; Airgap Tokens ;;;;;;;;;;;;;;;;;;;; (declare decode-airgap-token) @@ -164,22 +214,21 @@ (try (fetch-token-and-parse-body token token-check-url site-uuid) (catch Exception e1 ;; Unwrap exception from inside the future - (let [e1 (ex-cause e1)] - (log/errorf e1 "Error fetching token status from %s:" token-check-url) - ;; Try the fallback URL, which was the default URL prior to 45.2 - (try (fetch-token-and-parse-body token store-url site-uuid) - ;; if there was an error fetching the token from both the normal and fallback URLs, log the - ;; first error and return a generic message about the token being invalid. This message - ;; will get displayed in the Settings page in the admin panel so we do not want something - ;; complicated - (catch Exception e2 - (log/errorf (ex-cause e2) "Error fetching token status from %s:" store-url) - (let [body (u/ignore-exceptions (some-> (ex-data e1) :body (json/parse-string keyword)))] - (or - body - {:valid false - :status (tru "Unable to validate token") - :error-details (.getMessage e1)}))))))))) + (log/errorf e1 "Error fetching token status from %s:" token-check-url) + ;; Try the fallback URL, which was the default URL prior to 45.2 + (try (fetch-token-and-parse-body token store-url site-uuid) + ;; if there was an error fetching the token from both the normal and fallback URLs, log the + ;; first error and return a generic message about the token being invalid. This message + ;; will get displayed in the Settings page in the admin panel so we do not want something + ;; complicated + (catch Exception e2 + (log/errorf e2 "Error fetching token status from %s:" store-url) + (let [body (u/ignore-exceptions (some-> (ex-data e1) :body (json/parse-string keyword)))] + (or + body + {:valid false + :status (tru "Unable to validate token") + :error-details (.getMessage e1)})))))))) (mc/validate [:re AirgapToken] token) (do @@ -194,28 +243,11 @@ :error-details (trs "Token should be a valid 64 hexadecimal character token or an airgap token.")}))) (def ^{:arglists '([token])} fetch-token-status - "TTL-memoized version of `fetch-token-status*`. Caches API responses for 5 minutes. This is important to avoid making - too many API calls to the Store, which will throttle us if we make too many requests; putting in a bad token could - otherwise put us in a state where `valid-token->features*` made API calls over and over, never itself getting cached - because checks failed." - ;; don't blast the token status check API with requests if this gets called a bunch of times all at once -- wait for - ;; the first request to finish - (let [lock (Object.) - f (memoize/ttl - (fn [token] - ;; this is a sanity check to make sure we can actually get the active user count BEFORE we try to call - ;; [[fetch-token-status*]], because `fetch-token-status*` catches Exceptions and therefore caches failed - ;; results. We were running into issues in the e2e tests where `active-users-count` was timing out - ;; because of to weird timeouts after restoring the app DB from a snapshot, which would cause other - ;; tests to fail because a timed-out token check would get cached as a result. - (assert ((requiring-resolve 'metabase.db/db-is-set-up?)) "Metabase DB is not yet set up") - (u/with-timeout (u/seconds->ms 5) - (cached-active-users-count)) - (fetch-token-status* token)) - :ttl/threshold (u/minutes->ms 5))] + "Locked vesrion of `fetch-token-status` allowing one request at a time." + (let [lock (Object.)] (fn [token] (locking lock - (f token))))) + (fetch-token-status* token))))) (declare token-valid-now?) @@ -305,6 +337,13 @@ (cached-logger (premium-embedding-token) e) #{})))) +(mu/defn plan-alias :- [:maybe :string] + "Returns a string representing the instance's current plan, if included in the last token status request." + [] + (some-> (premium-embedding-token) + fetch-token-status + :plan-alias)) + (defn has-any-features? "True if we have a valid premium features token with ANY features." [] diff --git a/src/metabase/query_processor/middleware/add_default_temporal_unit.clj b/src/metabase/query_processor/middleware/add_default_temporal_unit.clj index 28dfb5b68dd2f1e80fb96d8fd50d1163bf3f96b7..ec72f398d6be3fb9e9fb47fb0f40a2631661dbba 100644 --- a/src/metabase/query_processor/middleware/add_default_temporal_unit.clj +++ b/src/metabase/query_processor/middleware/add_default_temporal_unit.clj @@ -1,14 +1,13 @@ (ns metabase.query-processor.middleware.add-default-temporal-unit (:require + [metabase.driver :as driver] + [metabase.driver.util :as driver.u] [metabase.legacy-mbql.util :as mbql.u] [metabase.lib.metadata :as lib.metadata] [metabase.lib.util.match :as lib.util.match] [metabase.query-processor.store :as qp.store])) -(defn add-default-temporal-unit - "Add `:temporal-unit` `:default` to any temporal `:field` clauses that don't already have a `:temporal-unit`. This - makes things more consistent because code downstream can rely on the key being present." - [query] +(defn- add-default-temporal-unit* [query] (lib.util.match/replace-in query [:query] [:field (_ :guard string?) (_ :guard (every-pred :base-type @@ -20,3 +19,13 @@ (let [{:keys [base-type effective-type]} (lib.metadata/field (qp.store/metadata-provider) id)] (cond-> &match (isa? (or effective-type base-type) :type/Temporal) (mbql.u/with-temporal-unit :default))))) + +(defn add-default-temporal-unit + "Add `:temporal-unit` `:default` to any temporal `:field` clauses that don't already have a `:temporal-unit`. This + makes things more consistent because code downstream can rely on the key being present. + + Only activates for drivers with the `:temporal/requires-default-unit` feature." + [query] + (let [database (lib.metadata/database (qp.store/metadata-provider))] + (cond-> query + (driver.u/supports? driver/*driver* :temporal/requires-default-unit database) add-default-temporal-unit*))) diff --git a/src/metabase/query_processor/middleware/add_implicit_clauses.clj b/src/metabase/query_processor/middleware/add_implicit_clauses.clj index 8e9cec02ccfe5e6e3c04f9a916e06457653b5412..4152473839cf526218901a760839e6f0e7c83a60 100644 --- a/src/metabase/query_processor/middleware/add_implicit_clauses.clj +++ b/src/metabase/query_processor/middleware/add_implicit_clauses.clj @@ -6,7 +6,6 @@ [metabase.legacy-mbql.util :as mbql.u] [metabase.lib.metadata :as lib.metadata] [metabase.lib.schema.id :as lib.schema.id] - [metabase.lib.types.isa :as lib.types.isa] [metabase.lib.util.match :as lib.util.match] [metabase.query-processor.error-type :as qp.error-type] [metabase.query-processor.store :as qp.store] @@ -40,8 +39,7 @@ (fn [field] ;; implicit datetime Fields get bucketing of `:default`. This is so other middleware doesn't try to give it ;; default bucketing of `:day` - [:field (u/the-id field) (when (lib.types.isa/temporal? field) - {:temporal-unit :default})]) + [:field (u/the-id field) nil]) fields))) (defn- multiply-bucketed-field-refs diff --git a/src/metabase/query_processor/middleware/wrap_value_literals.clj b/src/metabase/query_processor/middleware/wrap_value_literals.clj index 8b1ee12419d527a2a9eb088a6b0bff2cc808b39d..409f80409652c5e930aee9a378e36d7ffd186a9d 100644 --- a/src/metabase/query_processor/middleware/wrap_value_literals.clj +++ b/src/metabase/query_processor/middleware/wrap_value_literals.clj @@ -39,15 +39,8 @@ (defmethod type-info :metadata/column [field] ;; Opts should probably override all of these - (let [field-info (-> (select-keys field [:base-type :effective-type :coercion-strategy :semantic-type :database-type :name]) - (update-keys u/->snake_case_en))] - (merge - field-info - ;; add in a default unit for this Field so we know to wrap datetime strings in `absolute-datetime` below based on - ;; its presence. Its unit will get replaced by the`:temporal-unit` in `:field` options in the method below if - ;; present - (when (types/temporal-field? field-info) - {:unit :default})))) + (-> (select-keys field [:base-type :effective-type :coercion-strategy :semantic-type :database-type :name]) + (update-keys u/->snake_case_en))) (defn- str-id-field->type-info "Return _type info_ for `_field` with string `field-name`, coming from the source query or joins." @@ -79,8 +72,6 @@ (defmethod type-info :expression [[_ _name opts]] (merge - (when (isa? (:base-type opts) :type/Temporal) - {:unit :default}) (when (:temporal-unit opts) {:unit (:temporal-unit opts)}) (when (:base-type opts) @@ -260,11 +251,11 @@ (defmethod add-type-info String [s {:keys [unit], :as info} & {:keys [parse-datetime-strings?] :or {parse-datetime-strings? true}}] - (if (and unit + (if (and (or unit (when info (types/temporal-field? info))) parse-datetime-strings? (seq s)) (let [effective-type ((some-fn :effective_type :base_type) info)] - (parse-temporal-string-literal effective-type s unit)) + (parse-temporal-string-literal effective-type s (or unit :default))) [:value s info])) ;;; -------------------------------------------- wrap-literals-in-clause --------------------------------------------- diff --git a/src/metabase/query_processor/pivot.clj b/src/metabase/query_processor/pivot.clj index fcc51208709c5128e9dabe91432ba598ea50d212..3e0d6bbafd830cf3df896b870ec7085e217b032a 100644 --- a/src/metabase/query_processor/pivot.clj +++ b/src/metabase/query_processor/pivot.clj @@ -359,7 +359,12 @@ mlv2-query (lib/query metadata-provider query) breakouts (into [] (map-indexed (fn [i col] - (assoc col ::i i))) + (cond-> col + true (assoc ::i i) + ;; if the col has a card-id, we swap the :lib/source to say source/card + ;; this allows `lib/find-matching-column` to properly match a column that has a join-alias + ;; but whose source is a model + (contains? col :lib/card-id) (assoc :lib/source :source/card)))) (lib/breakouts-metadata mlv2-query))] (fn [legacy-ref] (try diff --git a/src/metabase/search.clj b/src/metabase/search.clj index fb733bf5990bb43dab8ce33c5d9d234675ebdcf0..5fca5ec963211fc33185128e540a76b61b339c5d 100644 --- a/src/metabase/search.clj +++ b/src/metabase/search.clj @@ -1,94 +1,47 @@ (ns metabase.search - "API namespace for the `metabase.search` module. - - TODO: a lot of this stuff wouldn't need to be exposed if we moved more of the search stuff - from [[metabase.api.search]] into the `metabase.search` module." + "API namespace for the `metabase.search` module" (:require - [metabase.db] + [metabase.db :as mdb] + [metabase.search.api :as search.api] [metabase.search.config :as search.config] + [metabase.search.fulltext :as search.fulltext] [metabase.search.impl :as search.impl] [metabase.search.postgres.core :as search.postgres] - [metabase.search.scoring :as scoring] - [metabase.util.log :as log] - [metabase.util.malli :as mu] [potemkin :as p])) (set! *warn-on-reflection* true) +(comment + search.api/keep-me + search.config/keep-me + search.impl/keep-me) + (p/import-vars [search.config SearchableModel all-models] + [search.api + model-set] [search.impl - query-model-set + search + ;; We could avoid exposing this by wrapping `query-model-set` and `search` with it. search-context]) -(defn is-postgres? - "Check whether we can create this index" - [] - (= :postgres (metabase.db/db-type))) - -(def ^:private default-engine :in-place) - -(defn- query-fn [search-engine] - (or - (case search-engine - :fulltext (when (is-postgres?) search.postgres/search) - :minimal (when (is-postgres?) search.postgres/search) - :in-place search.impl/in-place - nil) - - (log/warnf "%s search not supported for your AppDb, using %s" search-engine default-engine) - (recur default-engine))) - -(defn- model-set-fn [search-engine] - (or - (case search-engine - :fulltext (when (is-postgres?) search.postgres/model-set) - :minimal (when (is-postgres?) search.postgres/model-set) - :in-place search.impl/query-model-set - nil) - - (log/warnf "%s search not supported for your AppDb, using %s" search-engine default-engine) - (recur default-engine))) - -(defn- score-fn [search-engine] - (or - (case search-engine - :fulltext (when (is-postgres?) search.postgres/no-scoring) - :minimal (when (is-postgres?) search.postgres/no-scoring) - :in-place scoring/score-and-result - nil) - - (log/warnf "%s search not supported for your AppDb, using %s" search-engine default-engine) - (recur default-engine))) +;; TODO The following need to be cleaned up to use multimethods. (defn supports-index? - "Does this instance support a search index, e.g. has the right kind of AppDb" + "Does this instance support a search index?" [] - (is-postgres?)) + (search.fulltext/supported-db? (mdb/db-type))) (defn init-index! "Ensure there is an index ready to be populated." [& {:keys [force-reset?]}] - (when (is-postgres?) + (when (supports-index?) (search.postgres/init! force-reset?))) (defn reindex! "Populate a new index, and make it active. Simultaneously updates the current index." [] - (when (is-postgres?) + (when (supports-index?) (search.postgres/reindex!))) - -(mu/defn search - "Builds a search query that includes all the searchable entities and runs it" - [search-ctx :- search.config/SearchContext] - (let [engine (:search-engine search-ctx :in-place) - query-fn (query-fn engine) - score-fn (score-fn engine) - models-fn (model-set-fn engine)] - (search.impl/search - query-fn - models-fn - score-fn - search-ctx))) diff --git a/src/metabase/search/api.clj b/src/metabase/search/api.clj new file mode 100644 index 0000000000000000000000000000000000000000..b267bc62cb410b7b303c95aeb1eafc91c3d4345e --- /dev/null +++ b/src/metabase/search/api.clj @@ -0,0 +1,9 @@ +(ns metabase.search.api) + +;; TODO wrap these functions in Malli signatures + +(defmulti results "Find results matching the given search query." :search-engine) + +(defmulti model-set "Determine which models would have at least one result." :search-engine) + +(defmulti score "Rank the search results." (fn [_ {se :search-engine}] se)) diff --git a/src/metabase/search/config.clj b/src/metabase/search/config.clj index e87f01ca4490fadc937ce75a90ef0e27cd850880..72bfff03bae584a3eff0426265e4ccc66455b388 100644 --- a/src/metabase/search/config.clj +++ b/src/metabase/search/config.clj @@ -20,12 +20,6 @@ :export? true :audit :getter) -(def search-engines - "Supported search engines." - #{:in-place - :fulltext - :minimal}) - (def ^:dynamic *db-max-results* "Number of raw results to fetch from the database. This number is in place to prevent massive application DB load by returning tons of results; this number should probably be adjusted downward once we have UI in place to indicate @@ -107,6 +101,8 @@ (def SearchContext "Map with the various allowed search parameters, used to construct the SQL query." [:map {:closed true} + ;; display related + [:calculate-available-models? {:optional true} :boolean] ;; ;; required ;; @@ -116,7 +112,9 @@ [:current-user-perms [:set perms.u/PathSchema]] [:model-ancestors? :boolean] [:models [:set SearchableModel]] - [:search-string [:maybe ms/NonBlankString]] + ;; TODO this is optional only for tests, clean those up! + [:search-engine {:optional true} keyword?] + [:search-string {:optional true} [:maybe ms/NonBlankString]] ;; ;; optional ;; @@ -127,7 +125,6 @@ [:last-edited-by {:optional true} [:set {:min 1} ms/PositiveInt]] [:limit-int {:optional true} ms/Int] [:offset-int {:optional true} ms/Int] - [:search-engine {:optional true} (into [:enum] search-engines)] [:search-native-query {:optional true} true?] [:table-db-id {:optional true} ms/PositiveInt] ;; true to search for verified items only, nil will return all items diff --git a/src/metabase/search/fulltext.clj b/src/metabase/search/fulltext.clj new file mode 100644 index 0000000000000000000000000000000000000000..a37a4de13cb0e5fbb0be2f900751a53b751b82ba --- /dev/null +++ b/src/metabase/search/fulltext.clj @@ -0,0 +1,32 @@ +(ns metabase.search.fulltext + (:require + [metabase.public-settings :as public-settings] + [metabase.search.api :as search.api] + [metabase.search.postgres.core :as search.postgres])) + +;; We have a bunch of experimental flavors! 🧄🌶🊠+(derive :search.engine/hybrid :search.engine/fulltext) +(derive :search.engine/hybrid-multi :search.engine/fulltext) +(derive :search.engine/minimal :search.engine/fulltext) +(derive :search.engine/minimal-with-perms :search.engine/fulltext) + +(defmulti supported-db? "Does the app db support fulltext search?" identity) + +(defmethod supported-db? :default [_] false) + +(defmethod supported-db? :postgres [_] + (public-settings/experimental-fulltext-search-enabled)) + +;; For now we now that the app db is postgres. We can make these multimethods when that changes. + +(defmethod search.api/results :search.engine/fulltext + [search-ctx] + (search.postgres/search search-ctx)) + +(defmethod search.api/model-set :search.engine/fulltext + [search-ctx] + (search.postgres/model-set search-ctx)) + +(defmethod search.api/score :search.engine/fulltext + [results search-ctx] + (search.postgres/no-scoring results search-ctx)) diff --git a/src/metabase/search/impl.clj b/src/metabase/search/impl.clj index 8106f9abb0a588fd9fcbe82fb483ed56c4cb97b1..27375e237edbe303bd4bdc9e7775e336901604d3 100644 --- a/src/metabase/search/impl.clj +++ b/src/metabase/search/impl.clj @@ -2,10 +2,7 @@ (:require [cheshire.core :as json] [clojure.string :as str] - [honey.sql.helpers :as sql.helpers] - [medley.core :as m] [metabase.db :as mdb] - [metabase.db.query :as mdb.query] [metabase.legacy-mbql.normalize :as mbql.normalize] [metabase.lib.core :as lib] [metabase.models.collection :as collection] @@ -13,18 +10,15 @@ [metabase.models.data-permissions :as data-perms] [metabase.models.database :as database] [metabase.models.interface :as mi] - [metabase.models.permissions :as perms] [metabase.permissions.util :as perms.u] - [metabase.public-settings :as public-settings] [metabase.public-settings.premium-features :as premium-features] + [metabase.search.api :as search.api] [metabase.search.config :as search.config :refer [SearchableModel SearchContext]] [metabase.search.filter :as search.filter] + [metabase.search.fulltext :as search.fulltext] [metabase.search.scoring :as scoring] - [metabase.search.util :as search.util] - [metabase.util :as u] - [metabase.util.honey-sql-2 :as h2x] [metabase.util.i18n :refer [tru deferred-tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] @@ -36,303 +30,6 @@ (set! *warn-on-reflection* true) -(def ^:private HoneySQLColumn - [:or - :keyword - [:tuple :any :keyword]]) - -(mu/defn- ->column-alias :- keyword? - "Returns the column name. If the column is aliased, i.e. [`:original_name` `:aliased_name`], return the aliased - column name" - [column-or-aliased :- HoneySQLColumn] - (if (sequential? column-or-aliased) - (second column-or-aliased) - column-or-aliased)) - -(mu/defn- canonical-columns :- [:sequential HoneySQLColumn] - "Returns a seq of canonicalized list of columns for the search query with the given `model` Will return column names - prefixed with the `model` name so that it can be used in criteria. Projects a `nil` for columns the `model` doesn't - have and doesn't modify aliases." - [model :- SearchableModel, col-alias->honeysql-clause :- [:map-of :keyword HoneySQLColumn]] - (for [[search-col col-type] search.config/all-search-columns - :let [maybe-aliased-col (get col-alias->honeysql-clause search-col)]] - (cond - (= search-col :model) - [(h2x/literal model) :model] - - ;; This is an aliased column, no need to include the table alias - (sequential? maybe-aliased-col) - maybe-aliased-col - - ;; This is a column reference, need to add the table alias to the column - maybe-aliased-col - (search.config/column-with-model-alias model maybe-aliased-col) - - ;; This entity is missing the column, project a null for that column value. For Postgres and H2, cast it to the - ;; correct type, e.g. - ;; - ;; SELECT cast(NULL AS integer) - ;; - ;; For MySQL, this is not needed. - :else - [(when-not (= (mdb/db-type) :mysql) - [:cast nil col-type]) - search-col]))) - -(mu/defn- select-clause-for-model :- [:sequential HoneySQLColumn] - "The search query uses a `union-all` which requires that there be the same number of columns in each of the segments - of the query. This function will take the columns for `model` and will inject constant `nil` values for any column - missing from `entity-columns` but found in `search.config/all-search-columns`." - [model :- SearchableModel] - (let [entity-columns (search.config/columns-for-model model) - column-alias->honeysql-clause (m/index-by ->column-alias entity-columns) - cols-or-nils (canonical-columns model column-alias->honeysql-clause)] - cols-or-nils)) - -(mu/defn- from-clause-for-model :- [:tuple [:tuple :keyword :keyword]] - [model :- SearchableModel] - (let [{:keys [db-model alias]} (get search.config/model-to-db-model model)] - [[(t2/table-name db-model) alias]])) - -(mu/defn- base-query-for-model :- [:map {:closed true} - [:select :any] - [:from :any] - [:where {:optional true} :any] - [:join {:optional true} :any] - [:left-join {:optional true} :any]] - "Create a HoneySQL query map with `:select`, `:from`, and `:where` clauses for `model`, suitable for the `UNION ALL` - used in search." - [model :- SearchableModel context :- SearchContext] - (-> {:select (select-clause-for-model model) - :from (from-clause-for-model model)} - (search.filter/build-filters model context))) - -(mu/defn add-collection-join-and-where-clauses - "Add a `WHERE` clause to the query to only return Collections the Current User has access to; join against Collection - so we can return its `:name`." - [honeysql-query :- ms/Map - model :- :string - {:keys [filter-items-in-personal-collection - archived - current-user-id - is-superuser?]} :- SearchContext] - (let [collection-id-col (if (= model "collection") - :collection.id - :collection_id) - collection-filter-clause (collection/visible-collection-filter-clause - collection-id-col - {:include-archived-items :all - :include-trash-collection? true - :permission-level (if archived - :write - :read)} - {:current-user-id current-user-id - :is-superuser? is-superuser?})] - (cond-> honeysql-query - true - (sql.helpers/where collection-filter-clause (perms/audit-namespace-clause :collection.namespace nil)) - ;; add a JOIN against Collection *unless* the source table is already Collection - (not= model "collection") - (sql.helpers/left-join [:collection :collection] - [:= collection-id-col :collection.id]) - - (some? filter-items-in-personal-collection) - (sql.helpers/where - (case filter-items-in-personal-collection - "only" - (concat [:or] - ;; sub personal collections - (for [id (t2/select-pks-set :model/Collection :personal_owner_id [:not= nil])] - [:like :collection.location (format "/%d/%%" id)]) - ;; top level personal collections - [[:and - [:= :collection.location "/"] - [:not= :collection.personal_owner_id nil]]]) - - "exclude" - (conj [:or] - (into - [:and [:= :collection.personal_owner_id nil]] - (for [id (t2/select-pks-set :model/Collection :personal_owner_id [:not= nil])] - [:not-like :collection.location (format "/%d/%%" id)])) - [:= collection-id-col nil])))))) - -(mu/defn- add-table-db-id-clause - "Add a WHERE clause to only return tables with the given DB id. - Used in data picker for joins because we can't join across DB's." - [query :- ms/Map id :- [:maybe ms/PositiveInt]] - (if (some? id) - (sql.helpers/where query [:= id :db_id]) - query)) - -(mu/defn- add-card-db-id-clause - "Add a WHERE clause to only return cards with the given DB id. - Used in data picker for joins because we can't join across DB's." - [query :- ms/Map id :- [:maybe ms/PositiveInt]] - (if (some? id) - (sql.helpers/where query [:= id :database_id]) - query)) - -(mu/defn- replace-select :- :map - "Replace a select from query that has alias is `target-alias` with [`with` `target-alias`] column, throw an error if - can't find the target select. - - This works with the assumption that `query` contains a list of select from [[select-clause-for-model]], - and some of them are dummy column casted to the correct type. - - This function then will replace the dummy column with alias is `target-alias` with the `with` column." - [query :- :map - target-alias :- :keyword - with :- :keyword] - (let [selects (:select query) - idx (first (keep-indexed (fn [index item] - (when (and (coll? item) - (= (last item) target-alias)) - index)) - selects)) - with-select [with target-alias]] - (if (some? idx) - (assoc query :select (m/replace-nth idx with-select selects)) - (throw (ex-info "Failed to replace selector" {:status-code 400 - :target-alias target-alias - :with with}))))) - -(mu/defn- with-last-editing-info :- :map - [query :- :map - model :- [:enum "card" "dashboard"]] - (-> query - (replace-select :last_editor_id :r.user_id) - (replace-select :last_edited_at :r.timestamp) - (sql.helpers/left-join [:revision :r] - [:and [:= :r.model_id (search.config/column-with-model-alias model :id)] - [:= :r.most_recent true] - [:= :r.model (search.config/search-model->revision-model model)]]))) - -(mu/defn- with-moderated-status :- :map - [query :- :map - model :- [:enum "card" "dataset"]] - (-> query - (replace-select :moderated_status :mr.status) - (sql.helpers/left-join [:moderation_review :mr] - [:and - [:= :mr.moderated_item_type "card"] - [:= :mr.moderated_item_id (search.config/column-with-model-alias model :id)] - [:= :mr.most_recent true]]))) - -(defmulti ^:private search-query-for-model - {:arglists '([model search-context])} - (fn [model _] model)) - -(mu/defn- shared-card-impl - [model :- :metabase.models.card/type - search-ctx :- SearchContext] - (-> (base-query-for-model "card" search-ctx) - (sql.helpers/where [:= :card.type (name model)]) - (sql.helpers/left-join [:card_bookmark :bookmark] - [:and - [:= :bookmark.card_id :card.id] - [:= :bookmark.user_id (:current-user-id search-ctx)]]) - (add-collection-join-and-where-clauses "card" search-ctx) - (add-card-db-id-clause (:table-db-id search-ctx)) - (with-last-editing-info "card") - (with-moderated-status "card"))) - -(defmethod search-query-for-model "action" - [model search-ctx] - (-> (base-query-for-model model search-ctx) - (sql.helpers/left-join [:report_card :model] - [:= :model.id :action.model_id]) - (sql.helpers/left-join :query_action - [:= :query_action.action_id :action.id]) - (add-collection-join-and-where-clauses model search-ctx))) - -(defmethod search-query-for-model "card" - [_model search-ctx] - (shared-card-impl :question search-ctx)) - -(defmethod search-query-for-model "dataset" - [_model search-ctx] - (-> (shared-card-impl :model search-ctx) - (update :select (fn [columns] - (cons [(h2x/literal "dataset") :model] (rest columns)))))) - -(defmethod search-query-for-model "metric" - [_model search-ctx] - (-> (shared-card-impl :metric search-ctx) - (update :select (fn [columns] - (cons [(h2x/literal "metric") :model] (rest columns)))))) - -(defmethod search-query-for-model "collection" - [model search-ctx] - (-> (base-query-for-model "collection" search-ctx) - (sql.helpers/left-join [:collection_bookmark :bookmark] - [:and - [:= :bookmark.collection_id :collection.id] - [:= :bookmark.user_id (:current-user-id search-ctx)]]) - (add-collection-join-and-where-clauses model search-ctx))) - -(defmethod search-query-for-model "database" - [model search-ctx] - (base-query-for-model model search-ctx)) - -(defmethod search-query-for-model "dashboard" - [model search-ctx] - (-> (base-query-for-model model search-ctx) - (sql.helpers/left-join [:dashboard_bookmark :bookmark] - [:and - [:= :bookmark.dashboard_id :dashboard.id] - [:= :bookmark.user_id (:current-user-id search-ctx)]]) - (add-collection-join-and-where-clauses model search-ctx) - (with-last-editing-info "dashboard"))) - -(defn- add-model-index-permissions-clause - [query {:keys [current-user-id is-superuser?]}] - (sql.helpers/where - query - (collection/visible-collection-filter-clause - :collection_id - {} - {:current-user-id current-user-id - :is-superuser? is-superuser?}))) - -(defmethod search-query-for-model "indexed-entity" - [model search-ctx] - (-> (base-query-for-model model search-ctx) - (sql.helpers/left-join [:model_index :model-index] - [:= :model-index.id :model-index-value.model_index_id]) - (sql.helpers/left-join [:report_card :model] [:= :model-index.model_id :model.id]) - (sql.helpers/left-join [:collection :collection] [:= :model.collection_id :collection.id]) - (add-model-index-permissions-clause search-ctx))) - -(defmethod search-query-for-model "segment" - [model search-ctx] - (-> (base-query-for-model model search-ctx) - (sql.helpers/left-join [:metabase_table :table] [:= :segment.table_id :table.id]))) - -(defmethod search-query-for-model "table" - [model {:keys [current-user-perms table-db-id], :as search-ctx}] - (when (seq current-user-perms) - - (-> (base-query-for-model model search-ctx) - (add-table-db-id-clause table-db-id) - (sql.helpers/left-join :metabase_database [:= :table.db_id :metabase_database.id])))) - -(defn order-clause - "CASE expression that lets the results be ordered by whether they're an exact (non-fuzzy) match or not" - [query] - (let [match (search.util/wildcard-match (search.util/normalize query)) - columns-to-search (->> search.config/all-search-columns - (filter (fn [[_k v]] (= v :text))) - (map first) - (remove #{:collection_authority_level :moderated_status - :initial_sync_status :pk_ref :location - :collection_location})) - case-clauses (as-> columns-to-search <> - (map (fn [col] [:like [:lower col] match]) <>) - (interleave <> (repeat [:inline 0])) - (concat <> [:else [:inline 1]]))] - [(into [:case] case-clauses)])) - (defmulti ^:private check-permissions-for-model {:arglists '([search-ctx search-result])} (fn [_search-ctx search-result] ((comp keyword :model) search-result))) @@ -397,43 +94,6 @@ (can-write? search-ctx instance) (can-read? search-ctx instance))) -(mu/defn query-model-set :- [:set SearchableModel] - "Queries all models with respect to query for one result to see if we get a result or not" - [search-ctx :- SearchContext] - (let [model-queries (for [model (search.filter/search-context->applicable-models - (assoc search-ctx :models search.config/all-models))] - {:nest (sql.helpers/limit (search-query-for-model model search-ctx) 1)}) - query (when (pos-int? (count model-queries)) - {:select [:*] - :from [[{:union-all model-queries} :dummy_alias]]})] - (set (some->> query - mdb.query/query - (map :model) - set)))) - -(mu/defn full-search-query - "Postgres 9 is not happy with the type munging it needs to do to make the union-all degenerate down to trivial case of - one model without errors. Therefore we degenerate it down for it" - [search-ctx :- SearchContext] - (let [models (:models search-ctx) - order-clause [((fnil order-clause "") (:search-string search-ctx))]] - (cond - (= (count models) 0) - {:select [nil]} - - (= (count models) 1) - (merge (search-query-for-model (first models) search-ctx) - {:limit search.config/*db-max-results*}) - - :else - {:select [:*] - :from [[{:union-all (vec (for [model models - :let [query (search-query-for-model model search-ctx)] - :when (seq query)] - query))} :alias_is_required_by_sql_but_not_needed_here]] - :order-by order-clause - :limit search.config/*db-max-results*}))) - (defn- hydrate-user-metadata "Hydrate common-name for last_edited_by and created_by for each result." [results] @@ -537,28 +197,42 @@ (not (zero? v)) v)) -(def ^:private default-engine :in-place) +(defmulti supported-engine? "Does this instance support the given engine?" keyword) + +(defmethod supported-engine? :search.engine/in-place [_] true) +(defmethod supported-engine? :search.engine/fulltext [_] (search.fulltext/supported-db? (mdb/db-type))) + +(def ^:private default-engine :search.engine/in-place) -(defn- allowed-engine? [engine] - (case engine - :in-place true - :minimal (public-settings/experimental-fulltext-search-enabled) - :fulltext (public-settings/experimental-fulltext-search-enabled))) +(defn- known-engine? [engine] + (let [registered? #(contains? (methods supported-engine?) %)] + (some registered? (cons engine (ancestors engine))))) (defn- parse-engine [value] (or (when-not (str/blank? value) - (let [engine (keyword value)] + (let [engine (keyword "search.engine" value)] (cond - (not (contains? search.config/search-engines engine)) - (log/warnf "Unknown search-engine: %s" value) + (not (known-engine? engine)) + (log/warnf "Search-engine is unknown: %s" value) - (not (allowed-engine? engine)) - (log/warnf "Forbidden search-engine: %s" value) + (not (supported-engine? engine)) + (log/warnf "Search-engine is not supported: %s" value) :else engine))) default-engine)) +;; This forwarding is here for tests, we should clean those up. + +(defmethod search.api/results :default [search-ctx] + (search.api/results (assoc search-ctx :search-engine default-engine))) + +(defmethod search.api/model-set :default [search-ctx] + (search.api/model-set (assoc search-ctx :search-engine default-engine))) + +(defmethod search.api/score :default [results search-ctx] + (search.api/score results (assoc search-ctx :search-engine default-engine))) + (mr/def ::search-context.input [:map {:closed true} [:search-string [:maybe ms/NonBlankString]] @@ -615,6 +289,7 @@ :current-user-perms current-user-perms :model-ancestors? (boolean model-ancestors?) :models models + :search-engine (parse-engine search-engine) :search-string search-string} (some? created-at) (assoc :created-at created-at) (seq created-by) (assoc :created-by created-by) @@ -624,7 +299,6 @@ (some? table-db-id) (assoc :table-db-id table-db-id) (some? limit) (assoc :limit-int limit) (some? offset) (assoc :offset-int offset) - (some? search-engine) (assoc :search-engine (parse-engine search-engine)) (some? search-native-query) (assoc :search-native-query search-native-query) (some? verified) (assoc :verified verified) (seq ids) (assoc :ids ids))] @@ -633,15 +307,6 @@ (throw (ex-info (tru "Filtering by ids work only when you ask for a single model") {:status-code 400}))) (assoc ctx :models (search.filter/search-context->applicable-models ctx)))) -(defn in-place - "Return a reducible-query corresponding to searching the entities without an index." - [search-ctx] - (let [search-query (full-search-query search-ctx)] - (log/tracef "Searching with query:\n%s\n%s" - (u/pprint-to-str search-query) - (mdb.query/format-sql (first (mdb.query/compile search-query)))) - (t2/reducible-query search-query))) - (defn- to-toucan-instance [row] (let [model (-> row :model search.config/model-to-db-model :db-model)] (t2.instance/instance model row))) @@ -698,23 +363,18 @@ (mu/defn search "Builds a search query that includes all the searchable entities, and runs it." - ([search-ctx :- search.config/SearchContext] - (search in-place query-model-set scoring/score-and-result search-ctx)) - ([results-fn - model-set-fn - score-fn - search-ctx :- search.config/SearchContext] - (let [reducible-results (results-fn search-ctx) - scoring-ctx (select-keys search-ctx [:search-string :search-native-query]) - xf (comp - (take search.config/*db-max-results*) - (map normalize-result) - (filter (partial check-permissions-for-model search-ctx)) - (map (partial normalize-result-more search-ctx)) - (keep #(score-fn % scoring-ctx))) - total-results (cond->> (scoring/top-results reducible-results search.config/max-filtered-results xf) - true hydrate-user-metadata - (:model-ancestors? search-ctx) (add-dataset-collection-hierarchy) - true (add-collection-effective-location) - true (map serialize))] - (search-results search-ctx model-set-fn total-results)))) + [search-ctx :- search.config/SearchContext] + (let [reducible-results (search.api/results search-ctx) + scoring-ctx (select-keys search-ctx [:search-string :search-native-query]) + xf (comp + (take search.config/*db-max-results*) + (map normalize-result) + (filter (partial check-permissions-for-model search-ctx)) + (map (partial normalize-result-more search-ctx)) + (keep #(search.api/score % scoring-ctx))) + total-results (cond->> (scoring/top-results reducible-results search.config/max-filtered-results xf) + true hydrate-user-metadata + (:model-ancestors? search-ctx) (add-dataset-collection-hierarchy) + true (add-collection-effective-location) + true (map serialize))] + (search-results search-ctx search.api/model-set total-results))) diff --git a/src/metabase/search/legacy.clj b/src/metabase/search/legacy.clj new file mode 100644 index 0000000000000000000000000000000000000000..57581159e4f44e4b78cd0a8130f48c178bd6690d --- /dev/null +++ b/src/metabase/search/legacy.clj @@ -0,0 +1,367 @@ +(ns metabase.search.legacy + (:require + [honey.sql.helpers :as sql.helpers] + [medley.core :as m] + [metabase.db :as mdb] + [metabase.db.query :as mdb.query] + [metabase.models.collection :as collection] + [metabase.models.permissions :as perms] + [metabase.search.api :as search.api] + [metabase.search.config + :as search.config + :refer [SearchContext SearchableModel]] + [metabase.search.filter :as search.filter] + [metabase.search.scoring :as scoring] + [metabase.search.util :as search.util] + [metabase.util :as u] + [metabase.util.honey-sql-2 :as h2x] + [metabase.util.log :as log] + [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] + [toucan2.core :as t2])) + +(def ^:private HoneySQLColumn + [:or + :keyword + [:tuple :any :keyword]]) + +(mu/defn- ->column-alias :- keyword? + "Returns the column name. If the column is aliased, i.e. [`:original_name` `:aliased_name`], return the aliased + column name" + [column-or-aliased :- HoneySQLColumn] + (if (sequential? column-or-aliased) + (second column-or-aliased) + column-or-aliased)) + +(mu/defn- canonical-columns :- [:sequential HoneySQLColumn] + "Returns a seq of lists of canonical columns for the search query with the given `model` Will return column names + prefixed with the `model` name so that it can be used in criteria. Projects a `nil` for columns the `model` doesn't + have and doesn't modify aliases." + [model :- SearchableModel, col-alias->honeysql-clause :- [:map-of :keyword HoneySQLColumn]] + (for [[search-col col-type] search.config/all-search-columns + :let [maybe-aliased-col (get col-alias->honeysql-clause search-col)]] + (cond + (= search-col :model) + [(h2x/literal model) :model] + + ;; This is an aliased column, no need to include the table alias + (sequential? maybe-aliased-col) + maybe-aliased-col + + ;; This is a column reference, need to add the table alias to the column + maybe-aliased-col + (search.config/column-with-model-alias model maybe-aliased-col) + + ;; This entity is missing the column, project a null for that column value. For Postgres and H2, cast it to the + ;; correct type, e.g., + ;; + ;; SELECT cast(NULL AS integer) + ;; + ;; For MySQL, this is not needed. + :else + [(when-not (= (mdb/db-type) :mysql) + [:cast nil col-type]) + search-col]))) + +(mu/defn- add-table-db-id-clause + "Add a WHERE clause to only return tables with the given DB id. + Used in data picker for joins because we can't join across DB's." + [query :- ms/Map id :- [:maybe ms/PositiveInt]] + (if (some? id) + (sql.helpers/where query [:= id :db_id]) + query)) + +(mu/defn- add-card-db-id-clause + "Add a WHERE clause to only return cards with the given DB id. + Used in data picker for joins because we can't join across DB's." + [query :- ms/Map id :- [:maybe ms/PositiveInt]] + (if (some? id) + (sql.helpers/where query [:= id :database_id]) + query)) + +(mu/defn- replace-select :- :map + "Replace a select from query that has alias is `target-alias` with [`with` `target-alias`] column, throw an error if + can't find the target select. + + This works with the assumption that `query` contains a list of select from [[select-clause-for-model]], + and some of them are dummy column casted to the correct type. + + This function then will replace the dummy column with alias is `target-alias` with the `with` column." + [query :- :map + target-alias :- :keyword + with :- :keyword] + (let [selects (:select query) + idx (first (keep-indexed (fn [index item] + (when (and (coll? item) + (= (last item) target-alias)) + index)) + selects)) + with-select [with target-alias]] + (if (some? idx) + (assoc query :select (m/replace-nth idx with-select selects)) + (throw (ex-info "Failed to replace selector" {:status-code 400 + :target-alias target-alias + :with with}))))) + +(mu/defn- with-last-editing-info :- :map + [query :- :map + model :- [:enum "card" "dashboard"]] + (-> query + (replace-select :last_editor_id :r.user_id) + (replace-select :last_edited_at :r.timestamp) + (sql.helpers/left-join [:revision :r] + [:and [:= :r.model_id (search.config/column-with-model-alias model :id)] + [:= :r.most_recent true] + [:= :r.model (search.config/search-model->revision-model model)]]))) + +(mu/defn- with-moderated-status :- :map + [query :- :map + model :- [:enum "card" "dataset"]] + (-> query + (replace-select :moderated_status :mr.status) + (sql.helpers/left-join [:moderation_review :mr] + [:and + [:= :mr.moderated_item_type "card"] + [:= :mr.moderated_item_id (search.config/column-with-model-alias model :id)] + [:= :mr.most_recent true]]))) + +(defn order-clause + "CASE expression that lets the results be ordered by whether they're an exact (non-fuzzy) match or not" + [query] + (let [match (search.util/wildcard-match (search.util/normalize query)) + columns-to-search (->> search.config/all-search-columns + (filter (fn [[_k v]] (= v :text))) + (map first) + (remove #{:collection_authority_level :moderated_status + :initial_sync_status :pk_ref :location + :collection_location})) + case-clauses (as-> columns-to-search <> + (map (fn [col] [:like [:lower col] match]) <>) + (interleave <> (repeat [:inline 0])) + (concat <> [:else [:inline 1]]))] + [(into [:case] case-clauses)])) + +(defmulti search-query-for-model + "Build a HoneySQL query with all the data relevant to a given model, padded + with NULL fields to support UNION queries." + {:arglists '([model search-context])} + (fn [model _] model)) + +(mu/defn- select-clause-for-model :- [:sequential HoneySQLColumn] + "The search query uses a `union-all` which requires that there be the same number of columns in each of the segments + of the query. This function will take the columns for `model` and will inject constant `nil` values for any column + missing from `entity-columns` but found in `search.config/all-search-columns`." + [model :- SearchableModel] + (let [entity-columns (search.config/columns-for-model model) + column-alias->honeysql-clause (m/index-by ->column-alias entity-columns) + cols-or-nils (canonical-columns model column-alias->honeysql-clause)] + cols-or-nils)) + +(mu/defn- from-clause-for-model :- [:tuple [:tuple :keyword :keyword]] + [model :- SearchableModel] + (let [{:keys [db-model alias]} (get search.config/model-to-db-model model)] + [[(t2/table-name db-model) alias]])) + +(mu/defn- base-query-for-model :- [:map {:closed true} + [:select :any] + [:from :any] + [:where {:optional true} :any] + [:join {:optional true} :any] + [:left-join {:optional true} :any]] + "Create a HoneySQL query map with `:select`, `:from`, and `:where` clauses for `model`, suitable for the `UNION ALL` + used in search." + [model :- SearchableModel context :- SearchContext] + (-> {:select (select-clause-for-model model) + :from (from-clause-for-model model)} + (search.filter/build-filters model context))) + +(mu/defn add-collection-join-and-where-clauses + "Add a `WHERE` clause to the query to only return Collections the Current User has access to; join against Collection, + so we can return its `:name`." + [honeysql-query :- ms/Map + model :- :string + {:keys [filter-items-in-personal-collection + archived + current-user-id + is-superuser?]} :- SearchContext] + (let [collection-id-col (if (= model "collection") + :collection.id + :collection_id) + collection-filter-clause (collection/visible-collection-filter-clause + collection-id-col + {:include-archived-items :all + :include-trash-collection? true + :permission-level (if archived + :write + :read)} + {:current-user-id current-user-id + :is-superuser? is-superuser?})] + (cond-> honeysql-query + true + (sql.helpers/where collection-filter-clause (perms/audit-namespace-clause :collection.namespace nil)) + ;; add a JOIN against Collection *unless* the source table is already Collection + (not= model "collection") + (sql.helpers/left-join [:collection :collection] + [:= collection-id-col :collection.id]) + + (some? filter-items-in-personal-collection) + (sql.helpers/where + (case filter-items-in-personal-collection + "only" + (concat [:or] + ;; sub personal collections + (for [id (t2/select-pks-set :model/Collection :personal_owner_id [:not= nil])] + [:like :collection.location (format "/%d/%%" id)]) + ;; top level personal collections + [[:and + [:= :collection.location "/"] + [:not= :collection.personal_owner_id nil]]]) + + "exclude" + (conj [:or] + (into + [:and [:= :collection.personal_owner_id nil]] + (for [id (t2/select-pks-set :model/Collection :personal_owner_id [:not= nil])] + [:not-like :collection.location (format "/%d/%%" id)])) + [:= collection-id-col nil])))))) + +(mu/defn- shared-card-impl + [model :- :metabase.models.card/type + search-ctx :- SearchContext] + (-> (base-query-for-model "card" search-ctx) + (sql.helpers/where [:= :card.type (name model)]) + (sql.helpers/left-join [:card_bookmark :bookmark] + [:and + [:= :bookmark.card_id :card.id] + [:= :bookmark.user_id (:current-user-id search-ctx)]]) + (add-collection-join-and-where-clauses "card" search-ctx) + (add-card-db-id-clause (:table-db-id search-ctx)) + (with-last-editing-info "card") + (with-moderated-status "card"))) + +(defmethod search-query-for-model "action" + [model search-ctx] + (-> (base-query-for-model model search-ctx) + (sql.helpers/left-join [:report_card :model] + [:= :model.id :action.model_id]) + (sql.helpers/left-join :query_action + [:= :query_action.action_id :action.id]) + (add-collection-join-and-where-clauses model search-ctx))) + +(defmethod search-query-for-model "card" + [_model search-ctx] + (shared-card-impl :question search-ctx)) + +(defmethod search-query-for-model "dataset" + [_model search-ctx] + (-> (shared-card-impl :model search-ctx) + (update :select (fn [columns] + (cons [(h2x/literal "dataset") :model] (rest columns)))))) + +(defmethod search-query-for-model "metric" + [_model search-ctx] + (-> (shared-card-impl :metric search-ctx) + (update :select (fn [columns] + (cons [(h2x/literal "metric") :model] (rest columns)))))) + +(defmethod search-query-for-model "collection" + [model search-ctx] + (-> (base-query-for-model "collection" search-ctx) + (sql.helpers/left-join [:collection_bookmark :bookmark] + [:and + [:= :bookmark.collection_id :collection.id] + [:= :bookmark.user_id (:current-user-id search-ctx)]]) + (add-collection-join-and-where-clauses model search-ctx))) + +(defmethod search-query-for-model "database" + [model search-ctx] + (base-query-for-model model search-ctx)) + +(defmethod search-query-for-model "dashboard" + [model search-ctx] + (-> (base-query-for-model model search-ctx) + (sql.helpers/left-join [:dashboard_bookmark :bookmark] + [:and + [:= :bookmark.dashboard_id :dashboard.id] + [:= :bookmark.user_id (:current-user-id search-ctx)]]) + (add-collection-join-and-where-clauses model search-ctx) + (with-last-editing-info "dashboard"))) + +(defn- add-model-index-permissions-clause + [query {:keys [current-user-id is-superuser?]}] + (sql.helpers/where + query + (collection/visible-collection-filter-clause + :collection_id + {} + {:current-user-id current-user-id + :is-superuser? is-superuser?}))) + +(defmethod search-query-for-model "indexed-entity" + [model search-ctx] + (-> (base-query-for-model model search-ctx) + (sql.helpers/left-join [:model_index :model-index] + [:= :model-index.id :model-index-value.model_index_id]) + (sql.helpers/left-join [:report_card :model] [:= :model-index.model_id :model.id]) + (sql.helpers/left-join [:collection :collection] [:= :model.collection_id :collection.id]) + (add-model-index-permissions-clause search-ctx))) + +(defmethod search-query-for-model "segment" + [model search-ctx] + (-> (base-query-for-model model search-ctx) + (sql.helpers/left-join [:metabase_table :table] [:= :segment.table_id :table.id]))) + +(defmethod search-query-for-model "table" + [model {:keys [current-user-perms table-db-id], :as search-ctx}] + (when (seq current-user-perms) + + (-> (base-query-for-model model search-ctx) + (add-table-db-id-clause table-db-id) + (sql.helpers/left-join :metabase_database [:= :table.db_id :metabase_database.id])))) + +(defmethod search.api/model-set :search.engine/in-place + [search-ctx] + (let [model-queries (for [model (search.filter/search-context->applicable-models + ;; It's unclear why we don't use the existing :models + (assoc search-ctx :models search.config/all-models))] + {:nest (sql.helpers/limit (search-query-for-model model search-ctx) 1)}) + query (when (pos-int? (count model-queries)) + {:select [:*] + :from [[{:union-all model-queries} :dummy_alias]]})] + (into #{} (map :model) (some-> query mdb.query/query)))) + +(mu/defn full-search-query + "Postgres 9 is not happy with the type munging it needs to do to make the union-all degenerate down to a trivial case + of one model without errors. Therefore, we degenerate it down for it" + [search-ctx :- SearchContext] + (let [models (:models search-ctx) + order-clause [((fnil order-clause "") (:search-string search-ctx))]] + (cond + (= (count models) 0) + {:select [nil]} + + (= (count models) 1) + (merge (search-query-for-model (first models) search-ctx) + {:limit search.config/*db-max-results*}) + + :else + {:select [:*] + :from [[{:union-all (vec (for [model models + :let [query (search-query-for-model model search-ctx)] + :when (seq query)] + query))} :alias_is_required_by_sql_but_not_needed_here]] + :order-by order-clause + :limit search.config/*db-max-results*}))) + +;; Return a reducible-query corresponding to searching the entities without an index. +(defmethod search.api/results + :search.engine/in-place + [search-ctx] + (let [search-query (full-search-query search-ctx)] + (log/tracef "Searching with query:\n%s\n%s" + (u/pprint-to-str search-query) + (mdb.query/format-sql (first (mdb.query/compile search-query)))) + (t2/reducible-query search-query))) + +(defmethod search.api/score :search.engine/in-place [results search-ctx] + (scoring/score-and-result results search-ctx)) diff --git a/src/metabase/search/postgres/core.clj b/src/metabase/search/postgres/core.clj index daa60585da558a879c53023008a93e4a3df1d7d7..7502b25f709f55f9c0934aedc94b1a1e7b79254e 100644 --- a/src/metabase/search/postgres/core.clj +++ b/src/metabase/search/postgres/core.clj @@ -5,7 +5,7 @@ [honey.sql.helpers :as sql.helpers] [metabase.api.common :as api] [metabase.search.config :as search.config] - [metabase.search.impl :as search.impl] + [metabase.search.legacy :as search.legacy] [metabase.search.postgres.index :as search.index] [metabase.search.postgres.ingestion :as search.ingestion] [toucan2.core :as t2]) @@ -31,25 +31,25 @@ :current-user-perms #{"/"}})) (defn- in-place-query [{:keys [models search-term archived?] :as search-ctx}] - (search.impl/full-search-query + (search.legacy/full-search-query (merge (user-params search-ctx) - {:search-string search-term - :models (or models - (if api/*current-user-id* - search.config/all-models - ;; For REPL convenience, skip these models as - ;; they require the user to be initialized. - (disj search.config/all-models "indexed-entity"))) - :archived? archived? - :model-ancestors? true}))) + {:search-string search-term + :models (or models + (if api/*current-user-id* + search.config/all-models + ;; For REPL convenience, skip these models as + ;; they require the user to be initialized. + (disj search.config/all-models "indexed-entity"))) + :archived? archived? + :model-ancestors? true}))) (defn- hybrid - "Use the index for appling the search string, but rely on the legacy code path for rendering + "Use the index for using the search string, but rely on the legacy code path for rendering the display data, applying permissions, additional filtering, etc. NOTE: this is less efficient than legacy search even. We plan to replace it with something - less feature complete, but much faster." + less feature complete but much faster." [search-term & {:as search-ctx}] (when-not @#'search.index/initialized? (throw (ex-info "Search index is not initialized. Use [[init!]] to ensure it exists." @@ -115,7 +115,7 @@ (when-not @#'search.index/initialized? (throw (ex-info "Search index is not initialized. Use [[init!]] to ensure it exists." {:search-engine :postgres}))) - (->> (search.impl/add-collection-join-and-where-clauses + (->> (search.legacy/add-collection-join-and-where-clauses (assoc (search.index/search-query search-term) :select [:legacy_input]) ;; we just need this to not be "collection" @@ -133,11 +133,11 @@ (defn- search-fn [search-engine] (case search-engine - :hybrid hybrid - :hubrid-multi hybrid-multi - :minimal minimal - :minimal-with-perms minimal-with-perms - :fulltext default-engine + :search.engine/hybrid hybrid + :search.engine/hybrid-multi hybrid-multi + :search.engine/minimal minimal + :search.engine/minimal-with-perms minimal-with-perms + :search.engine/fulltext default-engine default-engine)) (defn search @@ -162,9 +162,9 @@ (:models search-ctx search.config/all-models)))) (defn no-scoring - "Do no scoring, whatsover" + "Do no scoring, whatsoever" [result _scoring-ctx] - {:score 1 + {:score 1 :result (assoc result :all-scores [] :relevant-scores [])}) (defn init! diff --git a/src/metabase/search/postgres/index.clj b/src/metabase/search/postgres/index.clj index ba593d0f5fa16af1275941ab0ae10d7d836237e7..726b3a238741de87a72f71cf1bef8fa2ad61fd28 100644 --- a/src/metabase/search/postgres/index.clj +++ b/src/metabase/search/postgres/index.clj @@ -42,7 +42,7 @@ [[:id :bigint [:primary-key] [:raw "GENERATED BY DEFAULT AS IDENTITY"]] ;; entity [:model_id :int :not-null] - [:model [:varchar 254] :not-null] ;; TODO find the right size + [:model [:varchar 254] :not-null] ;; TODO We could shrink this to just what we need. ;; search [:search_vector :tsvector :not-null] ;; results @@ -60,9 +60,10 @@ [:created_at :timestamp [:default [:raw "CURRENT_TIMESTAMP"]] :not-null]]) - t2/query) + ;; TODO I strongly suspect that there are more indexes that would help performance, we should examine EXPLAIN. + (t2/query (format "CREATE INDEX IF NOT EXISTS %s_tsvector_idx ON %s USING gin (search_vector)" (str/replace (str (name active-table) "_" (random-uuid)) #"-" "_") @@ -70,7 +71,7 @@ (reset! reindexing? true))) (defn activate-pending! - "Make the pending index active, if it exists. Returns true if it did so." + "Make the pending index active if it exists. Returns true if it did so." [] ;; ... just in case it wasn't cleaned up last time. (drop-table! retired-table) @@ -152,6 +153,7 @@ [input] (let [trimmed (str/trim input) complete? (not (str/ends-with? trimmed "\"")) + ;; TODO also only complete if search-typeahead-enabled and the context is the search palette maybe-complete (if complete? complete-last-word identity)] (->> (split-preserving-quotes trimmed) (remove str/blank?) @@ -171,7 +173,7 @@ (t2/insert! pending-table entries)))) (defn search-query - "Query fragment for all models corresponding to a query paramter `:search-term`." + "Query fragment for all models corresponding to a query parameter `:search-term`." [search-term] {:select [:model_id :model] :from [active-table] @@ -189,7 +191,7 @@ (t2/query (search-query search-term)))) (defn reset-index! - "Ensure we have a blank slate, in case the table schema or stored data format has changed." + "Ensure we have a blank slate; in case the table schema or stored data format has changed." [] (reset! reindexing? false) (drop-table! pending-table) diff --git a/src/metabase/search/postgres/ingestion.clj b/src/metabase/search/postgres/ingestion.clj index 85abe899956d90fd0a114b3c7299ea6c9bf376e8..c7ba83b5711e70eff33846727fd34ecbc68827f1 100644 --- a/src/metabase/search/postgres/ingestion.clj +++ b/src/metabase/search/postgres/ingestion.clj @@ -6,7 +6,7 @@ (:require [clojure.string :as str] [metabase.search.config :as search.config] - [metabase.search.impl :as search.impl] + [metabase.search.legacy :as search.legacy] [metabase.search.postgres.index :as search.index] [toucan2.core :as t2] [toucan2.realize :as t2.realize])) @@ -50,14 +50,13 @@ :models (disj search.config/all-models "indexed-entity") ;; we want to see everything :is-superuser? true - ;; irrelevant, as we're acting as a super user - :current-user-id 1 + :current-user-id (t2/select-one-pk :model/User :is_superuser true) :current-user-perms #{"/"} ;; include both achived and non-archived items. :archived? nil ;; only need this for display data :model-ancestors? false} - search.impl/full-search-query + search.legacy/full-search-query (dissoc :limit) t2/reducible-query)) @@ -65,7 +64,6 @@ "Go over all searchable items and populate the index with them." [] (->> (search-items-reducible) - ;; TODO realize and insert in batches (eduction (comp (map t2.realize/realize) diff --git a/src/metabase/server/middleware/auth.clj b/src/metabase/server/middleware/auth.clj index cc04fc517ddf965112bacca68d471cfb96dd08df..3d6ee714da92f4e9830c33b028f6a9ab73394b59 100644 --- a/src/metabase/server/middleware/auth.clj +++ b/src/metabase/server/middleware/auth.clj @@ -33,6 +33,7 @@ (defsetting api-key "When set, this API key is required for all API requests." + :encryption :when-encryption-key-set :visibility :internal :doc "Middleware that enforces validation of the client via the request header X-Metabase-Apikey. If the header is available, then it’s validated against MB_API_KEY. diff --git a/src/metabase/server/middleware/session.clj b/src/metabase/server/middleware/session.clj index 7bc41fabaf67f9286a319148e69ea532a06be1cf..77cd908d1e92e3634e8c795e889b977ad86f6658 100644 --- a/src/metabase/server/middleware/session.clj +++ b/src/metabase/server/middleware/session.clj @@ -509,24 +509,25 @@ ;; Should be in the form "{\"amount\":60,\"unit\":\"minutes\"}" where the unit is one of "seconds", "minutes" or "hours". ;; The amount is nillable. (deferred-tru "Time before inactive users are logged out. By default, sessions last indefinitely.") - :type :json - :default nil - :getter (fn [] - (let [value (setting/get-value-of-type :json :session-timeout)] - (if-let [error-key (check-session-timeout value)] - (do (log/warn (case error-key - :amount-must-be-positive "Session timeout amount must be positive." - :amount-must-be-less-than-100-years "Session timeout must be less than 100 years.")) - nil) - value))) - :setter (fn [new-value] - (when-let [error-key (check-session-timeout new-value)] - (throw (ex-info (case error-key - :amount-must-be-positive "Session timeout amount must be positive." - :amount-must-be-less-than-100-years "Session timeout must be less than 100 years.") - {:status-code 400}))) - (setting/set-value-of-type! :json :session-timeout new-value)) - :doc "Has to be in the JSON format `\"{\"amount\":120,\"unit\":\"minutes\"}\"` where the unit is one of \"seconds\", \"minutes\" or \"hours\".") + :encryption :no + :type :json + :default nil + :getter (fn [] + (let [value (setting/get-value-of-type :json :session-timeout)] + (if-let [error-key (check-session-timeout value)] + (do (log/warn (case error-key + :amount-must-be-positive "Session timeout amount must be positive." + :amount-must-be-less-than-100-years "Session timeout must be less than 100 years.")) + nil) + value))) + :setter (fn [new-value] + (when-let [error-key (check-session-timeout new-value)] + (throw (ex-info (case error-key + :amount-must-be-positive "Session timeout amount must be positive." + :amount-must-be-less-than-100-years "Session timeout must be less than 100 years.") + {:status-code 400}))) + (setting/set-value-of-type! :json :session-timeout new-value)) + :doc "Has to be in the JSON format `\"{\"amount\":120,\"unit\":\"minutes\"}\"` where the unit is one of \"seconds\", \"minutes\" or \"hours\".") (defn session-timeout->seconds "Convert the session-timeout setting value to seconds." diff --git a/src/metabase/task.clj b/src/metabase/task.clj index 26db485d7cf2adedce876abcc210a8867040bae4..4c70f28c2ff84ea4be2dbd590e71b2c12f7b8fbf 100644 --- a/src/metabase/task.clj +++ b/src/metabase/task.clj @@ -308,6 +308,13 @@ (instance? JobKey x) x (string? x) (JobKey. ^String x))) +(defn job-exists? + "Check whether there is a Job with the given key." + [job-key] + (boolean + (when-let [s (scheduler)] + (qs/get-job s (->job-key job-key))))) + (defn job-info "Get info about a specific Job (`job-key` can be either a String or `JobKey`). diff --git a/src/metabase/task/search_index.clj b/src/metabase/task/search_index.clj index 3a66099a57b1fc1d55db3dee8c4753fb534e10d0..7fc5d52b99e93e28055158ba718c72899d6c4198 100644 --- a/src/metabase/task/search_index.clj +++ b/src/metabase/task/search_index.clj @@ -1,8 +1,8 @@ (ns metabase.task.search-index (:require [clojurewerkz.quartzite.jobs :as jobs] + [clojurewerkz.quartzite.schedule.simple :as simple] [clojurewerkz.quartzite.triggers :as triggers] - [metabase.public-settings :as public-settings] [metabase.search :as search] [metabase.task :as task] [metabase.util.log :as log]) @@ -11,22 +11,36 @@ (set! *warn-on-reflection* true) -;; We need each instance to initialize on start-up currently, this will need to be refined. -(jobs/defjob ^{DisallowConcurrentExecution false +;; This is problematic multi-instance deployments, see below. +(def ^:private recreated? (atom false)) + +(def job-key + "Key used to define and trigger the search-index task." + (jobs/key "metabase.task.search-index.job")) + +(jobs/defjob ^{DisallowConcurrentExecution true :doc "Populate Search Index"} SearchIndexing [_ctx] - (when (public-settings/experimental-fulltext-search-enabled) - (log/info "Recreating search index from latest schema") - (search/init-index! {:force-reset? true}) - (log/info "Populating search index") - (search/reindex!) + (when (search/supports-index?) + (if (not @recreated?) + (do (log/info "Recreating search index from the latest schema") + ;; Each instance in a multi-instance deployment will recreate the table the first time it is selected to run + ;; the job, resulting in a momentary lack of search results. + ;; One solution to this would be to store metadata about the index in another table, which we can use to + ;; determine whether it was built by another version of Metabase and should be rebuilt. + (search/init-index! {:force-reset? (not @recreated?)}) + (reset! recreated? true)) + (do (log/info "Reindexing searchable entities") + (search/reindex!))) (log/info "Done indexing."))) (defmethod task/init! ::SearchIndex [_] (let [job (jobs/build (jobs/of-type SearchIndexing) - (jobs/with-identity (jobs/key "metabase.task.search-index.job"))) + (jobs/with-identity job-key)) trigger (triggers/build (triggers/with-identity (triggers/key "metabase.task.search-index.trigger")) - (triggers/start-now))] + (triggers/start-now) + (triggers/with-schedule + (simple/schedule (simple/with-interval-in-hours 1))))] (task/schedule-task! job trigger))) diff --git a/src/metabase/troubleshooting.clj b/src/metabase/troubleshooting.clj index c9d17fc9e14421162e585140a566eecdaa78ce6d..9e13877dded25ef5195d5290ce5591c55d99d211 100644 --- a/src/metabase/troubleshooting.clj +++ b/src/metabase/troubleshooting.clj @@ -31,12 +31,12 @@ [] (merge {:databases (->> (t2/select :model/Database) (map :engine) distinct) - :run-mode (config/config-kw :mb-run-mode) - :plan-alias (or (some-> (premium-features/premium-embedding-token) premium-features/fetch-token-status :plan-alias) "") - :version config/mb-version-info - :settings {:report-timezone (driver/report-timezone)} - :hosting-env (stats/environment-type) - :application-database (mdb/db-type)} + :run-mode (config/config-kw :mb-run-mode) + :plan-alias (or (premium-features/plan-alias) "") + :version config/mb-version-info + :settings {:report-timezone (driver/report-timezone)} + :hosting-env (stats/environment-type) + :application-database (mdb/db-type)} (when-not (premium-features/is-hosted?) {:application-database-details (t2/with-connection [^java.sql.Connection conn] (let [metadata (.getMetaData conn)] diff --git a/src/metabase/upload.clj b/src/metabase/upload.clj index 163a4897adeae541b7dde8d09bb464a0179c6ed0..6f0fc30433b0d7097f0b60329e4b329e7215859e 100644 --- a/src/metabase/upload.clj +++ b/src/metabase/upload.clj @@ -422,18 +422,21 @@ case-statement (into [:case] (mapcat identity) (for [[n display-name] field->display-name] - [[:= [:lower :name] n] display-name]))] + [[:= [:lower :name] n] + [:case + ;; Only update the display name if it still matches the automatic humanization. + [:= :display_name (humanization/name->human-readable-name n)] display-name + ;; Otherwise, it could have been set manually, so leave it as is. + true :display_name]]))] ;; Using t2/update! results in an invalid query for certain versions of PostgreSQL ;; SELECT * FROM \"metabase_field\" WHERE \"id\" AND (\"table_id\" = ?) AND ... ;; ^^^^^ ;; ERROR: argument of AND must be type boolean, not type integer (t2/query {:update (t2/table-name :model/Field) - :set {:display_name case-statement} - :where [:and - [:= :table_id table-id] - [:in [:lower :name] (keys field->display-name)] - ;; Only replace display names that have not been overridden already. - [:= [:lower :name] [:lower :display_name]]]}))) + :set {:display_name case-statement} + :where [:and + [:= :table_id table-id] + [:in [:lower :name] (keys field->display-name)]]}))) (defn- uploads-enabled? [] (some? (:db_id (public-settings/uploads-settings)))) diff --git a/src/metabase/util/embed.clj b/src/metabase/util/embed.clj index 9ff0f5cbf731b2352cdad99106db3e4755f0ce9c..7a74ebe99dca93f4b34082039e378eedd68c8ef5 100644 --- a/src/metabase/util/embed.clj +++ b/src/metabase/util/embed.clj @@ -58,6 +58,7 @@ (defsetting embedding-secret-key (deferred-tru "Secret key used to sign JSON Web Tokens for requests to `/api/embed` endpoints.") + :encryption :when-encryption-key-set :visibility :admin :audit :no-value :setter (fn [new-value] diff --git a/src/metabase/util/performance.clj b/src/metabase/util/performance.clj index f64d28308b8bf871452cb8bc85090718376cb3ff..202e7932c57dea18b382cc415c267b969faa9e05 100644 --- a/src/metabase/util/performance.clj +++ b/src/metabase/util/performance.clj @@ -1,6 +1,6 @@ (ns metabase.util.performance "Functions and utilities for faster processing." - (:refer-clojure :exclude [reduce mapv]) + (:refer-clojure :exclude [reduce mapv some]) (:import (clojure.lang LazilyPersistentVector RT))) (set! *warn-on-reflection* true) @@ -126,3 +126,8 @@ ([x y] (mapv #(% x y) fns)) ([x y z] (mapv #(% x y z) fns)) ([x y z & args] (mapv #(apply % x y z args) fns))))) + +(defn some + "Like `clojure.core/some` but uses our custom `reduce` which in turn uses iterators." + [f coll] + (unreduced (reduce #(when-let [match (f %2)] (reduced match)) nil coll))) diff --git a/test/metabase/analytics/snowplow_test.clj b/test/metabase/analytics/snowplow_test.clj index bfeb9ec7134ad6af7fefbfbb4bb6a0ad7855005e..a27fca0dd0e30596ef6453fba582e4eabbb1a410 100644 --- a/test/metabase/analytics/snowplow_test.clj +++ b/test/metabase/analytics/snowplow_test.clj @@ -33,7 +33,6 @@ "A function that can be used in place of track-event-impl! which pulls and decodes the payload, context and subject ID from an event and adds it to the in-memory [[*snowplow-collector*]] queue." [collector _tracker ^SelfDescribing event] - (def event event) (let [payload (-> event .getPayload .getMap normalize-map) ;; Don't normalize keys in [[properties]] so that we can assert that they are snake-case strings in the test ;; cases @@ -44,7 +43,6 @@ (-> subject .getSubject normalize-map)) [^SelfDescribingJson context-json] (.getContext event) context (normalize-map (.getMap context-json))] - (def payload payload) (swap! collector conj {:properties properties, :subject subject, :context context}))) (defn do-with-fake-snowplow-collector! diff --git a/test/metabase/analytics/stats_test.clj b/test/metabase/analytics/stats_test.clj index 70f6a7eb59bd68e8a861154c8eaef600410a69fb..ee7e4417ce034e679532cc4631a2c457607d756b 100644 --- a/test/metabase/analytics/stats_test.clj +++ b/test/metabase/analytics/stats_test.clj @@ -1,8 +1,10 @@ (ns metabase.analytics.stats-test (:require + [clojure.set :as set] [clojure.test :refer :all] [java-time.api :as t] - [metabase.analytics.stats :as stats :refer [anonymous-usage-stats]] + [metabase.analytics.stats :as stats :refer [legacy-anonymous-usage-stats]] + [metabase.config :as config] [metabase.core :as mbc] [metabase.db :as mdb] [metabase.email :as email] @@ -12,6 +14,7 @@ [metabase.models.pulse-card :refer [PulseCard]] [metabase.models.pulse-channel :refer [PulseChannel]] [metabase.models.query-execution :refer [QueryExecution]] + [metabase.public-settings.premium-features :as premium-features] [metabase.query-processor.util :as qp.util] [metabase.test :as mt] [metabase.test.fixtures :as fixtures] @@ -94,10 +97,10 @@ google-auth-enabled false enable-embedding false] (mt/with-temp [:model/Database _ {:is_sample true}] - (let [stats (anonymous-usage-stats)] + (let [stats (legacy-anonymous-usage-stats)] (is (partial= {:running_on :unknown :check_for_updates true - :startup_time_millis 1234.0 + :startup_time_millis 1234 :friendly_names false :email_configured false :slack_configured false @@ -143,10 +146,10 @@ application-colors {:brand "#123456"} show-metabase-links false] (mt/with-temp [:model/Database _ {:is_sample true}] - (let [stats (anonymous-usage-stats)] + (let [stats (legacy-anonymous-usage-stats)] (is (partial= {:running_on :unknown :check_for_updates true - :startup_time_millis 1234.0 + :startup_time_millis 1234 :friendly_names false :email_configured false :slack_configured false @@ -173,7 +176,7 @@ (deftest ^:parallel conversion-test (is (= #{true} - (let [system-stats (get-in (anonymous-usage-stats) [:stats :system])] + (let [system-stats (get-in (legacy-anonymous-usage-stats) [:stats :system])] (into #{} (map #(contains? system-stats %) [:java_version :java_runtime_name :max_memory])))) "Spot checking a few system stats to ensure conversion from property names and presence in the anonymous-usage-stats")) @@ -224,25 +227,26 @@ "the new version of the executions metrics works the same way the old one did"))) (deftest execution-metrics-started-at-test - (testing "execution metrics should not be sensitive to the app db time zone" + (testing "execution metrics should not be sensitive to the app db time zone\n" (doseq [tz ["Pacific/Auckland" "Europe/Helsinki"]] - (mt/with-app-db-timezone-id! tz - (let [get-executions #(:executions (#'stats/execution-metrics)) - before (get-executions)] - (mt/with-temp [QueryExecution _ (merge query-execution-defaults - {:started_at (-> (t/offset-date-time (t/zone-id "UTC")) - (t/minus (t/days 30)) - (t/plus (t/minutes 10)))})] - (is (= (inc before) - (get-executions)) - "execution metrics include query executions since 30 days ago")) - (mt/with-temp [QueryExecution _ (merge query-execution-defaults - {:started_at (-> (t/offset-date-time (t/zone-id "UTC")) - (t/minus (t/days 30)) - (t/minus (t/minutes 10)))})] - (is (= before - (get-executions)) - "the executions metrics exclude query executions before 30 days ago"))))))) + (testing tz + (mt/with-app-db-timezone-id! tz + (let [get-executions #(:executions (#'stats/execution-metrics)) + before (get-executions)] + (mt/with-temp [QueryExecution _ (merge query-execution-defaults + {:started_at (-> (t/offset-date-time (t/zone-id "UTC")) + (t/minus (t/days 30)) + (t/plus (t/minutes 10)))})] + (is (= (inc before) + (get-executions)) + "execution metrics include query executions since 30 days ago")) + (mt/with-temp [QueryExecution _ (merge query-execution-defaults + {:started_at (-> (t/offset-date-time (t/zone-id "UTC")) + (t/minus (t/days 30)) + (t/minus (t/minutes 10)))})] + (is (= before + (get-executions)) + "the executions metrics exclude query executions before 30 days ago")))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Pulses & Alerts | @@ -370,3 +374,74 @@ :public {} :embedded {}} (#'stats/dashboard-metrics))))))) + +(deftest activation-signals-test + (mt/with-temp-empty-app-db [_conn :h2] + (mdb/setup-db! :create-sample-content? true) + + (testing "sufficient-users? correctly counts the number of users within three days of instance creation" + (is (false? (@#'stats/sufficient-users? 1))) + + (mt/with-temp [:model/User _ {:date_joined + (t/plus (t/offset-date-time) (t/days 4))}] + (is (false? (@#'stats/sufficient-users? 1)))) + + (mt/with-temp [:model/User _ {:date_joined (t/offset-date-time)}] + (is (true? (@#'stats/sufficient-users? 1))))) + + (testing "sufficient-queries? correctly counts the number of queries" + (is (false? (@#'stats/sufficient-queries? 1))) + (mt/with-temp [:model/QueryExecution _ query-execution-defaults] + (is (true? (@#'stats/sufficient-queries? 1))))))) + +(deftest csv-upload-available-test + (mt/with-temp-empty-app-db [_conn :h2] + (mdb/setup-db! :create-sample-content? true) + + (testing "csv-upload-available? currently detects upload availability based on the current MB version" + (mt/with-temp [:model/Database _ {:engine :postgres}] + (with-redefs [config/current-major-version (constantly 46) + config/current-minor-version (constantly 0)] + (is false? (@#'stats/csv-upload-available?))) + + (with-redefs [config/current-major-version (constantly 47) + config/current-minor-version (constantly 1)] + (is true? (@#'stats/csv-upload-available?)))) + + (mt/with-temp [:model/Database _ {:engine :redshift}] + (with-redefs [config/current-major-version (constantly 49) + config/current-minor-version (constantly 5)] + (is false? (@#'stats/csv-upload-available?))) + + (with-redefs [config/current-major-version (constantly 49) + config/current-minor-version (constantly 6)] + (is true? (@#'stats/csv-upload-available?)))) + + ;; If we can't detect the MB version, return nil + (with-redefs [config/current-major-version (constantly nil) + config/current-minor-version (constantly nil)] + (is false? (@#'stats/csv-upload-available?)))))) + +(def ^:private excluded-features + "Set of features intentionally excluded from the daily stats ping. If you add a new feature, either add it to the stats ping + or to this set, so that [[every-feature-is-accounted-for-test]] passes." + #{:audit-app ;; tracked under :mb-analytics + :enhancements + :embedding + :embedding-sdk + :collection-cleanup + :llm-autodescription + :query-reference-validation + :session-timeout-config}) + +(deftest every-feature-is-accounted-for-test + (testing "Is every premium feature either tracked under the :features key, or intentionally excluded?" + (let [included-features (->> (concat (@#'stats/snowplow-features-data) (@#'stats/ee-snowplow-features-data)) + (map :name)) + included-features-set (set included-features) + all-features @premium-features/premium-features] + ;; make sure features are not missing + (is (empty? (set/difference all-features included-features-set excluded-features))) + + ;; make sure features are not duplicated + (is (= (count included-features) (count included-features-set)))))) diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 91e6c1bd0d6030ebfbe0e5429c545a2a00fc01b4..21d043a82925bd9571dd591b6c5c91edcd955ac6 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -3847,3 +3847,49 @@ false (apply original-can-read? args)))] (is (map? (mt/user-http-request :crowberto :get 200 (format "card/%d/query_metadata" (:id card)))))))))) + +(deftest pivot-tables-with-model-sources-show-row-totals + (testing "Pivot Tables with a model source will return row totals (#46575)" + (mt/with-temp [:model/Card {model-id :id} {:type :model + :dataset_query + {:database (mt/id) + :type :query + :query + {:source-table (mt/id :orders) + :joins + [{:fields :all + :strategy :left-join + :alias "People - User" + :condition + [:= + [:field (mt/id :orders :user_id) {:base-type :type/Integer}] + [:field (mt/id :people :id) {:base-type :type/BigInteger :join-alias "People - User"}]] + :source-table (mt/id :people)}]}}} + :model/Card {pivot-id :id} {:display :pivot + :dataset_query + {:database (mt/id) + :type :query + :query + {:aggregation [[:sum [:field "TOTAL" {:base-type :type/Float}]]] + :breakout + [[:field "CREATED_AT" {:base-type :type/DateTime, :temporal-unit :month}] + [:field "NAME" {:base-type :type/Text}] + [:field (mt/id :products :category) {:base-type :type/Text + :source-field (mt/id :orders :product_id)}]] + :source-table (format "card__%s" model-id)}} + :visualization_settings + {:pivot_table.column_split + {:rows + [[:field "NAME" {:base-type :type/Text}] + [:field "CREATED_AT" {:base-type :type/DateTime, :temporal-unit :month}]] + :columns [[:field (mt/id :products :category) {:base-type :type/Text + :source-field (mt/id :orders :product_id)}]] + :values [[:aggregation 0]]}}}] + ;; pivot row totals have a pivot-grouping of 1 (the second-last column in these results) + ;; before fixing issue #46575, these rows would not be returned given the model + card setup + (is (= [nil "Abbey Satterfield" "Doohickey" 1 347.91] + (let [result (mt/user-http-request :rasta :post 202 (format "card/pivot/%d/query" pivot-id)) + totals (filter (fn [row] + (< 0 (second (reverse row)))) + (get-in result [:data :rows]))] + (first totals))))))) diff --git a/test/metabase/api/database_test.clj b/test/metabase/api/database_test.clj index 44ef0cf7974fec079ff7df65ef6bf69ab8ece2c1..d7b29b4a87f0d8649f39465a3dfb1ce58af2ffd4 100644 --- a/test/metabase/api/database_test.clj +++ b/test/metabase/api/database_test.clj @@ -408,7 +408,8 @@ (deftest create-db-succesful-track-snowplow-test ;; h2 is no longer supported as a db source ;; the rests are disj because it's timeouted when adding it as a DB for some reasons - (mt/test-drivers (disj (mt/normal-drivers) :h2 :bigquery-cloud-sdk :athena :snowflake) + (mt/test-drivers (disj (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) + :h2 :bigquery-cloud-sdk :snowflake) (snowplow-test/with-fake-snowplow-collector (let [dataset-def (tx/get-dataset-definition (data.impl/resolve-dataset-definition *ns* 'avian-singles))] ;; trigger this to make sure the database exists before we add them diff --git a/test/metabase/api/premium_features_test.clj b/test/metabase/api/premium_features_test.clj index 32c4835a1db53109468b423a7ff0428f695dc767..6b0d147ec7c544cafb21485b21c671df952e40b0 100644 --- a/test/metabase/api/premium_features_test.clj +++ b/test/metabase/api/premium_features_test.clj @@ -14,7 +14,7 @@ :status "fake" :features ["test" "fixture"] :trial false})] - (mt/with-temporary-setting-values [:premium-embedding-token premium-features-test/random-fake-token] + (mt/with-temporary-setting-values [:premium-embedding-token (premium-features-test/random-token)] (testing "returns correctly" (is (= {:valid true :status "fake" diff --git a/test/metabase/api/search_test.clj b/test/metabase/api/search_test.clj index 16b71cb4a6b6c8ca86a5c6b340d9258c5acf18a6..e73fbf36c275ad70b77e62cfbd82f96730a35654 100644 --- a/test/metabase/api/search_test.clj +++ b/test/metabase/api/search_test.clj @@ -20,13 +20,21 @@ [metabase.models.permissions-group :as perms-group] [metabase.models.revision :as revision] [metabase.public-settings.premium-features :as premium-features] + [metabase.search :as search] [metabase.search.config :as search.config] + [metabase.search.fulltext :as search.fulltext] [metabase.search.scoring :as scoring] [metabase.test :as mt] [metabase.util :as u] [toucan2.core :as t2] [toucan2.tools.with-temp :as t2.with-temp])) +(comment + ;; We need this to ensure the engine hierarchy is registered + search.fulltext/keep-me) + +(set! *warn-on-reflection* true) + (def ^:private default-collection {:id false :name nil :authority_level nil :type nil}) (def ^:private default-search-row @@ -300,6 +308,14 @@ (is (= 2 (:limit (search-request :crowberto :q "test" :limit "2" :offset "3")))) (is (= 3 (:offset (search-request :crowberto :q "test" :limit "2" :offset "3"))))))) +(deftest custom-engine-test + (when (search/supports-index?) + (testing "It can use an alternate search engine" + (with-search-items-in-root-collection "test" + (let [resp (search-request :crowberto :q "test" :search_engine "fulltext")] + ;; The index is not populated here, so there's not much interesting to assert. + (is (= "search.engine/fulltext" (:engine resp)))))))) + (deftest archived-models-test (testing "It returns some stuff when you get results" (with-search-items-in-root-collection "test" @@ -1558,3 +1574,19 @@ :name "top level col" :type "foo"}]} (:collection leaf-card-response))))))))) + +(deftest force-reindex-test + (when (search/supports-index?) + (mt/with-temp [Card {id :id} {:name "It boggles the mind!"}] + (let [search-results #(:data (mt/user-http-request :rasta :get 200 "search" :q "boggle" :search_engine "fulltext"))] + (try + (t2/delete! :search_index) + (catch Exception _)) + (is (empty? (search-results))) + (mt/user-http-request :crowberto :post 200 "search/force-reindex") + (is (loop [attempts-left 5] + (if (some (comp #{id} :id) (search-results)) + ::success + (when (pos? attempts-left) + (Thread/sleep 200) + (recur (dec attempts-left)))))))))) diff --git a/test/metabase/api/session_test.clj b/test/metabase/api/session_test.clj index 1a29e151c0135ccab8aa85305ed2092b6fd5f745..0dd6391f11483be50f5d9285c6aff81c568b3658 100644 --- a/test/metabase/api/session_test.clj +++ b/test/metabase/api/session_test.clj @@ -453,6 +453,7 @@ (testing "Includes user-local settings" (defsetting test-session-api-setting "test setting" + :encryption :no :user-local :only :type :string :default "FOO") diff --git a/test/metabase/api/setting_test.clj b/test/metabase/api/setting_test.clj index 03ae6f14886faa5b7b72e4c93a3da9d73b7da251..cf29dd9f6d578c35be75cd858a4ce49e2576ce14 100644 --- a/test/metabase/api/setting_test.clj +++ b/test/metabase/api/setting_test.clj @@ -32,7 +32,8 @@ (defsetting test-settings-manager-visibility (deferred-tru "Setting to test the `:settings-manager` visibility level. This only shows up in dev.") - :visibility :settings-manager) + :visibility :settings-manager + :encryption :when-encryption-key-set) ;; ## Helper Fns (defn- fetch-test-settings diff --git a/test/metabase/driver/sql_jdbc/connection_test.clj b/test/metabase/driver/sql_jdbc/connection_test.clj index e4fd5117c496453ad9ae84b878af628e54aba8ea..0a277080ada8e1fe54b9fa38bf33f038c51f2ccf 100644 --- a/test/metabase/driver/sql_jdbc/connection_test.clj +++ b/test/metabase/driver/sql_jdbc/connection_test.clj @@ -118,6 +118,9 @@ :redshift (assoc details :additional-options "defaultRowFetchSize=1000") + :databricks + (assoc details :log-level 0) + (cond-> details ;; swap localhost and 127.0.0.1 (and (string? (:host details)) diff --git a/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj b/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj index f2c1a7e1ec4557d67551eb3939fed48be2b75a38..b2d6ef4fd29d40fcba49b873f58aae56e33714ad 100644 --- a/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj +++ b/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj @@ -16,6 +16,7 @@ [metabase.test.data.one-off-dbs :as one-off-dbs] [metabase.test.fixtures :as fixtures] [metabase.util :as u] + [metabase.util.log :as log] [toucan2.core :as t2] [toucan2.tools.with-temp :as t2.with-temp]) (:import @@ -221,7 +222,12 @@ (mt/db) nil (fn [^java.sql.Connection conn] - (.setAutoCommit conn auto-commit) + ;; Databricks does not support setting auto commit to false. Catching the setAutoCommit + ;; exception results in testing the true value only. + (try + (.setAutoCommit conn auto-commit) + (catch Exception _ + (log/trace "Failed to set auto commit."))) (is (false? (sql-jdbc.sync.interface/have-select-privilege? driver/*driver* conn schema (str table-name "_should_not_exist")))) (is (true? (sql-jdbc.sync.interface/have-select-privilege? diff --git a/test/metabase/driver_test.clj b/test/metabase/driver_test.clj index 33358d98554aaa8c3bf7a700a536973efdab4bb1..18ef2325197b158d7409b9fe7d4a7fbdf740d584 100644 --- a/test/metabase/driver_test.clj +++ b/test/metabase/driver_test.clj @@ -83,7 +83,7 @@ (deftest can-connect-with-destroy-db-test (testing "driver/can-connect? should fail or throw after destroying a database" - (mt/test-drivers (mt/normal-drivers-without-feature :connection/multiple-databases) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (let [database-name (mt/random-name) dbdef (basic-db-definition database-name)] (mt/dataset dbdef @@ -115,7 +115,7 @@ (deftest check-can-connect-before-sync-test (testing "Database sync should short-circuit and fail if the database at the connection has been deleted (metabase#7526)" - (mt/test-drivers (mt/normal-drivers-without-feature :connection/multiple-databases) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (let [database-name (mt/random-name) dbdef (basic-db-definition database-name)] (mt/dataset dbdef diff --git a/test/metabase/lib/temporal_bucket_test.cljc b/test/metabase/lib/temporal_bucket_test.cljc index 76c0be3097c883ba7f6dd8d1292cbcee7f91b3cb..798706100f8b0e3a4057b55259448ff7c0822673 100644 --- a/test/metabase/lib/temporal_bucket_test.cljc +++ b/test/metabase/lib/temporal_bucket_test.cljc @@ -110,7 +110,7 @@ :minute-of-hour :hour-of-day :day-of-week :day-of-month :day-of-year :week-of-year :month-of-year :quarter-of-year} - expected-defaults [{:lib/type :option/temporal-bucketing, :unit :day, :default true}]] + expected-defaults [{:lib/type :option/temporal-bucketing, :unit :month, :default true}]] (testing "missing fingerprint" (let [column (dissoc column :fingerprint) options (lib.temporal-bucket/available-temporal-buckets-method nil -1 column)] @@ -123,8 +123,8 @@ "2017-04-15T13:34:19.931Z" :week "2016-05-15T13:34:19.931Z" :day "2016-04-27T13:34:19.931Z" :minute - nil :day - "garbage" :day}] + nil :month + "garbage" :month}] (testing latest (let [bounds {:earliest "2016-04-26T19:29:55.147Z" :latest latest} @@ -166,12 +166,11 @@ (deftest ^:parallel temporal-bucketing-options-expressions-test (testing "Temporal bucketing should be available for Date and DateTime-valued expressions" - ;; TODO: Why is the default :month for a Field and :day for an expression? (is (=? [{:unit :minute} {:unit :hour} - {:unit :day, :default true} + {:unit :day} {:unit :week} - {:unit :month} + {:unit :month, :default true} {:unit :quarter} {:unit :year} {:unit :minute-of-hour} diff --git a/test/metabase/models/setting/multi_setting_test.clj b/test/metabase/models/setting/multi_setting_test.clj index 12a5fc209fcf8b7eb3a52f1e17cc6f2a20ae66b0..b69ceabc8e34e460ddf811a9ca21c660dd1b90f3 100644 --- a/test/metabase/models/setting/multi_setting_test.clj +++ b/test/metabase/models/setting/multi_setting_test.clj @@ -12,7 +12,8 @@ (multi-setting/define-multi-setting ^:private multi-setting-test-bird-name "A test Setting." (fn [] *parakeet*) - :visibility :internal) + :visibility :internal + :encryption :no) (multi-setting/define-multi-setting-impl multi-setting-test-bird-name :green-friend :getter (constantly "Green Friend") diff --git a/test/metabase/models/setting_test.clj b/test/metabase/models/setting_test.clj index 8742605554173dfd2cd5600d02ccd3e30d80b3ff..cabd53da09ec2f0d630a60e9e5ff1f126b77d9ff 100644 --- a/test/metabase/models/setting_test.clj +++ b/test/metabase/models/setting_test.clj @@ -27,15 +27,18 @@ ;; ## TEST SETTINGS DEFINITIONS (defsetting test-setting-1 - "Test setting - this only shows up in dev (1)") + "Test setting - this only shows up in dev (1)" + :encryption :when-encryption-key-set) (defsetting test-setting-2 "Test setting - this only shows up in dev (2)" - :default "[Default Value]") + :default "[Default Value]" + :encryption :when-encryption-key-set) (defsetting test-setting-3 "Test setting - this only shows up in dev (3)" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (defsetting test-boolean-setting "Test setting - this only shows up in dev (3)" @@ -44,26 +47,31 @@ (defsetting test-json-setting "Test setting - this only shows up in dev (4)" - :type :json) + :type :json + :encryption :when-encryption-key-set) (defsetting test-csv-setting "Test setting - this only shows up in dev (5)" :visibility :internal - :type :csv) + :type :csv + :encryption :when-encryption-key-set) (defsetting ^:private test-csv-setting-with-default "Test setting - this only shows up in dev (6)" :visibility :internal :type :csv - :default ["A" "B" "C"]) + :default ["A" "B" "C"] + :encryption :when-encryption-key-set) (defsetting test-env-setting "Test setting - this only shows up in dev (7)" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (defsetting toucan-name "Name for the Metabase Toucan mascot." - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (defsetting test-setting-calculated-getter "Test setting - this only shows up in dev (8)" @@ -74,7 +82,8 @@ (defsetting test-setting-custom-init "Test setting - this only shows up in dev (0)" :type :string - :init (comp str random-uuid)) + :init (comp str random-uuid) + :encryption :when-encryption-key-set) (def ^:private ^:dynamic *enabled?* false) @@ -82,34 +91,37 @@ "Setting to test the `:enabled?` property of settings. This only shows up in dev." :visibility :internal :type :string - :enabled? (fn [] *enabled?*)) + :enabled? (fn [] *enabled?*) + :encryption :when-encryption-key-set) (defsetting test-enabled-setting-default "Setting to test the `:enabled?` property of settings. This only shows up in dev." :visibility :internal :type :string :default "setting-default" - :enabled? (fn [] *enabled?*)) + :enabled? (fn [] *enabled?*) + :encryption :when-encryption-key-set) (defsetting test-feature-setting "Setting to test the `:feature` property of settings. This only shows up in dev." :visibility :internal :type :string :default "setting-default" - :feature :test-feature) + :feature :test-feature + :encryption :when-encryption-key-set) (defsetting test-never-encrypted-setting "Setting to test the `:encryption` property of settings. This only shows up in dev." :visibility :internal :type :string - :encryption :never + :encryption :no :feature :test-feature) (defsetting test-boolean-encrypted-setting "Setting to test that a boolean setting can be encrypted, even though the default is not to. This only shows up in dev." :visibility :internal :type :boolean - :encryption :maybe + :encryption :when-encryption-key-set :feature :test-feature) ;; ## HELPER FUNCTIONS @@ -222,13 +234,15 @@ (defsetting test-no-default-with-base-setting "Setting to test the `:base` property of settings. This only shows up in dev." :visibility :internal - :base base-options) + :base base-options + :encryption :when-encryption-key-set) (defsetting test-default-with-base-setting "Setting to test the `:base` property of settings. This only shows up in dev." :visibility :internal :base base-options - :default "fully-bespoke") + :default "fully-bespoke" + :encryption :when-encryption-key-set) (deftest ^:parallel defsetting-with-base-test (testing "A setting which specifies some base options" @@ -426,7 +440,8 @@ setting))))))) (defsetting test-i18n-setting - (deferred-tru "Test setting - with i18n")) + (deferred-tru "Test setting - with i18n") + :encryption :when-encryption-key-set) (deftest validate-description-test (testing "Validate setting description with i18n string" @@ -444,7 +459,8 @@ (description))))))))) (defsetting test-dynamic-i18n-setting - (deferred-tru "Test setting - with i18n: {0}" (test-i18n-setting))) + (deferred-tru "Test setting - with i18n: {0}" (test-i18n-setting)) + :encryption :when-encryption-key-set) (deftest dynamic-description-test (testing "Descriptions with i18n string should update if it depends on another setting's value." @@ -645,7 +661,8 @@ (defsetting uncached-setting "A test setting that should *not* be cached." :visibility :internal - :cache? false) + :cache? false + :encryption :when-encryption-key-set) (deftest uncached-settings-test (encryption-test/with-secret-key nil @@ -724,23 +741,28 @@ (defsetting test-internal-setting "test Setting" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (defsetting test-public-setting (deferred-tru "test Setting") - :visibility :public) + :visibility :public + :encryption :when-encryption-key-set) (defsetting test-authenticated-setting (deferred-tru "test Setting") - :visibility :authenticated) + :visibility :authenticated + :encryption :when-encryption-key-set) (defsetting test-settings-manager-setting (deferred-tru "test Setting") - :visibility :settings-manager) + :visibility :settings-manager + :encryption :when-encryption-key-set) (defsetting test-admin-setting (deferred-tru "test Setting") - :visibility :admin) + :visibility :admin + :encryption :when-encryption-key-set) (deftest can-read-setting-test (testing "no authenticated user" @@ -783,18 +805,21 @@ "test Setting" :visibility :internal :type :integer - :database-local :only) + :database-local :only + :encryption :when-encryption-key-set) (defsetting ^:private test-database-local-allowed-setting (deferred-tru "test Setting") :visibility :authenticated :type :integer - :database-local :allowed) + :database-local :allowed + :encryption :when-encryption-key-set) (defsetting ^:private test-database-local-never-setting "test Setting" :visibility :internal - :type :integer) ; `:never` should be the default + :type :integer + :encryption :when-encryption-key-set) ; `:no` should be the default for `:database-local` (deftest database-local-settings-test (doseq [[database-local-type {:keys [setting-name setting-getter-fn setting-setter-fn returns]}] @@ -883,7 +908,8 @@ (deferred-tru "test Setting") :visibility :authenticated :database-local :only - :default "DEFAULT") + :default "DEFAULT" + :encryption :when-encryption-key-set) (deftest database-local-only-settings-test (testing "Disallow setting Database-local-only Settings" @@ -926,16 +952,19 @@ (defsetting test-user-local-only-setting (deferred-tru "test Setting") :visibility :authenticated - :user-local :only) + :user-local :only + :encryption :when-encryption-key-set) (defsetting test-user-local-allowed-setting (deferred-tru "test Setting") :visibility :authenticated - :user-local :allowed) + :user-local :allowed + :encryption :when-encryption-key-set) (defsetting ^:private test-user-local-never-setting (deferred-tru "test Setting") - :visibility :internal) ; `:never` should be the default + :visibility :internal + :encryption :when-encryption-key-set) ; `:no` should be the default for `:user-local` (deftest user-local-settings-test (testing "Reading and writing a user-local-only setting in the context of a user uses the user-local value" @@ -989,7 +1018,8 @@ (defsetting test-user-local-and-db-local-setting (deferred-tru "test Setting") :user-local :allowed - :database-local :allowed))))) + :database-local :allowed + :encryption :when-encryption-key-set))))) (deftest identity-hash-test (testing "Settings are hashed based on the key" @@ -1045,7 +1075,8 @@ :type :string :default "setting-default" :enabled? (fn [] false) - :feature :test-feature))))) + :feature :test-feature + :encryption :when-encryption-key-set))))) ;;; ------------------------------------------------- Misc tests ------------------------------------------------------- @@ -1069,7 +1100,8 @@ (defsetting ^:private test-integer-setting "test Setting" :visibility :internal - :type :integer) + :type :integer + :encryption :when-encryption-key-set) (deftest integer-setting-test (testing "Should be able to set integer setting with a string" @@ -1085,15 +1117,15 @@ (testing "Should not be able to define a setting with a retired name" (with-redefs [setting/retired-setting-names #{"retired-setting"}] (try - (defsetting retired-setting (deferred-tru "A retired setting name")) + (defsetting retired-setting (deferred-tru "A retired setting name") :encryption :when-encryption-key-set) (catch Exception e (is (= "Setting name 'retired-setting' is retired; use a different name instead" (ex-message e)))))))) (deftest duplicated-setting-name (testing "can re-register a setting in the same ns (redefining or reloading ns)" - (is (defsetting foo (deferred-tru "A testing setting") :visibility :public)) - (is (defsetting foo (deferred-tru "A testing setting") :visibility :public))) + (is (defsetting foo (deferred-tru "A testing setting") :visibility :public :encryption :when-encryption-key-set)) + (is (defsetting foo (deferred-tru "A testing setting") :visibility :public :encryption :when-encryption-key-set))) (testing "if attempt to register in a different ns throws an error" (let [current-ns (ns-name *ns*)] (try @@ -1101,7 +1133,7 @@ (:require [metabase.models.setting :refer [defsetting]] [metabase.util.i18n :as i18n :refer [deferred-tru]])) - (defsetting foo (deferred-tru "A testing setting") :visibility :public) + (defsetting foo (deferred-tru "A testing setting") :visibility :public :encryption :when-encryption-key-set) (catch Exception e (is (=? {:existing-setting {:description (deferred-tru "A testing setting") @@ -1119,7 +1151,8 @@ (defsetting test-setting-with-question-mark? "Test setting - this only shows up in dev (6)" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (deftest munged-setting-name-test (testing "Only valid characters used for environment lookup" @@ -1138,29 +1171,34 @@ (m/map-vals #(select-keys % [:name :munged-name]) (try (defsetting test-setting-with-question-mark???? "Test setting - this only shows up in dev (6)" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (catch Exception e (ex-data e))))))) (testing "Munge collision on first definition" (defsetting test-setting-normal "Test setting - this only shows up in dev (6)" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (is (= {:existing-setting {:name :test-setting-normal, :munged-name "test-setting-normal"}, :new-setting {:name :test-setting-normal??, :munged-name "test-setting-normal"}} (m/map-vals #(select-keys % [:name :munged-name]) (try (defsetting test-setting-normal?? "Test setting - this only shows up in dev (6)" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (catch Exception e (ex-data e))))))) (testing "Munge collision on second definition" (defsetting test-setting-normal-1?? "Test setting - this only shows up in dev (6)" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (is (= {:new-setting {:munged-name "test-setting-normal-1", :name :test-setting-normal-1}, :existing-setting {:munged-name "test-setting-normal-1", :name :test-setting-normal-1??}} (m/map-vals #(select-keys % [:name :munged-name]) (try (defsetting test-setting-normal-1 "Test setting - this only shows up in dev (6)" - :visibility :internal) + :visibility :internal + :encryption :when-encryption-key-set) (catch Exception e (ex-data e))))))) (testing "Removes characters not-compliant with shells" (is (= "aa1aa-b2b_cc3c" @@ -1210,14 +1248,16 @@ (try (walk/macroexpand-all `(defsetting ~'test-asdf-asdf-asdf - "untranslated description")) + "untranslated description" + :encryption :when-encryption-key-set)) (catch Exception e (is (re-matches #"defsetting docstrings must be a \*deferred\* i18n form.*" (:cause (Throwable->map e))))))))) (defsetting test-setting-audit-never "Test setting with no auditing" - :audit :never) + :audit :never + :encryption :when-encryption-key-set) (defsetting test-setting-audit-raw-value "Test setting with auditing raw values" @@ -1226,15 +1266,17 @@ (defsetting test-setting-audit-getter "Test setting with auditing values returned from getter" - :type :string - :getter (constantly "GETTER VALUE") - :audit :getter) + :type :string + :getter (constantly "GETTER VALUE") + :audit :getter + :encryption :when-encryption-key-set) (defsetting test-sensitive-setting-audit "Test that a sensitive setting has its value obfuscated before being audited" :type :string :sensitive? true - :audit :getter) + :audit :getter + :encryption :when-encryption-key-set) (deftest setting-audit-test (mt/with-premium-features #{:audit-app} @@ -1293,6 +1335,7 @@ (deferred-tru "Audited user-local setting") :visibility :authenticated :user-local :only + :encryption :when-encryption-key-set :audit :raw-value) (deftest user-local-settings-audit-test @@ -1319,11 +1362,13 @@ (defsetting exported-setting "This setting would be serialized" :export? true + :encryption :when-encryption-key-set ;; make sure it's internal so it doesn't interfere with export test :visibility :internal) (defsetting non-exported-setting "This setting would not be serialized" + :encryption :when-encryption-key-set :export? false) (deftest export?-test @@ -1354,7 +1399,8 @@ (defmacro define-setting-for-type [format] `(defsetting ~(validation-setting-symbol format) "Setting to test validation of this format - this only shows up in dev" - :type ~(keyword (name format)))) + :type ~(keyword (name format)) + :encryption :when-encryption-key-set)) (defmacro get-parse-exception [format raw-value] `(mt/with-temp-env-var-value! [~(symbol (str "mb-" (validation-setting-symbol format))) ~raw-value] @@ -1554,6 +1600,6 @@ (deftest boolean-settings-default-to-never-encrypted (testing "Boolean settings default to never encrypted" - (is (= :never (:encryption (setting/resolve-setting :test-boolean-setting))))) + (is (= :no (:encryption (setting/resolve-setting :test-boolean-setting))))) (testing "Boolean settings can be encrypted" - (is (= :maybe (:encryption (setting/resolve-setting :test-boolean-encrypted-setting)))))) + (is (= :when-encryption-key-set (:encryption (setting/resolve-setting :test-boolean-encrypted-setting)))))) diff --git a/test/metabase/public_settings/premium_features_test.clj b/test/metabase/public_settings/premium_features_test.clj index 30275536e88f9702715ca8506fc70a38632aabbc..a244a58ff59b14166c83ef87b62eb0d2844f71d3 100644 --- a/test/metabase/public_settings/premium_features_test.clj +++ b/test/metabase/public_settings/premium_features_test.clj @@ -4,6 +4,7 @@ [clj-http.client :as http] [clj-http.fake :as http-fake] [clojure.test :refer :all] + [diehard.circuit-breaker :as dh.cb] [mb.hawk.parallel] [metabase.config :as config] [metabase.db.connection :as mdb.connection] @@ -16,6 +17,24 @@ [toucan2.core :as t2] [toucan2.tools.with-temp :as t2.with-temp])) +(set! *warn-on-reflection* true) + +(defn- open-circuit-breaker! [cb] + (.open ^dev.failsafe.CircuitBreaker cb)) + +(defmacro with-open-circuit-breaker! [& body] + `(binding [premium-features/*store-circuit-breaker* (dh.cb/circuit-breaker + @#'premium-features/store-circuit-breaker-config)] + (open-circuit-breaker! premium-features/*store-circuit-breaker*) + (do ~@body))) + +(defn reset-circuit-breaker-fixture [f] + (binding [premium-features/*store-circuit-breaker* (dh.cb/circuit-breaker + @#'premium-features/store-circuit-breaker-config)] + (f))) + +(use-fixtures :each reset-circuit-breaker-fixture) + (defn- token-status-response [token premium-features-response] (http-fake/with-fake-routes-in-isolation @@ -32,25 +51,25 @@ :features ["test" "fixture"] :trial false})) -(def random-fake-token - "d7ad0b5f9ddfd1953b1b427b75d620e4ba91d38e7bcbc09d8982480863dbc611") - -(defn- random-token [] +(defn random-token + "A random token-like string" + [] (let [alphabet (into [] (concat (range 0 10) (map char (range (int \a) (int \g)))))] (apply str (repeatedly 64 #(rand-nth alphabet))))) (deftest ^:parallel fetch-token-status-test - (let [print-token "d7ad...c611"] + (let [token (random-token) + print-token (apply str (concat (take 4 token) "..." (take-last 4 token)))] (testing "Do not log the token (#18249)" (mt/with-log-messages-for-level [messages :info] - (#'premium-features/fetch-token-status* random-fake-token) + (#'premium-features/fetch-token-status* token) (let [logs (mapv :message (messages))] - (is (every? (complement #(re-find (re-pattern random-fake-token) %)) logs)) + (is (every? (complement #(re-find (re-pattern token) %)) logs)) (is (= 1 (count (filter #(re-find (re-pattern print-token) %) logs))))))))) (deftest ^:parallel fetch-token-status-test-2 (testing "With the backend unavailable" - (let [result (token-status-response random-fake-token {:status 500})] + (let [result (token-status-response (random-token) {:status 500})] (is (false? (:valid result)))))) (deftest ^:parallel fetch-token-status-test-3 @@ -64,33 +83,63 @@ :error-details "network issues"} (premium-features/fetch-token-status (apply str (repeat 64 "b")))))))) -(deftest fetch-token-status-test-4 - (testing "Only attempt the token twice (default and fallback URLs)" +(deftest fetch-token-caches-successful-responses + (testing "For successful responses, the result is cached" (let [call-count (atom 0) token (random-token)] (binding [http/request (fn [& _] (swap! call-count inc) - (throw (Exception. "no internet")))] - (mt/with-temporary-raw-setting-values [:premium-embedding-token token] - (testing "Sanity check" - (is (= token - (premium-features/premium-embedding-token))) - (is (= #{} - (premium-features/*token-features*)))) - (doseq [has-feature? [#'premium-features/hide-embed-branding? - #'premium-features/enable-whitelabeling? - #'premium-features/enable-audit-app? - #'premium-features/enable-sandboxes? - #'premium-features/enable-serialization?]] - (testing (format "\n%s is false" (:name (meta has-feature?))) - (is (not (has-feature?))))) - (is (= 2 - @call-count))))))) - -(deftest ^:parallel fetch-token-status-test-5 + {:status 200 :body "{\"valid\": true, \"status\": \"fake\"}"})] + (dotimes [_ 10] (premium-features/fetch-token-status token)) + (is (= 1 @call-count)))))) + +(deftest fetch-token-caches-invalid-responses + (testing "For 4XX responses, the result is cached" + (let [call-count (atom 0) + token (random-token)] + (binding [http/request (fn [& _] + (swap! call-count inc) + {:status 400 :body "{\"valid\": false, \"status\": \"fake\"}"})] + (dotimes [_ 10] (premium-features/fetch-token-status token)) + (is (= 1 @call-count)))))) + +(deftest fetch-token-does-not-cache-exceptions + (testing "For timeouts, 5XX errors, etc. we don't cache the result" + (let [call-count (atom 0) + token (random-token)] + (binding [http/request (fn [& _] + (swap! call-count inc) + (throw (ex-info "oh, fiddlesticks" {})))] + (dotimes [_ 5] (premium-features/fetch-token-status token)) + ;; Note that we have a fallback URL that gets hit in this case (see + ;; https://github.com/metabase/metabase/issues/27036) and 2x5=10 + (is (= 10 @call-count)))))) + +(deftest fetch-token-does-not-cache-5XX-responses + (let [call-count (atom 0) + token (random-token)] + (binding [http/request (fn [& _] + (swap! call-count inc) + {:status 500})] + (dotimes [_ 10] (premium-features/fetch-token-status token)) + ;; Same as above, we have a fallback URL that gets hit in this case (see + ;; https://github.com/metabase/metabase/issues/27036) and 2x10=20 + (is (= 10 @call-count))))) + +(deftest fetch-token-is-circuit-broken + (let [call-count (atom 0)] + (with-open-circuit-breaker! + (binding [http/request (fn [& _] (swap! call-count inc))] + (is (= {:valid false + :status "Unable to validate token" + :error-details "Token validation is currently unavailable."} + (premium-features/fetch-token-status (random-token)))) + (is (= 0 @call-count)))))) + +(deftest ^:parallel fetch-token-status-test-4 (testing "With a valid token" - (let [result (token-status-response random-fake-token {:status 200 - :body token-response-fixture})] + (let [result (token-status-response (random-token) {:status 200 + :body token-response-fixture})] (is (:valid result)) (is (contains? (set (:features result)) "test"))))) @@ -100,7 +149,7 @@ ;; upstream in Cloud could break this. We probably want to catch that stuff anyway tho in tests rather than waiting ;; for bug reports to come in (is (partial= {:valid false, :status "Token does not exist."} - (#'premium-features/fetch-token-status* random-fake-token))))) + (#'premium-features/fetch-token-status* (random-token)))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Defenterprise Macro Tests | diff --git a/test/metabase/query_processor/middleware/add_default_temporal_unit_test.clj b/test/metabase/query_processor/middleware/add_default_temporal_unit_test.clj deleted file mode 100644 index 10c30173991395567eb73c474fe665c894add2fd..0000000000000000000000000000000000000000 --- a/test/metabase/query_processor/middleware/add_default_temporal_unit_test.clj +++ /dev/null @@ -1,85 +0,0 @@ -(ns metabase.query-processor.middleware.add-default-temporal-unit-test - (:require - [clojure.test :refer :all] - [metabase.lib.test-metadata :as meta] - [metabase.lib.test-util.macros :as lib.tu.macros] - [metabase.query-processor.middleware.add-default-temporal-unit - :as add-default-temporal-unit] - [metabase.query-processor.store :as qp.store])) - -(defn- add-default-temporal-unit [query] - (qp.store/with-metadata-provider meta/metadata-provider - (add-default-temporal-unit/add-default-temporal-unit query))) - -(deftest ^:parallel add-default-temporal-unit-test - (testing "Should add temporal-unit :default to a :field clause" - (testing "with a Field ID" - (is (= (lib.tu.macros/mbql-query checkins - {:filter [:> !default.date "2021-05-13T00:00:00Z"]}) - (add-default-temporal-unit - (lib.tu.macros/mbql-query checkins - {:filter [:> $date "2021-05-13T00:00:00Z"]}))))))) - -(deftest ^:parallel add-default-temporal-unit-test-2 - (testing "Should add temporal-unit :default to a :field clause" - (testing "with a Field name and temporal base type" - (is (= (lib.tu.macros/mbql-query checkins - {:filter [:> - [:field "date" {:base-type :type/TimeWithLocalTZ, :temporal-unit :default}] - "2021-05-13T00:00:00Z"]}) - (add-default-temporal-unit - (lib.tu.macros/mbql-query checkins - {:filter [:> - [:field "date" {:base-type :type/TimeWithLocalTZ}] - "2021-05-13T00:00:00Z"]}))))))) - -(deftest ^:parallel add-default-temporal-unit-test-3 - (testing "Should ignore fields that already have a temporal unit" - (testing "with an ID" - (let [query (lib.tu.macros/mbql-query checkins - {:filter [:> !month.date "2021-05-13T00:00:00Z"]})] - (is (= query - (add-default-temporal-unit query))))) - (testing "with a field name" - (let [query (lib.tu.macros/mbql-query checkins - {:filter [:> - [:field "date" {:base-type :type/TimeWithLocalTZ, :temporal-unit :month}] - "2021-05-13T00:00:00Z"]})] - (is (= query - (add-default-temporal-unit query))))))) - -(deftest ^:parallel add-default-temporal-unit-test-4 - (testing "Should add temporal-unit :default to a :field clause" - (testing "with a Field ID" - (is (= (lib.tu.macros/mbql-query checkins - {:filter [:> !default.date "2021-05-13T00:00:00Z"]}) - (add-default-temporal-unit - (lib.tu.macros/mbql-query checkins - {:filter [:> $date "2021-05-13T00:00:00Z"]}))))))) - -(deftest ^:parallel add-default-temporal-unit-test-5 - (testing "Should add temporal-unit :default to a :field clause" - (testing "with a Field name and temporal base type" - (is (= (lib.tu.macros/mbql-query checkins - {:filter [:> - [:field "date" {:base-type :type/TimeWithLocalTZ, :temporal-unit :default}] - "2021-05-13T00:00:00Z"]}) - (add-default-temporal-unit - (lib.tu.macros/mbql-query checkins - {:filter [:> - [:field "date" {:base-type :type/TimeWithLocalTZ}] - "2021-05-13T00:00:00Z"]}))))))) - -(deftest ^:parallel ignore-parameters-test - (testing "Don't try to update query `:parameters`" - (let [query {:database (meta/id) - :type :native - :native {:query "select 111 as my_number, 'foo' as my_string"} - :parameters [{:type "category" - :value [:param-value] - :target [:dimension - [:field - (meta/id :categories :id) - {:source-field (meta/id :venues :category-id)}]]}]}] - (is (= query - (add-default-temporal-unit query)))))) diff --git a/test/metabase/query_processor/middleware/add_implicit_clauses_test.clj b/test/metabase/query_processor/middleware/add_implicit_clauses_test.clj index 27687df3555540d21d84eba9698e44bcaab6588b..609ab21720ed81e20737e50d9093b0dd19320efa 100644 --- a/test/metabase/query_processor/middleware/add_implicit_clauses_test.clj +++ b/test/metabase/query_processor/middleware/add_implicit_clauses_test.clj @@ -151,7 +151,7 @@ (is (query= (:query (lib.tu.macros/mbql-query venues {:fields [$id $name - [:field 1 {:temporal-unit :default}] + [:field 1 nil] $category-id $latitude $longitude $price]})) (add-implicit-fields (:query (lib.tu.macros/mbql-query venues)))))))) diff --git a/test/metabase/query_processor/middleware/add_implicit_joins_test.clj b/test/metabase/query_processor/middleware/add_implicit_joins_test.clj index 5b0d7a196db59f1f7bfbdc400d63a8ca981ee3dc..cfc48a9a87e02a0ccef4603defd7a9c41349fb27 100644 --- a/test/metabase/query_processor/middleware/add_implicit_joins_test.clj +++ b/test/metabase/query_processor/middleware/add_implicit_joins_test.clj @@ -274,7 +274,7 @@ $tax $total $discount - !default.created-at + $created-at $quantity [:field %products.category {:source-field %product-id :join-alias "PRODUCTS__via__PRODUCT_ID"}]] @@ -374,7 +374,7 @@ (is (= (lib.tu.macros/mbql-query checkins {:source-query {:source-table $$checkins :fields [$id - !default.date + $date $user-id $venue-id] :filter [:> $date "2014-01-01"]} diff --git a/test/metabase/query_processor/middleware/add_source_metadata_test.clj b/test/metabase/query_processor/middleware/add_source_metadata_test.clj index 64dbb49c8b1cf2f82d0053b825d5a995f9d582d6..d319419d5ab59f00617ec94aad7d141942834fb0 100644 --- a/test/metabase/query_processor/middleware/add_source_metadata_test.clj +++ b/test/metabase/query_processor/middleware/add_source_metadata_test.clj @@ -366,13 +366,7 @@ ;; the actual metadata this middleware should return. Doesn't have all the columns that come back from ;; `qp.preprocess/query->expected-cols` expected-metadata (for [col metadata] - (cond-> (merge (results-col col) (select-keys col [:source_alias])) - ;; for some reason this middleware returns temporal fields with a `:default` unit, - ;; whereas `query->expected-cols` does not return the unit. It ulimately makes zero - ;; difference, so I haven't looked into why this is the case yet. - (isa? (:base_type col) :type/Temporal) - (update :field_ref (fn [[_ id-or-name opts]] - [:field id-or-name (assoc opts :temporal-unit :default)]))))] + (merge (results-col col) (select-keys col [:source_alias])))] (letfn [(added-metadata [query] (get-in (add-source-metadata query) [:query :source-metadata]))] (testing "\nShould add source metadata if there's none already" diff --git a/test/metabase/query_processor/middleware/resolve_joins_test.clj b/test/metabase/query_processor/middleware/resolve_joins_test.clj index c14e96b9e4eb24bd75261f6e2a2708532a938d03..f0aa38e5c9f1e45e5ee18abbf06b25fa33dd5781 100644 --- a/test/metabase/query_processor/middleware/resolve_joins_test.clj +++ b/test/metabase/query_processor/middleware/resolve_joins_test.clj @@ -212,7 +212,7 @@ :strategy :left-join :condition [:= $id [:field "USER_ID" {:base-type :type/Integer, :join-alias "c"}]] :fields [&c.checkins.id - !default.&c.checkins.date + &c.checkins.date &c.checkins.user_id &c.checkins.venue_id]}] :aggregation [[:sum [:field "id" {:base-type :type/Float, :join-alias "c"}]]] diff --git a/test/metabase/query_processor/test_util.clj b/test/metabase/query_processor/test_util.clj index 260820190942e47315b251794e26f8625166dfaf..0b86cdfa7b6569a31e26b718715c1f14bd7227ff 100644 --- a/test/metabase/query_processor/test_util.clj +++ b/test/metabase/query_processor/test_util.clj @@ -100,10 +100,7 @@ (t2/select-one [:model/Field :id :table_id :semantic_type :base_type :effective_type :coercion_strategy :name :display_name :fingerprint] :id (data/id table-kw field-kw))) - {:field_ref [:field (data/id table-kw field-kw) nil]} - (when (#{:last_login :date} field-kw) - {:unit :default - :field_ref [:field (data/id table-kw field-kw) {:temporal-unit :default}]}))) + {:field_ref [:field (data/id table-kw field-kw) nil]})) (defn- expected-column-names "Get a sequence of keyword names of Fields belonging to a Table in the order they'd normally appear in QP results." diff --git a/test/metabase/query_processor/util/add_alias_info_test.clj b/test/metabase/query_processor/util/add_alias_info_test.clj index cff464c9d0cdd056db0969f07f440e31263a7ba3..79c68c4f9d8fd86bc6a050ad4039aab80c01189e 100644 --- a/test/metabase/query_processor/util/add_alias_info_test.clj +++ b/test/metabase/query_processor/util/add_alias_info_test.clj @@ -197,16 +197,14 @@ ::add/desired-alias "count" ::add/position 0}]] :filter [:!= - [:field %date {:temporal-unit :default - ::add/source-table $$checkins + [:field %date {::add/source-table $$checkins ::add/source-alias "DATE"}] [:value nil {:base_type :type/Date :effective_type :type/Date :coercion_strategy nil :semantic_type nil :database_type "DATE" - :name "DATE" - :unit :default}]]}) + :name "DATE"}]]}) (add-alias-info (lib.tu.macros/mbql-query checkins {:aggregation [[:count]] diff --git a/test/metabase/query_processor/util/nest_query_test.clj b/test/metabase/query_processor/util/nest_query_test.clj index f66aac0545c0fd2a9f13c21370bd73dbc6ad2ce2..cfa089415e83537cb5bab80ab8c80d9880d17239 100644 --- a/test/metabase/query_processor/util/nest_query_test.clj +++ b/test/metabase/query_processor/util/nest_query_test.clj @@ -480,8 +480,7 @@ (is (partial= (lib.tu.macros/$ids venues {:source-query {:source-table $$venues :expressions {"test" [:* 1 1]} - :fields [[:field %price {:temporal-unit :default - ::add/source-table $$venues + :fields [[:field %price {::add/source-table $$venues ::add/source-alias "PRICE" ::add/desired-alias "PRICE" ::add/position 0}] @@ -517,8 +516,7 @@ ::add/source-alias "PRODUCT_ID" ::add/desired-alias "PRODUCT_ID" ::add/position 0}] - created-at [:field %created-at {:temporal-unit :default - ::add/source-table $$orders + created-at [:field %created-at {::add/source-table $$orders ::add/source-alias "CREATED_AT" ::add/desired-alias "CREATED_AT" ::add/position 1}] diff --git a/test/metabase/query_processor_test/alternative_date_test.clj b/test/metabase/query_processor_test/alternative_date_test.clj index c1aea168818fb7811472c8ecbfc3d08c30de6137..d457330e10325ac59138c34313814e2cde94a469 100644 --- a/test/metabase/query_processor_test/alternative_date_test.clj +++ b/test/metabase/query_processor_test/alternative_date_test.clj @@ -240,6 +240,13 @@ (assoc (mt/mbql-query just-dates {:order-by [[:asc $id]]}) :middleware {:format-rows? false})))) +(defmethod iso-8601-text-fields-query :databricks + [_driver] + (mt/dataset + just-dates + (assoc (mt/mbql-query just_dates {:order-by [[:asc $id]]}) + :middleware {:format-rows? false}))) + (defmulti iso-8601-text-fields-expected-rows "Expected rows for the [[iso-8601-text-fields]] test below." {:arglists '([driver])} @@ -260,6 +267,12 @@ [2 "bar" #t "2008-10-19T10:23:54Z[UTC]" #t "2008-10-19"] [3 "baz" #t "2012-10-19T10:23:54Z[UTC]" #t "2012-10-19"]]) +(defmethod iso-8601-text-fields-expected-rows :databricks + [_driver] + [[1 "foo" (t/offset-date-time #t "2004-10-19T10:23:54Z") #t "2004-10-19"] + [2 "bar" (t/offset-date-time #t "2008-10-19T10:23:54Z") #t "2008-10-19"] + [3 "baz" (t/offset-date-time #t "2012-10-19T10:23:54Z") #t "2012-10-19"]]) + ;;; oracle doesn't have a time type (defmethod iso-8601-text-fields-expected-rows :oracle [_driver] @@ -322,6 +335,13 @@ {:filter [:= !day.ts "2008-10-19"] :fields [$ts]})) +(defmethod iso-8601-text-fields-should-be-queryable-datetime-test-query :databricks + [_driver] + (mt/mbql-query + times + {:filter [:= !day.ts "2008-10-19"] + :fields [$d $ts]})) + (deftest ^:parallel iso-8601-text-fields-should-be-queryable-datetime-test (testing "text fields with semantic_type :type/ISO8601DateTimeString" (testing "are queryable as dates" @@ -352,7 +372,8 @@ (mt/test-drivers (mt/normal-drivers-with-feature ::iso-8601-test-fields-are-queryable ::parse-string-to-date) (is (= 1 (->> (mt/run-mbql-query times - {:filter [:= !day.d "2008-10-19"]}) + {:fields [$d] + :filter [:= !day.d "2008-10-19"]}) mt/rows count))))))))) diff --git a/test/metabase/query_processor_test/date_bucketing_test.clj b/test/metabase/query_processor_test/date_bucketing_test.clj index e77e23da515255070d097ac94d3277cf0ad03884..28ca00db0914a371a69ef9796a429ab0ddb30cae 100644 --- a/test/metabase/query_processor_test/date_bucketing_test.clj +++ b/test/metabase/query_processor_test/date_bucketing_test.clj @@ -1265,19 +1265,8 @@ (or (some-> results mt/first-row first int) results))))) -;;; whether the driver supports loading dynamic test datasets on each test run. Datasets in tests below with names like -;;; `checkins:4-per-minute` are created dynamically in each test run. This should be truthy for every driver we test -;;; against except for Athena which currently requires test data to be loaded separately. -(defmethod driver/database-supports? [::driver/driver ::dynamic-dataset-loading] - [_driver _feature _database] - true) - -(defmethod driver/database-supports? [:athena ::dynamic-dataset-loading] - [_driver _feature _database] - false) - (deftest ^:parallel count-of-grouping-test - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (testing "4 checkins per minute dataset" (testing "group by minute" (doseq [args [[:current] [-1 :minute] [1 :minute]]] @@ -1286,7 +1275,7 @@ (format "filter by minute = %s" (into [:relative-datetime] args)))))))) (deftest ^:parallel count-of-grouping-test-2 - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (testing "4 checkins per hour dataset" (testing "group by hour" (doseq [args [[:current] [-1 :hour] [1 :hour]]] @@ -1295,7 +1284,7 @@ (format "filter by hour = %s" (into [:relative-datetime] args)))))))) (deftest ^:parallel count-of-grouping-test-3 - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (testing "1 checkin per day dataset" (testing "group by day" (doseq [args [[:current] [-1 :day] [1 :day]]] @@ -1304,7 +1293,7 @@ (format "filter by day = %s" (into [:relative-datetime] args)))))))) (deftest ^:parallel count-of-grouping-test-4 - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (testing "1 checkin per day dataset" (testing "group by week" (is (= 7 @@ -1312,7 +1301,7 @@ "filter by week = [:relative-datetime :current]"))))) (deftest ^:parallel time-interval-test - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (testing "Syntactic sugar (`:time-interval` clause)" (mt/dataset checkins:1-per-day (is (= 1 @@ -1324,7 +1313,7 @@ :filter [:time-interval $timestamp :current :day]}))))))))) (deftest ^:parallel time-interval-test-2 - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (testing "Syntactic sugar (`:time-interval` clause)" (mt/dataset checkins:1-per-day (is (= 7 @@ -1336,7 +1325,7 @@ :filter [:time-interval $timestamp :last :week]}))))))))) (deftest ^:parallel time-interval-expression-test - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (mt/dataset checkins:1-per-day (let [metadata-provider (lib.metadata.jvm/application-database-metadata-provider (mt/id)) orders (lib.metadata/table metadata-provider (mt/id :checkins)) @@ -1353,7 +1342,7 @@ (deftest ^:parallel relative-time-interval-test (mt/test-drivers - (mt/normal-drivers-with-feature :date-arithmetics ::dynamic-dataset-loading) + (mt/normal-drivers-with-feature :date-arithmetics :test/dynamic-dataset-loading) ;; Following verifies #45942 is solved. Changing the offset ensures that intervals do not overlap. (testing "Syntactic sugar (`:relative-time-interval` clause) (#45942)" (mt/dataset checkins:1-per-day:60 @@ -1388,7 +1377,7 @@ :unit (-> results :data :cols first :unit)})) (deftest ^:parallel date-bucketing-when-you-test - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (is (= {:rows 1, :unit :day} (date-bucketing-unit-when-you :breakout-by "day", :filter-by "day"))) (is (= {:rows 7, :unit :day} @@ -1416,7 +1405,7 @@ ;; We should get count = 1 for the current day, as opposed to count = 0 if we weren't auto-bucketing ;; (e.g. 2018-11-19T00:00 != 2018-11-19T12:37 or whatever time the checkin is at) (deftest ^:parallel default-bucketing-test - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (mt/dataset checkins:1-per-day (is (= [[1]] (mt/formatted-rows @@ -1460,7 +1449,7 @@ true) (deftest ^:parallel default-bucketing-test-4 - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (testing "if datetime string is not yyyy-MM-dd no date bucketing should take place, and thus we should get no (exact) matches" (mt/dataset checkins:1-per-day (is (= (if (driver/database-supports? driver/*driver* ::empty-results-wrong-because-of-issue-5419 (mt/db)) @@ -1734,7 +1723,7 @@ (deftest filter-by-expression-time-interval-test (testing "Datetime expressions can filter to a date range (#33528)" - (mt/test-drivers (mt/normal-drivers-with-feature ::dynamic-dataset-loading) + (mt/test-drivers (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) (mt/dataset checkins:1-per-day (let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id)) @@ -1757,7 +1746,7 @@ (deftest filter-by-expression-relative-time-interval-test (testing "Datetime expressions can filter to a date range" (mt/test-drivers - (mt/normal-drivers-with-feature :date-arithmetics ::dynamic-dataset-loading) + (mt/normal-drivers-with-feature :date-arithmetics :test/dynamic-dataset-loading) (mt/dataset checkins:1-per-day:60 (let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id)) query (as-> (lib/query mp (lib.metadata/table mp (mt/id :checkins))) $q diff --git a/test/metabase/query_processor_test/explicit_joins_test.clj b/test/metabase/query_processor_test/explicit_joins_test.clj index 2d2b8d76e67419ea2818eb34610c3ba9467ae8e9..d43fd04aa2ccdd24e73272334108628faf7e34b9 100644 --- a/test/metabase/query_processor_test/explicit_joins_test.clj +++ b/test/metabase/query_processor_test/explicit_joins_test.clj @@ -593,7 +593,8 @@ (is (= [["2019-11-01T07:23:18Z" "2019-11-01T07:23:18Z"]] (mt/formatted-rows [u.date/temporal-str->iso8601-str u.date/temporal-str->iso8601-str] - (mt/run-mbql-query attempts + (mt/run-mbql-query + attempts {:fields [$datetime_tz] :filter [:and [:between $datetime_tz "2019-11-01" "2019-11-01"] diff --git a/test/metabase/query_processor_test/nested_queries_test.clj b/test/metabase/query_processor_test/nested_queries_test.clj index dc48a7ec39e896612f35f84157e42259e1b5a861..3a9f863d2381d149f63beeee24c954b028bbebde 100644 --- a/test/metabase/query_processor_test/nested_queries_test.clj +++ b/test/metabase/query_processor_test/nested_queries_test.clj @@ -670,7 +670,7 @@ ;; the `:year` bucketing if you used this query in another subsequent query, so the field ref doesn't ;; include the unit; however `:unit` is still `:year` so the frontend can use the correct formatting to ;; display values of the column. - (is (=? [(assoc date-col :field_ref [:field (mt/id :checkins :date) {:temporal-unit :default}], :unit :year) + (is (=? [(assoc date-col :field_ref [:field (mt/id :checkins :date) nil], :unit :year) (assoc count-col :field_ref [:field "count" {:base-type :type/Integer}])] (mt/cols (qp/process-query (query-with-source-card 1))))))))))) @@ -1267,7 +1267,7 @@ $tax $total $discount - !default.created_at + $created_at $quantity &â„™.products.id &â„™.products.ean @@ -1276,7 +1276,7 @@ &â„™.products.vendor &â„™.products.price &â„™.products.rating - !default.&â„™.products.created_at]) + &â„™.products.created_at]) (map :field_ref metadata)))) (testing "\nShould be able to use the query as a source query" (letfn [(test-query [query] diff --git a/test/metabase/query_processor_test/timezones_test.clj b/test/metabase/query_processor_test/timezones_test.clj index 52171e856347216980aa6e288a0e50f65d1908a3..0d5b4d126fd5fdaaa5d3404372f5e65bc2e16428 100644 --- a/test/metabase/query_processor_test/timezones_test.clj +++ b/test/metabase/query_processor_test/timezones_test.clj @@ -220,12 +220,24 @@ (defn- supports-datetime-with-offset? [] (driver-distinguishes-between-base-types? :type/DateTimeWithZoneOffset :type/DateTimeWithTZ)) (defn- supports-datetime-with-zone-id? [] (driver-distinguishes-between-base-types? :type/DateTimeWithZoneID :type/DateTimeWithTZ)) +;; Following signals whether driver maps some database type to `:type/DateTime` (and not its descendants). +(defmethod driver/database-supports? [::driver/driver :test/date-time-type] + [_driver _feature _database] + true) + +;; TODO: Remove this when https://github.com/metabase/metabase/issues/47359 is addressed. +(defmethod driver/database-supports? [:databricks :test/date-time-type] + [_driver _feature _database] + false #_true) + (defn- expected-attempts [] (merge {:date (t/local-date "2019-11-01") - :time (t/local-time "00:23:18.331") - :datetime (t/local-date-time "2019-11-01T00:23:18.331") :datetime_ltz (t/offset-date-time "2019-11-01T07:23:18.331Z")} + (when (driver/database-supports? driver/*driver* :test/date-time-type nil) + {:datetime (t/local-date-time "2019-11-01T00:23:18.331")}) + (when (driver/database-supports? driver/*driver* :test/time-type nil) + {:time (t/local-time "00:23:18.331")}) (when (supports-time-with-time-zone?) {:time_ltz (t/offset-time "07:23:18.331Z")}) (when (supports-time-with-offset?) @@ -320,7 +332,8 @@ (is (= expected-row row)))))))))) (deftest filter-datetime-by-date-in-timezone-relative-to-current-date-test - (mt/test-drivers (set-timezone-drivers) + (mt/test-drivers (set/intersection (mt/normal-drivers-with-feature :test/dynamic-dataset-loading) + (set-timezone-drivers)) (testing "Relative to current date" (let [expected-datetime (u.date/truncate (t/zoned-date-time) :second)] (mt/with-temp-test-data [["relative_filter" @@ -344,7 +357,8 @@ t/offset-date-time))))))))))))) (deftest filter-datetime-by-date-in-timezone-relative-to-days-since-test - (mt/test-drivers (set-timezone-drivers) + (mt/test-drivers (filter #(driver/database-supports? % :test/dynamic-dataset-loading nil) + (set-timezone-drivers)) (testing "Relative to days since" (let [expected-datetime (u.date/truncate (u.date/add (t/zoned-date-time) :day -1) :second)] (mt/with-temp-test-data [["relative_filter" diff --git a/test/metabase/search/impl_test.clj b/test/metabase/search/impl_test.clj index 57f260bd46287ea079ef1089e2106c4d9752fd51..301a851ee54b55c91976ce726d108bfab359db04 100644 --- a/test/metabase/search/impl_test.clj +++ b/test/metabase/search/impl_test.clj @@ -8,12 +8,29 @@ [java-time.api :as t] [metabase.api.common :as api] [metabase.config :as config] + [metabase.search :as search] [metabase.search.config :as search.config] [metabase.search.impl :as search.impl] + [metabase.search.legacy :as search.legacy] [metabase.test :as mt] [toucan2.core :as t2] [toucan2.tools.with-temp :as t2.with-temp])) +(deftest ^:parallel parse-engine-test + (testing "Default engine" + (is (= :search.engine/in-place (#'search.impl/parse-engine nil)))) + (testing "Unknown engine resolves to the default" + (is (= (#'search.impl/parse-engine nil) + (#'search.impl/parse-engine "vespa")))) + (testing "Registered engines" + (is (= :search.engine/in-place (#'search.impl/parse-engine "in-place"))) + (when (search/supports-index?) + (is (= :search.engine/fulltext (#'search.impl/parse-engine "fulltext"))))) + (when (search/supports-index?) + (testing "Subclasses" + (is (= :search.engine/hybrid (#'search.impl/parse-engine "hybrid"))) + (is (= :search.engine/minimal (#'search.impl/parse-engine "minimal")))))) + (deftest ^:parallel order-clause-test (testing "it includes all columns and normalizes the query" (is (= [[:case @@ -31,7 +48,7 @@ [:like [:lower :model_name] "%foo%"] [:inline 0] [:like [:lower :dataset_query] "%foo%"] [:inline 0] :else [:inline 1]]] - (search.impl/order-clause "Foo"))))) + (search.legacy/order-clause "Foo"))))) (deftest search-db-call-count-test (let [search-string (mt/random-name)] diff --git a/test/metabase/search/postgres/core_test.clj b/test/metabase/search/postgres/core_test.clj index 1199832745373b18c3c1da952bf793828ece0cc0..2a362ef83796bf8698576df29882adb3dd8d2888 100644 --- a/test/metabase/search/postgres/core_test.clj +++ b/test/metabase/search/postgres/core_test.clj @@ -2,7 +2,8 @@ (:require [clojure.string :as str] [clojure.test :refer [deftest is testing]] - [metabase.search :as search :refer [is-postgres?]] + [metabase.db :as mdb] + [metabase.search :as search] [metabase.search.postgres.core :as search.postgres] [metabase.search.postgres.index-test :refer [legacy-results]] [metabase.test :as mt] @@ -13,7 +14,7 @@ #_{:clj-kondo/ignore [:metabase/test-helpers-use-non-thread-safe-functions]} (defmacro with-setup [& body] - `(when (is-postgres?) + `(when (= :postgres (mdb/db-type)) ;; TODO add more extensive data to search (mt/dataset ~'test-data (search.postgres/init! true) @@ -59,21 +60,23 @@ (testing "consistent results with minimal implementations\n" (doseq [term example-terms] (testing term - (is (= (hybrid term) - (#'search.postgres/minimal term)))))))) + ;; there is no ranking, so order is non-deterministic + (is (= (set (hybrid term)) + (set (#'search.postgres/minimal term))))))))) (deftest minimal-with-perms-test (with-setup (testing "consistent results with minimal implementations\n" (doseq [term (take 1 example-terms)] (testing term - (is (= (hybrid term) - (#'search.postgres/minimal-with-perms - term - {:current-user-id (mt/user->id :crowberto) - :is-superuser? true - :archived? false - :current-user-perms #{"/"} - :model-ancestors? false - :models search/all-models - :search-string term})))))))) + ;; there is no ranking, so order is non-deterministic + (is (= (set (hybrid term)) + (set (#'search.postgres/minimal-with-perms + term + {:current-user-id (mt/user->id :crowberto) + :is-superuser? true + :archived? false + :current-user-perms #{"/"} + :model-ancestors? false + :models search/all-models + :search-string term}))))))))) diff --git a/test/metabase/search/postgres/index_test.clj b/test/metabase/search/postgres/index_test.clj index 654fe9ea047cf0ecbba76848465c1542c42406b5..0721354eb19b5c00d297ba05559164e4b406cb09 100644 --- a/test/metabase/search/postgres/index_test.clj +++ b/test/metabase/search/postgres/index_test.clj @@ -1,7 +1,7 @@ (ns metabase.search.postgres.index-test (:require [clojure.test :refer [deftest is testing]] - [metabase.search :refer [is-postgres?]] + [metabase.db :as mdb] [metabase.search.postgres.core :as search.postgres] [metabase.search.postgres.index :as search.index] [metabase.search.postgres.ingestion :as search.ingestion] @@ -11,7 +11,7 @@ (defn legacy-results "Use the source tables directly to search for records." [search-term & {:as opts}] - (t2/query (#'search.postgres/in-place-query (assoc opts :search-term search-term)))) + (t2/query (#'search.postgres/in-place-query (assoc opts :search-engine :search.engine/in-place :search-term search-term)))) (def legacy-models "Just the identity of the matches" @@ -27,7 +27,7 @@ (defmacro with-index "Ensure a clean, small index." [& body] - `(when (is-postgres?) + `(when (= :postgres (mdb/db-type)) (mt/dataset ~(symbol "test-data") (mt/with-temp [:model/Card {} {:name "Customer Satisfaction" :collection_id 1} :model/Card {} {:name "The Latest Revenue Projections" :collection_id 1} diff --git a/test/metabase/search/scoring_test.clj b/test/metabase/search/scoring_test.clj index 628f4de42d76c8204a3f5283a77ed452cb5175d8..e23129f0e9eceb75d839333127b414c57e75b78d 100644 --- a/test/metabase/search/scoring_test.clj +++ b/test/metabase/search/scoring_test.clj @@ -4,7 +4,7 @@ [java-time.api :as t] [metabase.search.config :as search.config] [metabase.search.filter-test :as search.filter-test] - [metabase.search.impl :as search.impl] + [metabase.search.legacy :as search.legacy] [metabase.search.scoring :as scoring] [metabase.test :as mt] [toucan2.core :as t2])) @@ -235,7 +235,7 @@ [search-ctx] (mt/with-current-user (mt/user->id :crowberto) (let [search-ctx (merge search.filter-test/default-search-ctx search-ctx)] - (t2/query (#'search.impl/full-search-query search-ctx))))) + (t2/query (search.legacy/full-search-query search-ctx))))) (deftest search-native-query-scoring-test (testing "Exclude native query matches in search scoring when the search should exclude native queries" diff --git a/test/metabase/task_test.clj b/test/metabase/task_test.clj index 8cb13fb5554dd014b554f9054b6a78cd60357fb2..917f3c5595e2f52fa31e3341d83dd0af28889067 100644 --- a/test/metabase/task_test.clj +++ b/test/metabase/task_test.clj @@ -63,6 +63,13 @@ {:cron-expression (.getCronExpression trigger) :misfire-instruction (.getMisfireInstruction trigger)}))) +(deftest job-exists?-test + (with-temp-scheduler-and-cleanup! + (is (false? (task/job-exists? (.getKey (job))))) + (task/schedule-task! (job) (trigger-1)) + (is (true? (task/job-exists? (.getKey (job))))) + (is (false? (task/job-exists? "not-found"))))) + (deftest schedule-job-test (testing "can we schedule a job?" (with-temp-scheduler-and-cleanup! diff --git a/test/metabase/test/data/interface.clj b/test/metabase/test/data/interface.clj index 30805fd67964410b2d74ad8cdd8febab32718048..99de1584098173327cd3366d900e685521399c9e 100644 --- a/test/metabase/test/data/interface.clj +++ b/test/metabase/test/data/interface.clj @@ -40,6 +40,7 @@ (defsetting database-source-dataset-name "The name of the test dataset this Database was created from, if any." + :encryption :no :visibility :internal :type :string :database-local :only) diff --git a/test/metabase/test/data/sql_jdbc/load_data.clj b/test/metabase/test/data/sql_jdbc/load_data.clj index 16aa50fdac97e399a0493152d1029272278a3b64..0b18203898eca52c17a384aef7d3fa52f2997c57 100644 --- a/test/metabase/test/data/sql_jdbc/load_data.clj +++ b/test/metabase/test/data/sql_jdbc/load_data.clj @@ -263,7 +263,9 @@ (spec/dbdef->spec driver :db dbdef) {:write? true} (fn [^java.sql.Connection conn] - (.setAutoCommit conn true) + (try (.setAutoCommit conn true) + (catch Throwable _ + (log/debugf "`.setAutoCommit` failed with engine `%s`" (name driver)))) (create-db-execute-db-ddl-statements! driver conn dbdef options) (create-db-load-data! driver conn dbdef)))) diff --git a/test/metabase/test/util_test.clj b/test/metabase/test/util_test.clj index 5b806fad98e195543f1126e3cec801b7b8299f02..5903e61f32445de523202909d5373c8bd2f95c11 100644 --- a/test/metabase/test/util_test.clj +++ b/test/metabase/test/util_test.clj @@ -35,7 +35,8 @@ "Another internal test setting" :visibility :internal :default ["A" "B" "C"] - :type :csv) + :type :csv + :encryption :no) (deftest with-temporary-setting-values-test (testing "`with-temporary-setting-values` should do its thing" diff --git a/test/metabase/upload_test.clj b/test/metabase/upload_test.clj index f5851a78611d52cfc4d1f5e68552c58950fef70c..674e02a73523e12a921e4b864e6c5575cf5a774c 100644 --- a/test/metabase/upload_test.clj +++ b/test/metabase/upload_test.clj @@ -519,19 +519,22 @@ "2\t || a |true |1.1\t |2022-01-01|2022-01-01T00:00:00" "\" 3\"|| b|false|\"$ 1,000.1\"|2022-02-01|2022-02-01T00:00:00"]}) +(defn- auto-pk-column? [] + (#'upload/auto-pk-column? driver/*driver* (mt/db))) + (defn- columns-with-auto-pk [columns] (cond-> columns - (driver.u/supports? driver/*driver* :upload-with-auto-pk (mt/db)) + (auto-pk-column?) (#'upload/columns-with-auto-pk))) (defn- header-with-auto-pk [header] (cond->> header - (driver.u/supports? driver/*driver* :upload-with-auto-pk (mt/db)) + (auto-pk-column?) (cons @#'upload/auto-pk-column-name))) (defn- rows-with-auto-pk [rows] (cond->> rows - (driver.u/supports? driver/*driver* :upload-with-auto-pk (mt/db)) + (auto-pk-column?) (map-indexed (fn [i row] (cons (inc i) row))))) (defn- column-position [table column-name] @@ -677,30 +680,31 @@ (deftest create-from-csv-offset-datetime-test (testing "Upload a CSV file with an offset datetime column" (mt/test-drivers (mt/normal-drivers-with-feature :uploads) - (with-mysql-local-infile-on-and-off - (with-redefs [driver/db-default-timezone (constantly "Z") - upload/current-database (constantly (mt/db))] - (let [transpose (fn [m] (apply mapv vector m)) - [csv-strs expected] (transpose [["2022-01-01T12:00:00-07" "2022-01-01T19:00:00Z"] - ["2022-01-01T12:00:00-07:00" "2022-01-01T19:00:00Z"] - ["2022-01-01T12:00:00-07:30" "2022-01-01T19:30:00Z"] - ["2022-01-01T12:00:00Z" "2022-01-01T12:00:00Z"] - ["2022-01-01T12:00:00-00:00" "2022-01-01T12:00:00Z"] - ["2022-01-01T12:00:00+07" "2022-01-01T05:00:00Z"] - ["2022-01-01T12:00:00+07:00" "2022-01-01T05:00:00Z"] - ["2022-01-01T12:00:00+07:30" "2022-01-01T04:30:00Z"] - ["2022-01-01T12:00:00+0730" "2022-01-01T04:30:00Z"]])] - (testing "Fields exists after sync" - (with-upload-table! - [table (create-from-csv-and-sync-with-defaults! - :file (csv-file-with (into ["offset_datetime"] csv-strs)))] - (testing "Check the offset datetime column the correct base_type" - (is (=? :type/DateTimeWithLocalTZ - (t2/select-one-fn :base_type Field :%lower.name "offset_datetime" :table_id (:id table))))) - (let [position (column-position table "offset_datetime") - values (map #(nth % position) (rows-for-table table))] - (is (= expected - values))))))))))) + (when (driver/upload-type->database-type driver/*driver* :metabase.upload/offset-datetime) + (with-mysql-local-infile-on-and-off + (with-redefs [driver/db-default-timezone (constantly "Z") + upload/current-database (constantly (mt/db))] + (let [transpose (fn [m] (apply mapv vector m)) + [csv-strs expected] (transpose [["2022-01-01T12:00:00-07" "2022-01-01T19:00:00Z"] + ["2022-01-01T12:00:00-07:00" "2022-01-01T19:00:00Z"] + ["2022-01-01T12:00:00-07:30" "2022-01-01T19:30:00Z"] + ["2022-01-01T12:00:00Z" "2022-01-01T12:00:00Z"] + ["2022-01-01T12:00:00-00:00" "2022-01-01T12:00:00Z"] + ["2022-01-01T12:00:00+07" "2022-01-01T05:00:00Z"] + ["2022-01-01T12:00:00+07:00" "2022-01-01T05:00:00Z"] + ["2022-01-01T12:00:00+07:30" "2022-01-01T04:30:00Z"] + ["2022-01-01T12:00:00+0730" "2022-01-01T04:30:00Z"]])] + (testing "Fields exists after sync" + (with-upload-table! + [table (create-from-csv-and-sync-with-defaults! + :file (csv-file-with (into ["offset_datetime"] csv-strs)))] + (testing "Check the offset datetime column the correct base_type" + (is (=? :type/DateTimeWithLocalTZ + (t2/select-one-fn :base_type Field :%lower.name "offset_datetime" :table_id (:id table))))) + (let [position (column-position table "offset_datetime") + values (map #(nth % position) (rows-for-table table))] + (is (= expected + values)))))))))))) (deftest create-from-csv-boolean-test (testing "Upload a CSV file" @@ -770,21 +774,21 @@ (with-mysql-local-infile-on-and-off (with-upload-table! [table (create-from-csv-and-sync-with-defaults! - :file (csv-file-with ["ID,åå‰,å¹´é½¢,è·æ¥,都市" - "1,ä½è—¤å¤ªéƒŽ,25,エンジニア,æ±äº¬" - "2,鈴木花å,30,デザイナー,大阪" - "3,ç”°ä¸ä¸€éƒŽ,28,マーケター,åå¤å±‹" - "4,山田次郎,35,プãƒã‚¸ã‚§ã‚¯ãƒˆãƒžãƒãƒ¼ã‚¸ãƒ£ãƒ¼,ç¦å²¡" - "5,ä¸æ‘美咲,32,データサイエンティスト,æœå¹Œ"]))] + :file (csv-file-with ["ID,åå‰,å¹´é½¢,è·æ¥,都市,Дтв ызд" + "1,ä½è—¤å¤ªéƒŽ,25,エンジニア,æ±äº¬,9" + "2,鈴木花å,30,デザイナー,大阪,8" + "3,ç”°ä¸ä¸€éƒŽ,28,マーケター,åå¤å±‹,7" + "4,山田次郎,35,プãƒã‚¸ã‚§ã‚¯ãƒˆãƒžãƒãƒ¼ã‚¸ãƒ£ãƒ¼,ç¦å²¡,6" + "5,ä¸æ‘美咲,32,データサイエンティスト,æœå¹Œ,5"]))] (testing "Check the data was uploaded into the table correctly" - (is (= (header-with-auto-pk ["ID" "åå‰" "å¹´é½¢" "è·æ¥" "都市"]) + (is (= (header-with-auto-pk ["ID" "åå‰" "å¹´é½¢" "è·æ¥" "都市" "Дтв Ызд"]) (column-display-names-for-table table))) (is (= (rows-with-auto-pk - [[1 "ä½è—¤å¤ªéƒŽ" 25 "エンジニア" "æ±äº¬"] - [2 "鈴木花å" 30 "デザイナー" "大阪"] - [3 "ç”°ä¸ä¸€éƒŽ" 28 "マーケター" "åå¤å±‹"] - [4 "山田次郎" 35 "プãƒã‚¸ã‚§ã‚¯ãƒˆãƒžãƒãƒ¼ã‚¸ãƒ£ãƒ¼" "ç¦å²¡"] - [5 "ä¸æ‘美咲" 32 "データサイエンティスト" "æœå¹Œ"]]) + [[1 "ä½è—¤å¤ªéƒŽ" 25 "エンジニア" "æ±äº¬" 9] + [2 "鈴木花å" 30 "デザイナー" "大阪" 8] + [3 "ç”°ä¸ä¸€éƒŽ" 28 "マーケター" "åå¤å±‹" 7] + [4 "山田次郎" 35 "プãƒã‚¸ã‚§ã‚¯ãƒˆãƒžãƒãƒ¼ã‚¸ãƒ£ãƒ¼" "ç¦å²¡" 6] + [5 "ä¸æ‘美咲" 32 "データサイエンティスト" "æœå¹Œ" 5]]) (rows-for-table table))))))))) (deftest create-from-csv-empty-header-test @@ -825,11 +829,9 @@ "$123,12.3, 100"]))] (testing "Table and Fields exist after sync" (testing "Check the data was uploaded into the table correctly" - (is (= #_[@#'upload/auto-pk-column-name "Cost $" "Cost %" "Cost #"] - ;; Blame it on humanization/name->human-readable-name - (header-with-auto-pk ["Cost" "Cost 2" "Cost 3"]) + (is (= (header-with-auto-pk ["Cost $" "Cost %" "Cost #"]) (column-display-names-for-table table))) - (is (= [@#'upload/auto-pk-column-name "cost__" "cost___2" "cost___3"] + (is (= (header-with-auto-pk ["cost__" "cost___2" "cost___3"]) (column-names-for-table table)))))))))) (deftest create-from-csv-bool-and-int-test @@ -865,13 +867,14 @@ "9000000000,Razor Crest,Din Djarin,Spear"]) :auxiliary-sync-steps :synchronous))] (testing "Check the data was uploaded into the table correctly" - (is (= [@#'upload/auto-pk-column-name "id" "ship" "name" "weapon"] + (is (= (header-with-auto-pk ["id" "ship" "name" "weapon"]) (column-names-for-table table))) (is (=? {:name #"(?i)id" :semantic_type :type/PK :base_type :type/BigInteger :database_is_auto_increment false} - (t2/select-one Field :database_position 1 :table_id (:id table)))))))))) + (let [pos (if (auto-pk-column?) 1 0)] + (t2/select-one Field :database_position pos :table_id (:id table))))))))))) (deftest create-from-csv-auto-pk-column-test (mt/test-drivers (mt/normal-drivers-with-feature :uploads :upload-with-auto-pk) @@ -1177,7 +1180,7 @@ "size_mb" 3.910064697265625E-5 "num_columns" 2 "num_rows" 2 - "generated_columns" 1 + "generated_columns" (if (auto-pk-column?) 1 0) "upload_seconds" pos?} :user-id (str (mt/user->id :rasta))} (last (snowplow-test/pop-event-data-and-user-id!))))) @@ -1213,7 +1216,7 @@ :model-id pos? :stats {:num-rows 2 :num-columns 2 - :generated-columns 1 + :generated-columns (if (auto-pk-column?) 1 0) :size-mb 3.910064697265625E-5 :upload-seconds pos?}}} (last-audit-event :upload-create))))))))) @@ -1330,7 +1333,7 @@ :or {table-name (mt/random-name) schema-name (sql.tx/session-schema driver/*driver*) col->upload-type (cond->> (ordered-map/ordered-map :name ::upload-types/varchar-255) - (#'upload/auto-pk-column? driver/*driver* (mt/db)) + (auto-pk-column?) (merge (ordered-map/ordered-map upload/auto-pk-column-keyword ::upload-types/auto-incrementing-int-pk))) rows [["Obi-Wan Kenobi"]]}}] @@ -1349,7 +1352,12 @@ {:primary-key [upload/auto-pk-column-keyword]} {})) (driver/insert-into! driver db-id schema+table-name insert-col-names rows) - (sync-upload-test-table! :database (mt/db) :table-name table-name :schema-name schema-name))) + (let [table (sync-upload-test-table! :database (mt/db) :table-name table-name :schema-name schema-name)] + ;; ensure we have the same display name for the auto-pk-column that a real upload would have + (t2/update! :model/Field + {:table_id (:id table), :name upload/auto-pk-column-name} + {:display_name upload/auto-pk-column-name}) + table))) (defn catch-ex-info* [f] (try @@ -1483,6 +1491,8 @@ ["_mb_row_id,id,extra 1, extra 2,name"] nil ["extra 1, extra 2"] + ;; TODO note that the order of the fields is reversed + ;; It would be better if they were alphabetical, or matched the order in the database / file. (trim-lines "The CSV file is missing columns that are in the table: - id - name @@ -1502,8 +1512,8 @@ - name There are new columns in the CSV file that are not in the table: - - _mb_row_id - - extra_2"))}] + - extra_2 + - _mb_row_id"))}] (with-upload-table! [table (create-upload-table! {:col->upload-type (ordered-map/ordered-map @@ -1606,53 +1616,54 @@ (deftest update-mb-row-id-csv-only-test (mt/test-drivers (mt/normal-drivers-with-feature :uploads) - (doseq [action (actions-to-test driver/*driver*)] - (testing (action-testing-str action) - (testing "If the table doesn't have _mb_row_id but the CSV does, ignore the CSV _mb_row_id but create the column anyway" - (with-upload-table! - [table (create-upload-table! {:col->upload-type (ordered-map/ordered-map - :name vchar-type) - :rows [["Obi-Wan Kenobi"]]})] - (let [csv-rows ["_MB-row ID,name" "1000,Luke Skywalker"] - file (csv-file-with csv-rows)] - (is (= {:row-count 1} - (update-csv! action {:file file, :table-id (:id table)}))) - ;; Only create auto-pk columns for drivers that supported uploads before auto-pk columns - ;; were introduced by metabase#36249. Otherwise we can assume that the table was created - ;; with an auto-pk column. - (if (driver/create-auto-pk-with-append-csv? driver/*driver*) - (do - (testing "Check a _mb_row_id column was created" - (is (= ["name" "_mb_row_id"] - (column-names-for-table table)))) - (testing "Check a _mb_row_id column was sync'd" - (is (=? {:semantic_type :type/PK - :base_type :type/BigInteger - :name "_mb_row_id" - :display_name "_mb_row_id"} - (t2/select-one :model/Field :table_id (:id table) :name upload/auto-pk-column-name)))) - (testing "Check the data was uploaded into the table, but the _mb_row_id column values were ignored" + (when (driver.u/supports? driver/*driver* :upload-with-auto-pk (mt/db)) + (doseq [action (actions-to-test driver/*driver*)] + (testing (action-testing-str action) + (testing "If the table doesn't have _mb_row_id but the CSV does, ignore the CSV _mb_row_id but create the column anyway" + (with-upload-table! + [table (create-upload-table! {:col->upload-type (ordered-map/ordered-map + :name vchar-type) + :rows [["Obi-Wan Kenobi"]]})] + (let [csv-rows ["_MB-row ID,name" "1000,Luke Skywalker"] + file (csv-file-with csv-rows)] + (is (= {:row-count 1} + (update-csv! action {:file file, :table-id (:id table)}))) + ;; Only create auto-pk columns for drivers that supported uploads before auto-pk columns + ;; were introduced by metabase#36249. Otherwise we can assume that the table was created + ;; with an auto-pk column. + (if (driver/create-auto-pk-with-append-csv? driver/*driver*) + (do + (testing "Check a _mb_row_id column was created" + (is (= ["name" "_mb_row_id"] + (column-names-for-table table)))) + (testing "Check a _mb_row_id column was sync'd" + (is (=? {:semantic_type :type/PK + :base_type :type/BigInteger + :name "_mb_row_id" + :display_name "_mb_row_id"} + (t2/select-one :model/Field :table_id (:id table) :name upload/auto-pk-column-name)))) + (testing "Check the data was uploaded into the table, but the _mb_row_id column values were ignored" + (case action + ::upload/append + (is (= [["Obi-Wan Kenobi" 1] + ["Luke Skywalker" 2]] + (rows-for-table table))) + ::upload/replace + (is (= [["Luke Skywalker" 1]] + (rows-for-table table)))))) + (do + (testing "Check a _mb_row_id column wasn't created" + (is (= ["name"] + (column-names-for-table table)))) (case action ::upload/append - (is (= [["Obi-Wan Kenobi" 1] - ["Luke Skywalker" 2]] + (is (= [["Obi-Wan Kenobi"] + ["Luke Skywalker"]] (rows-for-table table))) ::upload/replace - (is (= [["Luke Skywalker" 1]] + (is (= [["Luke Skywalker"]] (rows-for-table table)))))) - (do - (testing "Check a _mb_row_id column wasn't created" - (is (= ["name"] - (column-names-for-table table)))) - (case action - ::upload/append - (is (= [["Obi-Wan Kenobi"] - ["Luke Skywalker"]] - (rows-for-table table))) - ::upload/replace - (is (= [["Luke Skywalker"]] - (rows-for-table table)))))) - (io/delete-file file)))))))) + (io/delete-file file))))))))) (deftest update-no-mb-row-id-failure-test (mt/test-drivers (mt/normal-drivers-with-feature :uploads) @@ -1861,26 +1872,13 @@ (deftest update-mb-row-id-csv-and-table-test (mt/test-drivers (mt/normal-drivers-with-feature :uploads) - (doseq [action (actions-to-test driver/*driver*)] - (testing (action-testing-str action) - (testing "Append succeeds if the table has _mb_row_id and the CSV does too" - (with-upload-table! [table (create-upload-table!)] - (let [csv-rows ["_mb_row_id,name" "1000,Luke Skywalker"] - file (csv-file-with csv-rows (mt/random-name))] - (is (= {:row-count 1} - (update-csv! action {:file file, :table-id (:id table)}))) - (testing "Check the data was uploaded into the table, but the _mb_row_id was ignored" - (is (= (set (updated-contents action - [["Obi-Wan Kenobi"]] - [["Luke Skywalker"]])) - (set (rows-for-table table))))) - (io/delete-file file))) - - ;; TODO we can deduplicate a lot of code in this test - (testing "with duplicate normalized _mb_row_id columns in the CSV file" + (when (driver.u/supports? driver/*driver* :upload-with-auto-pk (mt/db)) + (doseq [action (actions-to-test driver/*driver*)] + (testing (action-testing-str action) + (testing "Append succeeds if the table has _mb_row_id and the CSV does too" (with-upload-table! [table (create-upload-table!)] - (let [csv-rows ["_mb_row_id,name,-MB-ROW-ID" "1000,Luke Skywalker,1001"] - file (csv-file-with csv-rows)] + (let [csv-rows ["_mb_row_id,name" "1000,Luke Skywalker"] + file (csv-file-with csv-rows (mt/random-name))] (is (= {:row-count 1} (update-csv! action {:file file, :table-id (:id table)}))) (testing "Check the data was uploaded into the table, but the _mb_row_id was ignored" @@ -1888,7 +1886,21 @@ [["Obi-Wan Kenobi"]] [["Luke Skywalker"]])) (set (rows-for-table table))))) - (io/delete-file file))))))))) + (io/delete-file file))) + + ;; TODO we can deduplicate a lot of code in this test + (testing "with duplicate normalized _mb_row_id columns in the CSV file" + (with-upload-table! [table (create-upload-table!)] + (let [csv-rows ["_mb_row_id,name,-MB-ROW-ID" "1000,Luke Skywalker,1001"] + file (csv-file-with csv-rows)] + (is (= {:row-count 1} + (update-csv! action {:file file, :table-id (:id table)}))) + (testing "Check the data was uploaded into the table, but the _mb_row_id was ignored" + (is (= (set (updated-contents action + [["Obi-Wan Kenobi"]] + [["Luke Skywalker"]])) + (set (rows-for-table table))))) + (io/delete-file file)))))))))) (deftest update-duplicate-header-csv-test (mt/test-drivers (mt/normal-drivers-with-feature :uploads) @@ -1961,8 +1973,8 @@ file (csv-file-with csv-rows)] (testing "The new row is inserted with the values correctly reordered" (is (= {:row-count 1} (update-csv! action {:file file, :table-id (:id table)}))) - (is (= ["name" "α"] - (rest (column-display-names-for-table table)))) + (is (= (header-with-auto-pk ["name" "α"]) + (column-display-names-for-table table))) (is (= (set (updated-contents action [["Obi-Wan Kenobi" nil]] [["Everything" "omega"]])) @@ -1978,7 +1990,7 @@ ;; for drivers that insert rows in chunks, we change the chunk size to 1 so that we can test that the ;; inserted rows are rolled back (binding [driver/*insert-chunk-rows* 1] - (doseq [auto-pk-column? (if (driver.u/supports? driver/*driver* :upload-with-auto-pk (mt/db)) + (doseq [auto-pk-column? (if (auto-pk-column?) [true false] [false])] (testing (str "\nFor a table that has " (if auto-pk-column? "an" " no") " automatically generated PK already") @@ -2140,6 +2152,15 @@ (update!))))) (io/delete-file file))))))))))) +(defn- round-floats + "Round all floats to have n digits of precision." + [digits-precision rows] + (let [factor (Math/pow 10 digits-precision)] + (mapv (partial mapv #(if (float? %) + (/ (Math/round (* factor %)) factor) + %)) + rows))) + (deftest update-promotion-multiple-columns-test (mt/test-drivers (disj (mt/normal-drivers-with-feature :uploads) :redshift) ; redshift doesn't support promotion (doseq [action (actions-to-test driver/*driver*)] @@ -2165,8 +2186,10 @@ (testing "\nAppend should succeed" (is (= {:row-count 1} (update!)))) - (is (= [[1 coerced coerced]] - (rows-for-table table)))))))))))))) + (is (= (rows-with-auto-pk [[coerced coerced]]) + ;; Clickhouse uses 32bit floats, so we must account for that loss in precision. + ;; In this case 2.1 => 2.0999999046325684 + (round-floats 6 (rows-for-table table))))))))))))))) (deftest create-from-csv-int-and-float-test (testing "Creation should handle a mix of int and float-or-int values in any order" @@ -2178,8 +2201,8 @@ "1, 1.0" "1.0, 1"]))] (testing "Check the data was uploaded into the table correctly" - (is (= [[1 1.0 1.0] - [2 1.0 1.0]] + (is (= (rows-with-auto-pk [[1.0 1.0] + [1.0 1.0]]) (rows-for-table table))))))))) (deftest create-from-csv-int-and-non-integral-float-test @@ -2192,9 +2215,11 @@ "1, 1.1" "1.1, 1"]))] (testing "Check the data was uploaded into the table correctly" - (is (= [[1 1.0 1.1] - [2 1.1 1.0]] - (rows-for-table table))))))))) + (is (= (rows-with-auto-pk [[1.0 1.1] + [1.1 1.0]]) + ;; Clickhouse uses 32 bit floats, + ;; so 1.1 is approximated by 1.10000002384185791015625 + (round-floats 7 (rows-for-table table)))))))))) (deftest update-from-csv-int-and-float-test (mt/test-drivers (mt/normal-drivers-with-feature :uploads) @@ -2280,10 +2305,10 @@ (map (partial #'upload/normalize-column-name driver/*driver*)))] (is (every? (set column-names) header-names)))) (testing "We preserve prefixes where_possible" - (is (= {"_mb_row_" 1 - "a_really" 1 + (is (= {"a_really" 1 "b_really" 2} - (frequencies (map #(subs % 0 8) column-names)))))))))))))) + (dissoc (frequencies (map #(subs % 0 8) column-names)) + "_mb_row_"))))))))))))) (deftest append-with-really-long-names-test (testing "Upload a CSV file with unique column names that get sanitized to the same string" @@ -2302,10 +2327,37 @@ (is (= {:row-count 1} (update-csv! ::upload/append {:file file, :table-id (:id table)}))) (testing "Check the data was appended into the table" - (is (= (map second (rows-with-auto-pk - [(csv/read-csv original-row) - (csv/read-csv appended-row)])) - (map rest (rows-for-table table))))) + (is (= (set + (rows-with-auto-pk + (concat + (csv/read-csv original-row) + (csv/read-csv appended-row)))) + (set (rows-for-table table))))) + (io/delete-file file)))))))) + +(deftest append-with-preserved-display-name-test + (testing "Upload a CSV file with unique column names that get sanitized to the same string\n" + (mt/test-drivers (mt/normal-drivers-with-feature :uploads) + (with-mysql-local-infile-on-and-off + (let [data ["a" 1] + bespoke-name "i put a lot of effort into this display name"] + (with-upload-table! + [table (create-from-csv-and-sync-with-defaults! + :file (csv-file-with data))] + (testing "Initially, we get the inferred name" + (is (= (header-with-auto-pk ["A"]) + (column-display-names-for-table table)))) + (testing "But we can configure it" + (t2/update! :model/Field {:name "a" :table_id (:id table)} + {:display_name bespoke-name}) + (is (= (header-with-auto-pk [bespoke-name]) + (column-display-names-for-table table)))) + (let [file (csv-file-with data (mt/random-name))] + (is (= {:row-count 1} + (update-csv! ::upload/append {:file file, :table-id (:id table)}))) + (testing "And our configuration is preserved when we append more data" + (is (= (header-with-auto-pk [bespoke-name]) + (column-display-names-for-table table)))) (io/delete-file file)))))))) (deftest append-with-really-long-names-that-duplicate-test @@ -2328,8 +2380,8 @@ :data {:status-code 422}} (catch-ex-info (update-csv! ::upload/append {:file file, :table-id (:id table)})))) (testing "Check the data was not uploaded into the table" - (is (= (map second (rows-with-auto-pk [(csv/read-csv original-row)])) - (map rest (rows-for-table table))))) + (is (= (rows-with-auto-pk (csv/read-csv original-row)) + (rows-for-table table)))) (io/delete-file file)))))))) (driver/register! ::short-column-test-driver) diff --git a/test/metabase/util/embed_test.clj b/test/metabase/util/embed_test.clj index cf182c01476b1d07030743be5622a9fb1a155b3c..61d7de2f06d7551e176f78a60a5fc88e3f44d51f 100644 --- a/test/metabase/util/embed_test.clj +++ b/test/metabase/util/embed_test.clj @@ -47,7 +47,7 @@ :status "fake" :features ["test" "fixture"] :trial false})] - (mt/with-temporary-setting-values [premium-embedding-token premium-features-test/random-fake-token] + (mt/with-temporary-setting-values [premium-embedding-token (premium-features-test/random-token)] (is (= (embed/show-static-embed-terms) false)) (embed/show-static-embed-terms! false) (is (= (embed/show-static-embed-terms) false))))) diff --git a/webpack.embedding-sdk.config.js b/webpack.embedding-sdk.config.js index 1ff7056f86e83363001e39cc83353a0a6d442f43..fcd7acc52446a6837154b98b8da8fa03a336e91b 100644 --- a/webpack.embedding-sdk.config.js +++ b/webpack.embedding-sdk.config.js @@ -149,7 +149,7 @@ module.exports = env => { }), new webpack.EnvironmentPlugin({ EMBEDDING_SDK_VERSION, - IS_EMBEDDING_SDK_BUILD: true, + IS_EMBEDDING_SDK: true, }), new ForkTsCheckerWebpackPlugin({ async: isDevMode,