Compare commits
57 Commits
python-sec
...
git-branch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb709452e | ||
|
|
ee69b59359 | ||
|
|
1c364b155f | ||
|
|
44c13c3252 | ||
|
|
11409b26b6 | ||
|
|
8e6256f8f6 | ||
|
|
0be9f7d6f4 | ||
|
|
875e49f9bd | ||
|
|
61f4c68f47 | ||
|
|
07d3620a90 | ||
|
|
5f4fc27062 | ||
|
|
5de2223b87 | ||
|
|
32d8aa9421 | ||
|
|
cb32d20726 | ||
|
|
ca103c9e1a | ||
|
|
0dcc5d61d7 | ||
|
|
bdc723344f | ||
|
|
24f1302818 | ||
|
|
bf922a4f10 | ||
|
|
d98001aefc | ||
|
|
6145d23b0c | ||
|
|
ba7ebaa23c | ||
|
|
7d914011d6 | ||
|
|
11164bbab2 | ||
|
|
8e3beb84bd | ||
|
|
d40e9b29bf | ||
|
|
7bf74da2e6 | ||
|
|
384ca628a6 | ||
|
|
d20795d4e7 | ||
|
|
f27c689f5d | ||
|
|
9d3c308f24 | ||
|
|
31010009f2 | ||
|
|
84cd1cce84 | ||
|
|
280cb7e148 | ||
|
|
df73ec116d | ||
|
|
5e86092937 | ||
|
|
aa9a55b270 | ||
|
|
acb1891c36 | ||
|
|
24ebf822c9 | ||
|
|
43490ce7df | ||
|
|
541737c7f4 | ||
|
|
a958631573 | ||
|
|
1f27377440 | ||
|
|
94dc557cdf | ||
|
|
52ee47a4d1 | ||
|
|
04087e572d | ||
|
|
9f8975416d | ||
|
|
edf1410b10 | ||
|
|
48e8a64b7a | ||
|
|
15b15b39b3 | ||
|
|
42ef031fab | ||
|
|
2a7dbdce28 | ||
|
|
e80b91fbd4 | ||
|
|
1d4ac086fe | ||
|
|
f9338127bb | ||
|
|
f5bd4814c3 | ||
|
|
c24c901a2f |
@@ -1838,8 +1838,10 @@
|
||||
:api #{metabase-enterprise.semantic-search.api
|
||||
metabase-enterprise.semantic-search.core
|
||||
metabase-enterprise.semantic-search.init}
|
||||
:uses #{analytics
|
||||
:uses #{activity-feed
|
||||
analytics
|
||||
api
|
||||
app-db
|
||||
config
|
||||
connection-pool
|
||||
models
|
||||
|
||||
2
.github/CODEOWNERS
vendored
@@ -1,7 +1,9 @@
|
||||
# Before changing this file, make sure you have read how we use it:
|
||||
# https://www.notion.so/metabase/Pull-Request-Code-Review-Guide-374f05df889748b69d67af753f9bc9b8?pvs=4#36550c04ceab40b5a8b8bee6264b090c
|
||||
|
||||
enterprise/frontend/src/embedding-sdk-package @metabase/embedding
|
||||
enterprise/frontend/src/embedding-sdk-bundle @metabase/embedding
|
||||
enterprise/frontend/src/embedding-sdk-shared @metabase/embedding
|
||||
frontend/src/metabase/embedding-sdk @metabase/embedding
|
||||
frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion @metabase/embedding
|
||||
frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard @metabase/embedding
|
||||
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
# - starts it
|
||||
# - runs its Cypress tests against the local metabase.jar and local @metabase/embedding-sdk-react package.
|
||||
# The job is not required, though if it's failing better to be sure that it's not caused by a breaking change.
|
||||
# For more details see `Sample Apps compatibility with Embedding SDK tests` in `enterprise/frontend/src/embedding-sdk-bundle/dev.md`
|
||||
# For more details see `Sample Apps compatibility with Embedding SDK tests` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
|
||||
e2e-sample-apps-tests:
|
||||
needs: [get-build-requirements, get-sample-apps-data, build]
|
||||
strategy:
|
||||
@@ -316,7 +316,7 @@ jobs:
|
||||
# - starts an app
|
||||
# - runs its Cypress tests against the e2e test metabase instance and local @metabase/embedding-sdk-react package.
|
||||
# The job is not required, though if it's failing better to be sure that it's not caused by a breaking change.
|
||||
# For more details see `Embedding SDK integration tests with Host Apps` in `enterprise/frontend/src/embedding-sdk-bundle/dev.md`
|
||||
# For more details see `Embedding SDK integration tests with Host Apps` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
|
||||
e2e-host-apps-tests:
|
||||
needs: [get-build-requirements, build]
|
||||
strategy:
|
||||
|
||||
4
.github/workflows/embedding-sdk.yml
vendored
@@ -137,7 +137,7 @@ jobs:
|
||||
if: ${{ steps.docs-generation.outcome == 'failure' }}
|
||||
run: exit 1
|
||||
|
||||
# See `Component e2e tests` in `enterprise/frontend/src/embedding-sdk-bundle/dev.md`
|
||||
# See `Component e2e tests` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
|
||||
sdk-e2e-tests:
|
||||
name: "SDK e2e tests"
|
||||
uses: ./.github/workflows/e2e-component-tests-embedding-sdk.yml
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
- embedding-sdk-docs-snippets-type-check
|
||||
- embedding-sdk-api-documentation-generation-validation
|
||||
- sdk-e2e-tests
|
||||
# - e2e-component-tests-embedding-sdk-backward-compatibility
|
||||
- e2e-component-tests-embedding-sdk-backward-compatibility
|
||||
if: always() && !cancelled()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
2
.github/workflows/release-embedding-sdk.yml
vendored
@@ -187,7 +187,7 @@ jobs:
|
||||
- name: Get current SDK version
|
||||
id: current-sdk-version
|
||||
run: |
|
||||
VERSION=$(jq -r .version ./enterprise/frontend/src/embedding-sdk-bundle/package.template.json)
|
||||
VERSION=$(jq -r .version ./enterprise/frontend/src/embedding-sdk-package/package.template.json)
|
||||
echo "sdk_current_version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get next SDK patch version
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -11,7 +11,9 @@ const { isEmbeddingSdkPackageInstalled, embeddingSdkPackageVersion } =
|
||||
resolveEmbeddingSdkPackage();
|
||||
|
||||
module.exports = {
|
||||
stories: ["../enterprise/frontend/src/embedding-sdk-bundle/**/*.stories.tsx"],
|
||||
stories: [
|
||||
"../enterprise/frontend/src/embedding-sdk-{package,bundle,shared}/**/*.stories.tsx",
|
||||
],
|
||||
staticDirs: ["../resources/frontend_client", "./msw-public"],
|
||||
addons: [
|
||||
"@storybook/addon-webpack5-compiler-babel",
|
||||
@@ -57,8 +59,8 @@ module.exports = {
|
||||
alias: {
|
||||
...appConfig.resolve.alias,
|
||||
...(isEmbeddingSdkPackageInstalled && {
|
||||
// $ means that only exact "embedding-sdk" imports will be rerouted, all nested embedding-sdk/* will still be resolved locally
|
||||
"embedding-sdk-bundle$": require.resolve(
|
||||
// $ means that only exact "embedding-sdk-package" imports will be rerouted, all nested embedding-sdk-package/* will still be resolved locally
|
||||
"embedding-sdk-package$": require.resolve(
|
||||
"@metabase/embedding-sdk-react",
|
||||
),
|
||||
}),
|
||||
@@ -91,7 +93,7 @@ function resolveEmbeddingSdkPackage() {
|
||||
} catch (err) {
|
||||
const sdkPackageTemplateJson = fs.readFileSync(
|
||||
path.resolve(
|
||||
"./enterprise/frontend/src/embedding-sdk-bundle/package.template.json",
|
||||
"./enterprise/frontend/src/embedding-sdk-package/package.template.json",
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -19,13 +19,15 @@ const REPLACES_MAP = {
|
||||
"metabase-lib": "frontend/src/metabase-lib",
|
||||
"metabase-types": "frontend/src/metabase-types",
|
||||
metabase: "frontend/src/metabase",
|
||||
"embedding-sdk-package": "enterprise/frontend/src/embedding-sdk-package",
|
||||
"embedding-sdk-bundle": "enterprise/frontend/src/embedding-sdk-bundle",
|
||||
"embedding-sdk-shared": "enterprise/frontend/src/embedding-sdk-shared",
|
||||
cljs: "target/cljs_release",
|
||||
};
|
||||
|
||||
const API_EXTRACTOR_CONFIG_PATH = path.join(
|
||||
__dirname,
|
||||
"../../enterprise/frontend/src/embedding-sdk-bundle/embedding-sdk-api-extractor.json",
|
||||
"../../enterprise/frontend/src/embedding-sdk-package/embedding-sdk-api-extractor.json",
|
||||
);
|
||||
|
||||
const getLogger = (prefix) => {
|
||||
@@ -131,7 +133,7 @@ const generateDtsRollup = () => {
|
||||
const dtsRollupEntryPointPath = path.resolve(
|
||||
path.join(
|
||||
__dirname,
|
||||
"../../resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-bundle/index.d.ts",
|
||||
"../../resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/index.d.ts",
|
||||
),
|
||||
);
|
||||
const dtsRollupEntryPointPathExist = fs.existsSync(dtsRollupEntryPointPath);
|
||||
|
||||
@@ -36,7 +36,7 @@ function generateSdkPackage() {
|
||||
|
||||
const sdkPackageTemplateJson = fs.readFileSync(
|
||||
path.resolve(
|
||||
"./enterprise/frontend/src/embedding-sdk-bundle/package.template.json",
|
||||
"./enterprise/frontend/src/embedding-sdk-package/package.template.json",
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
@@ -87,7 +87,7 @@ if (!fs.existsSync(SDK_DIST_DIR)) {
|
||||
|
||||
generateSdkPackage();
|
||||
copyFileToOutput("LICENSE.txt");
|
||||
copyFileToOutput("frontend/src/embedding-sdk-bundle/README.md", "README.md");
|
||||
copyFileToOutput("frontend/src/embedding-sdk-package/README.md", "README.md");
|
||||
copyFileToOutput(
|
||||
"frontend/src/embedding-sdk-bundle/CHANGELOG.md",
|
||||
"CHANGELOG.md",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/bin/bash
|
||||
function update_readme() {
|
||||
sed -i -E 's|(embedding-sdk-)[0-9.]+|\1'"$1"'|' enterprise/frontend/src/embedding-sdk-bundle/README.md
|
||||
sed -i -E 's|(http://downloads.metabase.com/sdk/v)[0-9.]+|\1'"$1"'|' enterprise/frontend/src/embedding-sdk-bundle/README.md
|
||||
sed -i -E 's|(embedding-sdk-)[0-9.]+|\1'"$1"'|' enterprise/frontend/src/embedding-sdk-package/README.md
|
||||
sed -i -E 's|(http://downloads.metabase.com/sdk/v)[0-9.]+|\1'"$1"'|' enterprise/frontend/src/embedding-sdk-package/README.md
|
||||
}
|
||||
|
||||
function update_package_json_template() {
|
||||
sed -i -E 's/("version": ").*"/\1'"$1"'"/' enterprise/frontend/src/embedding-sdk-bundle/package.template.json
|
||||
sed -i -E 's/("version": ").*"/\1'"$1"'"/' enterprise/frontend/src/embedding-sdk-package/package.template.json
|
||||
}
|
||||
|
||||
$1 $2
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
#! /usr/bin/env bash
|
||||
|
||||
function prepare_files() {
|
||||
tsx ./enterprise/frontend/src/embedding-sdk-bundle/bin/generate-cli-snippets-for-testing.ts
|
||||
tsx ./enterprise/frontend/src/embedding-sdk-package/bin/generate-cli-snippets-for-testing.ts
|
||||
}
|
||||
|
||||
function install_dependencies() {
|
||||
yarn --cwd ./enterprise/frontend/src/embedding-sdk-bundle/cli-snippets-tmp/express-server install --frozen-lockfile
|
||||
yarn --cwd ./enterprise/frontend/src/embedding-sdk-package/cli-snippets-tmp/express-server install --frozen-lockfile
|
||||
}
|
||||
|
||||
function type_check() {
|
||||
tsc --project ./enterprise/frontend/src/embedding-sdk-bundle/tsconfig.cli-snippets.json
|
||||
tsc --project ./enterprise/frontend/src/embedding-sdk-package/tsconfig.cli-snippets.json
|
||||
}
|
||||
|
||||
function cleanup_files() {
|
||||
rm -rf ./enterprise/frontend/src/embedding-sdk-bundle/cli-snippets-tmp
|
||||
rm -rf ./enterprise/frontend/src/embedding-sdk-package/cli-snippets-tmp
|
||||
}
|
||||
|
||||
cleanup_files
|
||||
|
||||
186
dev/src/dev/branching.clj
Normal file
@@ -0,0 +1,186 @@
|
||||
(ns dev.branching
|
||||
(:require
|
||||
[metabase.test.initialize.test-users :as tu]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn create-sample-branch-setup!
|
||||
"Creates a development branch setup with cards, copies, and mappings.
|
||||
This is a development/testing version that simulates branching functionality
|
||||
without requiring the enterprise branching models.
|
||||
|
||||
Returns a map with:
|
||||
- :branch - simulated branch data
|
||||
- :original-cards - the original card instances
|
||||
- :copied-cards - the copied card instances
|
||||
- :mappings - simulated branch model mappings
|
||||
- :dashboard - dashboard referencing original cards"
|
||||
[& {:keys [branch-name card-count copy-ratio collection-id]
|
||||
:or {branch-name "dev-branch"
|
||||
card-count 5
|
||||
copy-ratio 0.6
|
||||
collection-id nil}}]
|
||||
(tu/init!)
|
||||
(let [user (t2/select-one :model/User :email "rasta@metabase.com")
|
||||
branch (t2/insert-returning-instance! :model/Branch {:name branch-name})
|
||||
|
||||
;; Determine collection
|
||||
target-collection (if collection-id
|
||||
(t2/select-one :model/Collection :id collection-id)
|
||||
(t2/insert-returning-instance! :model/Collection
|
||||
{:name (str "Branch Collection - " branch-name)}))
|
||||
|
||||
;; Create original cards
|
||||
original-cards (vec
|
||||
(for [i (range card-count)]
|
||||
(t2/insert-returning-instance! :model/Card
|
||||
{:name (str "Original Card " (inc i))
|
||||
:collection_id (:id target-collection)
|
||||
:dataset_query {:database 1
|
||||
:type :native
|
||||
:native {:query (str "SELECT " (inc i) " as result")}}
|
||||
:display "table"
|
||||
:visualization_settings {}
|
||||
:creator_id (:id user)})))
|
||||
|
||||
;; Determine which cards to copy based on ratio
|
||||
cards-to-copy-count (int (* card-count copy-ratio))
|
||||
cards-to-copy (take cards-to-copy-count original-cards)
|
||||
|
||||
;; Create copied cards
|
||||
copied-cards (vec
|
||||
(for [original-card cards-to-copy]
|
||||
(t2/insert-returning-instance! :model/Card
|
||||
{:name (str "Branched " (:name original-card))
|
||||
:collection_id (:id target-collection)
|
||||
:dataset_query (update (:dataset_query original-card)
|
||||
:native
|
||||
assoc :query "SELECT 420 as result -- BRANCHED VERSION")
|
||||
:display (:display original-card)
|
||||
:visualization_settings (:visualization_settings original-card)
|
||||
:creator_id (:id user)})))
|
||||
|
||||
;; Simulate branch model mappings (as data, not DB records)
|
||||
mappings (vec
|
||||
(for [[original copied] (map vector cards-to-copy copied-cards)]
|
||||
(t2/insert-returning-instance! :model/BranchModelMapping
|
||||
{:branch_id (:id branch)
|
||||
:model_type "report_card"
|
||||
:original_id (:id original)
|
||||
:branched_model_id (:id copied)})))
|
||||
|
||||
;; Create dashboard referencing original cards
|
||||
dashboard (t2/insert-returning-instance! :model/Dashboard
|
||||
{:name (str "Dashboard for " branch-name)
|
||||
:collection_id (:id target-collection)
|
||||
:creator_id (:id user)})
|
||||
|
||||
;; Create dashboard cards linking to original cards
|
||||
dashcards (vec
|
||||
(for [[idx original-card] (map-indexed vector original-cards)]
|
||||
(t2/insert-returning-instance! :model/DashboardCard
|
||||
{:dashboard_id (:id dashboard)
|
||||
:card_id (:id original-card)
|
||||
:row (* idx 2)
|
||||
:col 0
|
||||
:size_x 4
|
||||
:size_y 2})))]
|
||||
|
||||
{:branch branch
|
||||
:collection target-collection
|
||||
:original-cards original-cards
|
||||
:copied-cards copied-cards
|
||||
:mappings mappings
|
||||
:dashboard dashboard
|
||||
:dashcards dashcards}))
|
||||
|
||||
(defn resolve-branched-id
|
||||
"Simulated version of resolve-branched-id that uses in-memory mappings."
|
||||
[mappings model-type original-id branch-id]
|
||||
(or (->> mappings
|
||||
(filter #(and (= (:model_type %) (name model-type))
|
||||
(= (:original_id %) original-id)
|
||||
(= (:branch_id %) branch-id)))
|
||||
first
|
||||
:branched_model_id)
|
||||
original-id))
|
||||
|
||||
(defn print-branch-summary
|
||||
"Prints a summary of the created branch setup."
|
||||
[{:keys [branch collection original-cards copied-cards mappings dashboard]}]
|
||||
(println "=== Branch Setup Summary ===")
|
||||
(println (format "Branch: %s (ID: %s, Slug: %s)"
|
||||
(:name branch) (:id branch) (:slug branch)))
|
||||
(println (format "Collection: %s (ID: %s)"
|
||||
(:name collection) (:id collection)))
|
||||
(println (format "Original Cards: %d" (count original-cards)))
|
||||
(println (format "Copied Cards: %d" (count copied-cards)))
|
||||
(println (format "Mappings: %d" (count mappings)))
|
||||
(println (format "Dashboard: %s (ID: %s)"
|
||||
(:name dashboard) (:id dashboard)))
|
||||
(println "\n=== Card Mappings ===")
|
||||
(doseq [{:keys [original_id branched_model_id]} mappings]
|
||||
(let [original-name (:name (t2/select-one :model/Card :id original_id))
|
||||
branched-name (:name (t2/select-one :model/Card :id branched_model_id))]
|
||||
(println (format "%s -> %s" original-name branched-name))))
|
||||
|
||||
(println "\n=== Example Usage ===")
|
||||
(println "To resolve branched IDs:")
|
||||
(doseq [card original-cards]
|
||||
(let [resolved-id (resolve-branched-id mappings :card (:id card) (:id branch))]
|
||||
(if (not= resolved-id (:id card))
|
||||
(println (format "Card %d resolves to branched card %d" (:id card) resolved-id))
|
||||
(println (format "Card %d has no branch mapping, uses original" (:id card)))))))
|
||||
|
||||
(defn cleanup-branch-setup!
|
||||
"Cleans up a branch setup by deleting all created entities."
|
||||
[{:keys [branch collection original-cards copied-cards mappings dashboard dashcards]}]
|
||||
(println "Cleaning up branch setup...")
|
||||
|
||||
;; Delete in reverse dependency order
|
||||
(when dashcards
|
||||
(doseq [dashcard dashcards]
|
||||
(t2/delete! :model/DashboardCard :id (:id dashcard))))
|
||||
|
||||
(when dashboard
|
||||
(t2/delete! :model/Dashboard :id (:id dashboard)))
|
||||
|
||||
;; Note: mappings are just data, no DB cleanup needed
|
||||
|
||||
(when copied-cards
|
||||
(doseq [card copied-cards]
|
||||
(t2/delete! :model/Card :id (:id card))))
|
||||
|
||||
(when original-cards
|
||||
(doseq [card original-cards]
|
||||
(t2/delete! :model/Card :id (:id card))))
|
||||
|
||||
(when collection
|
||||
(t2/delete! :model/Collection :id (:id collection)))
|
||||
|
||||
;; Note: branch is simulated data, no DB cleanup needed
|
||||
|
||||
(println "Cleanup complete."))
|
||||
|
||||
(comment
|
||||
;; Example usage:
|
||||
|
||||
;; Create a branch setup
|
||||
(def setup (create-sample-branch-setup!))
|
||||
|
||||
;; Print summary
|
||||
(print-branch-summary setup)
|
||||
|
||||
;; Test resolving branched IDs
|
||||
(resolve-branched-id (:mappings setup) :card
|
||||
(-> setup :original-cards first :id)
|
||||
(-> setup :branch :id))
|
||||
|
||||
;; Create with custom options
|
||||
(def custom-setup (create-sample-branch-setup!
|
||||
:branch-name "feature-xyz"
|
||||
:card-count 8
|
||||
:copy-ratio 0.5))
|
||||
|
||||
;; Clean up when done
|
||||
(cleanup-branch-setup! setup)
|
||||
(cleanup-branch-setup! custom-setup))
|
||||
195
docs/api.html
@@ -2199,15 +2199,19 @@ redirect_from:
|
||||
"required" : [ "moved" ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.!=" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.*" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.+" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.-" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema..parameter.target" : {
|
||||
@@ -2223,21 +2227,27 @@ redirect_from:
|
||||
} ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema./" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.<" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.<=" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.=" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.>" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.>=" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.Addable" : {
|
||||
@@ -2482,7 +2492,7 @@ redirect_from:
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.case"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.case:if"
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.if"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.offset"
|
||||
}, {
|
||||
@@ -2703,7 +2713,7 @@ redirect_from:
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.case"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.case:if"
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.if"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.datetime-diff"
|
||||
}, {
|
||||
@@ -2960,7 +2970,7 @@ redirect_from:
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.case"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.case:if"
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.if"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.host"
|
||||
}, {
|
||||
@@ -3213,13 +3223,13 @@ redirect_from:
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.case"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.case:if"
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.if"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.median"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.percentile"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.ag:var"
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.var"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.legacy-mbql.schema.cum-count"
|
||||
}, {
|
||||
@@ -3255,43 +3265,48 @@ redirect_from:
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.parameter.widget-type"
|
||||
},
|
||||
"metabase.legacy-mbql.schema.abs" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.absolute-datetime" : {
|
||||
"oneOf" : [ { }, {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
}, {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
} ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.ag:var" : {
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.aggregation" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.aggregation-options" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.and" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.asc" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.avg" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.between" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.case" : {
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.case:if" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.ceil" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.check-keys-for-query-type" : {
|
||||
@@ -3301,91 +3316,118 @@ redirect_from:
|
||||
"description" : "`:source-metadata` is added to queries when `card__id` source queries are resolved. It contains info about the\n columns in the source query.\n\n Where this is added was changed in Metabase 0.33.0 -- previously, when `card__id` source queries were resolved, the\n middleware would add `:source-metadata` to the top-level; to support joins against source queries, this has been\n changed so it is always added at the same level the resolved `:source-query` is added.\n\n This should automatically be fixed by `normalize`; if we encounter it, it means some middleware is not functioning\n properly."
|
||||
},
|
||||
"metabase.legacy-mbql.schema.coalesce" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.concat" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.contains" : {
|
||||
"anyOf" : [ {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
}, {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
} ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.convert-timezone" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.count" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.count-where" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.cum-count" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.cum-sum" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.date" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.datetime" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.datetime-add" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.datetime-diff" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.datetime-subtract" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.day-name" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.desc" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.dimension" : {
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.distinct" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.distinct-where" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.does-not-contain" : {
|
||||
"anyOf" : [ {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
}, {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
} ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.domain" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.during" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.ends-with" : {
|
||||
"anyOf" : [ {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
}, {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
} ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.exp" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.expression" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.field" : {
|
||||
"allOf" : [ {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
}, {
|
||||
"description" : "Fields using names rather than integer IDs are required to specify `:base-type`.",
|
||||
@@ -3400,60 +3442,82 @@ redirect_from:
|
||||
} ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.float" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.floor" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-day" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-day-of-week" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-hour" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-minute" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-month" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-quarter" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-second" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-week" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.get-year" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.helpers.distinct" : {
|
||||
"description" : "values must be distinct"
|
||||
},
|
||||
"metabase.legacy-mbql.schema.host" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.if" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.in" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.inside" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.integer" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.interval" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.is-empty" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.is-null" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.legacy-column-metadata" : {
|
||||
@@ -3500,148 +3564,199 @@ redirect_from:
|
||||
"required" : [ "base_type", "display_name", "name" ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.length" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.log" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.lower" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.ltrim" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.max" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.median" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.metric" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.min" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.month-name" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.no-binning-options-at-top-level" : { },
|
||||
"metabase.legacy-mbql.schema.not" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.not-empty" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.not-in" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.not-null" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.now" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.offset" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.or" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.path" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.percentile" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.power" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.quarter-name" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.regex-match-first" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.relative-datetime" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.relative-time-interval" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.replace" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.require-base-type-for-field-name" : { },
|
||||
"metabase.legacy-mbql.schema.round" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.rtrim" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.segment" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.share" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.split-part" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.sqrt" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.starts-with" : {
|
||||
"anyOf" : [ {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
}, {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
} ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.stddev" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.subdomain" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.substring" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.sum" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.sum-where" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.template-tag" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.temporal-extract" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.text" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.time" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.time-interval" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.today" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.trim" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.upper" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.validate-temporal-unit" : { },
|
||||
"metabase.legacy-mbql.schema.value" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.var" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.legacy-mbql.schema.variable" : {
|
||||
"description" : "schema for a valid legacy MBQL :tag clause",
|
||||
"allOf" : [ ]
|
||||
},
|
||||
"metabase.lib.schema.actions.row" : {
|
||||
@@ -5422,40 +5537,34 @@ redirect_from:
|
||||
"metabase.warehouses.api.available-settings" : {
|
||||
"type" : "object",
|
||||
"additionalProperties" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"enabled" : {
|
||||
"const" : true
|
||||
}
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"enabled" : {
|
||||
"type" : "boolean"
|
||||
},
|
||||
"required" : [ "enabled" ]
|
||||
}, {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"enabled" : {
|
||||
"const" : false
|
||||
},
|
||||
"reasons" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"key" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"message" : {
|
||||
"anyOf" : [ {
|
||||
"type" : "string"
|
||||
}, { } ]
|
||||
}
|
||||
"reasons" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"key" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"required" : [ "key", "message" ]
|
||||
}
|
||||
"message" : {
|
||||
"anyOf" : [ {
|
||||
"type" : "string"
|
||||
}, { } ]
|
||||
},
|
||||
"type" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "error", "warning" ]
|
||||
}
|
||||
},
|
||||
"required" : [ "key", "type", "message" ]
|
||||
}
|
||||
},
|
||||
"required" : [ "enabled", "reasons" ]
|
||||
} ]
|
||||
}
|
||||
},
|
||||
"required" : [ "enabled" ]
|
||||
}
|
||||
},
|
||||
"metabot.reaction.redirect" : {
|
||||
@@ -11395,7 +11504,7 @@ redirect_from:
|
||||
} ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
"description" : "map where {:settings -> <map from <keyword> to <one of <true = map where {:enabled -> <must equal true>} | false = map where {:enabled -> <must equal false>, :reasons -> <sequence of map where {:key -> <keyword>, :message -> <string, or function>}>}> dispatched by :enabled>>}",
|
||||
"description" : "map where {:settings -> <map from <keyword> to <map where {:enabled -> <boolean>, :reasons (optional) -> <sequence of map where {:key -> <keyword>, :type -> <enum of :error, :warning>, :message -> <string, or function>}>}>>}",
|
||||
"content" : {
|
||||
"application/json" : {
|
||||
"schema" : {
|
||||
|
||||
@@ -190,7 +190,7 @@ Cypress._.times(N, () => {
|
||||
|
||||
### Embedding SDK tests
|
||||
|
||||
See [sdk docs about e2e](https://github.com/metabase/metabase/blob/master/enterprise/frontend/src/embedding-sdk-bundle/dev.md)
|
||||
See [sdk docs about e2e](https://github.com/metabase/metabase/blob/master/enterprise/frontend/src/embedding-sdk-package/dev.md)
|
||||
|
||||
## DB Snapshots
|
||||
|
||||
|
||||
@@ -22,11 +22,9 @@ To grab the latest version of the SDK that works with Metabase nightly builds, u
|
||||
|
||||
Version 52 is the minimum version supported for the Embedded analytics SDK.
|
||||
|
||||
## Instances on Metabase Cloud will be pinned to a specific version
|
||||
## You can pin instances to a version on Metabase Cloud
|
||||
|
||||
By default, if you're running on Metabase Cloud and using the Embedded analytics SDK, we'll pin your version to avoid breaking changes.
|
||||
|
||||
Normally, Metabase Cloud upgrades your Metabase as new versions roll out so you don't have to deal with upgrades. But if you're using the SDK with Metabase Cloud, you'll want to upgrade manually to make sure your embeds don't break when you upgrade both your Metabase and your SDK version.
|
||||
Metabase Cloud upgrades your instance automatically as new versions roll out. But if you're using the SDK with Metabase Cloud, you may want to pin your version so you can upgrade manually. This way you can make sure that your embeds don't break when you upgrade both your Metabase and your SDK version.
|
||||
|
||||
### Manually pinning your instance version on Metabase Cloud
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ If you have multiple parameters, separate them with an ampersand (`&`):
|
||||
category=Gadget&state=Vermont#theme=night&locale=ko
|
||||
```
|
||||
|
||||
The `locale` param only changes the language for Metabase UI elements. The _content_'s language is defined by whomever created the item. So for example Metabase wouldn't translate the name of a question, but Metabase _would_ translate the UI text "Export to PDF".
|
||||
The `locale` parameter changes the language for Metabase UI elements, like the label of "Export to PDF" button. To change the _content_'s language in a static embed (like names of questions and dashboards), you'll need to [upload a translation dictionary](./translations.md).
|
||||
|
||||
## Transparent backgrounds for embeds
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ For now, translations are only available for [static embeds](./static-embedding.
|
||||
|
||||
You can upload a translation dictionary to translate strings both in Metabase content (like dashboard titles) and in the data itself (like column names and values).
|
||||
|
||||
## Add a translation dictionary
|
||||
|
||||
The dictionary must be a CSV with these columns:
|
||||
|
||||
- **Language** with the locale code
|
||||
- **Language** with the locale code
|
||||
- **String** with the string to be translated
|
||||
- **Translation**
|
||||
|
||||
@@ -23,6 +25,16 @@ Uploading a new dictionary will replace the existing dictionary.
|
||||
|
||||
To remove a translation dictionary, upload a blank dictionary.
|
||||
|
||||
## Translate content in static embeds
|
||||
|
||||
To translate content in a static embed using the uploaded dictionary, add the [`locale` parameter](./static-embedding-parameters.md#setting-the-language-for-a-static-embed) to the embed URL:
|
||||
|
||||
```
|
||||
https://metabase.example.com/public/dashboard/7b6e347b-6928-4aff-a56f-6cfa5b718c6b?category=&city=&state=#locale=ko
|
||||
```
|
||||
|
||||
Metabase UI elements (like button labels) will be translated automatically - you don't need to add translations for them to your dictionary.
|
||||
|
||||
## Example translation dictionary
|
||||
|
||||
Metabase uses these dictionaries to translate user-generated content, like dashboard names in [static embeds](./static-embedding.md).
|
||||
@@ -34,8 +46,29 @@ Metabase uses these dictionaries to translate user-generated content, like dashb
|
||||
| pt-BR | Another tab | Outra aba |
|
||||
| pt-BR | Title | Título |
|
||||
| pt-BR | Vendor | Vendedor |
|
||||
| IT | Examples | Esempi |
|
||||
|
||||
[See a list of supported locales](../configuring-metabase/localization.md#supported-languages)
|
||||
Prefer hyphens in your `pt-BR` in your translation dictionary. Underscores are also acceptable (if you download the dictionary _after_ uploading it, Metabase will transform `pt-BR` to `pt_BR`), but since the `locale` parameter in static embedding only accepts locales with hyphens (like `pt-BR`), we recommend using hyphens for consistency.
|
||||
|
||||
[See a list of supported locales](../configuring-metabase/localization.md#supported-languages).
|
||||
|
||||
## Use full phrases in translation dictionaries
|
||||
|
||||
Currently, Metabase doesn't tokenize strings for translations, so you should include exact phrases in your translation dictionary.
|
||||
|
||||
For example, if you have a dashboard called "Monthly Sales", it's not sufficient to have translations of "Monthly" and "Sales" - you also need to include "Monthly Sales" as a full string.
|
||||
|
||||
Exact translations also apply to strings that use punctuation and special characters. For example, if you have a question title "How many Widgets did we sell this week?", you must include that exact string (with "?") into the translation dictionary. Metabase would treat "How many Widgets did we sell this week" as a different string. Essentially, the strings are keys in a table Metabase looks up, so they must match exactly.
|
||||
|
||||
## Include markdown formatting in translation dictionaries
|
||||
|
||||
If the strings you want to translate include markdown formatting, you'll need to include the formatting in the dictionary. For example:
|
||||
|
||||
| Language | String | Translation |
|
||||
| -------- | -------------- | -------------- |
|
||||
| pt-BR | `**Examples**` | `**Exemplos**` |
|
||||
| pt-BR | `_Examples_` | `_Exemplos_` |
|
||||
| pt-BR | `## Examples` | `## Exemplos` |
|
||||
|
||||
## Further reading
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"**/enterprise/frontend/src/embedding-sdk-bundle",
|
||||
"**/enterprise/frontend/src/embedding-sdk-bundle/*"
|
||||
"**/enterprise/frontend/src/embedding-sdk-package",
|
||||
"**/enterprise/frontend/src/embedding-sdk-package/*"
|
||||
],
|
||||
"message": "Please use SDK package name - '@metabase/embedding-sdk-react'"
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@ export const SAMPLE_DB_SCHEMA_ID = "1:PUBLIC";
|
||||
// so any changes that affect the order of the tables returned by that function will change the ids of these tables. A common case is when adding a new key to table metadata
|
||||
export const SAMPLE_DB_TABLES = {
|
||||
// old tables
|
||||
STATIC_PRODUCTS_ID: 3,
|
||||
STATIC_ORDERS_ID: 4,
|
||||
STATIC_PEOPLE_ID: 6,
|
||||
STATIC_REVIEWS_ID: 8,
|
||||
STATIC_PRODUCTS_ID: 7,
|
||||
STATIC_ORDERS_ID: 1,
|
||||
STATIC_PEOPLE_ID: 2,
|
||||
STATIC_REVIEWS_ID: 5,
|
||||
// new tables
|
||||
STATIC_ACCOUNTS_ID: 5,
|
||||
STATIC_ANALYTIC_EVENTS_ID: 2,
|
||||
STATIC_FEEDBACK_ID: 7,
|
||||
STATIC_INVOICES_ID: 1,
|
||||
STATIC_ACCOUNTS_ID: 8,
|
||||
STATIC_ANALYTIC_EVENTS_ID: 4,
|
||||
STATIC_FEEDBACK_ID: 3,
|
||||
STATIC_INVOICES_ID: 6,
|
||||
};
|
||||
|
||||
// All users and admin groups are the defaults that come with Metabase.
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
InteractiveQuestion,
|
||||
type MetabaseDashboard,
|
||||
MetabaseProvider,
|
||||
useApplicationName,
|
||||
useAvailableFonts,
|
||||
useCreateDashboardApi,
|
||||
useCurrentUser,
|
||||
useMetabaseAuthStatus,
|
||||
} from "@metabase/embedding-sdk-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import {
|
||||
DEFAULT_SDK_AUTH_PROVIDER_CONFIG,
|
||||
mountSdk,
|
||||
} from "e2e/support/helpers/embedding-sdk-component-testing";
|
||||
import {
|
||||
mockAuthProviderAndJwtSignIn,
|
||||
signInAsAdminAndEnableEmbeddingSdk,
|
||||
} from "e2e/support/helpers/embedding-sdk-testing";
|
||||
|
||||
describe("scenarios > embedding-sdk > sdk-bundle public hooks", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
|
||||
"dashcardQuery",
|
||||
);
|
||||
|
||||
signInAsAdminAndEnableEmbeddingSdk();
|
||||
|
||||
cy.signOut();
|
||||
|
||||
mockAuthProviderAndJwtSignIn();
|
||||
});
|
||||
|
||||
describe("useMetabaseAuthStatus", () => {
|
||||
const ComponentWithHook = () => {
|
||||
return useMetabaseAuthStatus()?.status ?? "SDK Bundle Loading...";
|
||||
};
|
||||
|
||||
it("should return the auth status when the hook is called inside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<ComponentWithHook />
|
||||
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("loading").should("exist");
|
||||
cy.findByText("success").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the auth status when the hook is called outside of MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("loading").should("exist");
|
||||
cy.findByText("success").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the auth status when the hook is called without rendered SDK components", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG} />
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("loading").should("exist");
|
||||
cy.findByText("success").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCreateDashboardApi", () => {
|
||||
const ComponentWithHook = () => {
|
||||
const result = useCreateDashboardApi();
|
||||
const [createdDashboard, setCreatedDashboard] = useState<{
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
result
|
||||
.createDashboard({
|
||||
name: "Test Dashboard",
|
||||
description: "This is a test dashboard",
|
||||
})
|
||||
.then((dashboard: MetabaseDashboard) => {
|
||||
setCreatedDashboard(dashboard);
|
||||
});
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return createdDashboard?.name;
|
||||
};
|
||||
|
||||
it("should render the created dashboard name when called inside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<ComponentWithHook />
|
||||
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("Test Dashboard").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the created dashboard name when called outside of MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("Test Dashboard").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the created dashboard name when called without rendered SDK components", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG} />
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("Test Dashboard").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useApplicationName", () => {
|
||||
const ComponentWithHook = () => {
|
||||
const appName = useApplicationName();
|
||||
|
||||
return <div data-testid="app-name">{appName ?? "Loading..."}</div>;
|
||||
};
|
||||
|
||||
it("should render the application name when called inside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<ComponentWithHook />
|
||||
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>,
|
||||
);
|
||||
|
||||
cy.findByTestId("app-name").should("have.text", "Metabase");
|
||||
});
|
||||
|
||||
it("should render the application name when called outside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.findByTestId("app-name").should("have.text", "Metabase");
|
||||
});
|
||||
|
||||
it("should render the application name when called without rendered SDK components", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG} />
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.findByTestId("app-name").should("have.text", "Metabase");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAvailableFonts", () => {
|
||||
const ComponentWithHook = () => {
|
||||
const fonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<div data-testid="fonts-list">
|
||||
{fonts?.availableFonts?.join(", ") ?? "Loading..."}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
it("should render available fonts when called inside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<ComponentWithHook />
|
||||
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>,
|
||||
);
|
||||
|
||||
cy.findByTestId("fonts-list").should("contain.text", "Lato");
|
||||
});
|
||||
|
||||
it("should render available fonts when called outside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.findByTestId("fonts-list").should("contain.text", "Lato");
|
||||
});
|
||||
|
||||
it("should render available fonts when called without rendered SDK components", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG} />
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.findByTestId("fonts-list").should("contain.text", "Lato");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCurrentUser", () => {
|
||||
const ComponentWithHook = () => {
|
||||
const user = useCurrentUser();
|
||||
|
||||
return (
|
||||
<div data-testid="current-user">{user ? user.email : "Loading..."}</div>
|
||||
);
|
||||
};
|
||||
|
||||
it("should render the current user email when called inside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<ComponentWithHook />
|
||||
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>,
|
||||
);
|
||||
|
||||
cy.findByTestId("current-user").should(
|
||||
"have.text",
|
||||
"admin@metabase.test",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the current user email when called outside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.findByTestId("current-user").should(
|
||||
"have.text",
|
||||
"admin@metabase.test",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the current user email when called without rendered SDK components", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG} />
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.findByTestId("current-user").should(
|
||||
"have.text",
|
||||
"admin@metabase.test",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
InteractiveQuestion,
|
||||
type MetabaseDashboard,
|
||||
MetabaseProvider,
|
||||
useCreateDashboardApi,
|
||||
useMetabaseAuthStatus,
|
||||
} from "@metabase/embedding-sdk-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
|
||||
@@ -324,129 +320,6 @@ describe(
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hooks", () => {
|
||||
describe("useMetabaseAuthStatus", () => {
|
||||
const ComponentWithHook = () => {
|
||||
return useMetabaseAuthStatus()?.status ?? "SDK Bundle Loading...";
|
||||
};
|
||||
|
||||
it("should return the auth status when the hook is called inside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<ComponentWithHook />
|
||||
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("loading").should("exist");
|
||||
cy.findByText("success").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the auth status when the hook is called outside of MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("loading").should("exist");
|
||||
cy.findByText("success").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the auth status when the hook is called without rendered SDK components", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG} />
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("loading").should("exist");
|
||||
cy.findByText("success").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCreateDashboardApi", () => {
|
||||
const ComponentWithHook = () => {
|
||||
const result = useCreateDashboardApi();
|
||||
const [createdDashboard, setCreatedDashboard] = useState<{
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
result
|
||||
.createDashboard({
|
||||
name: "Test Dashboard",
|
||||
description: "This is a test dashboard",
|
||||
})
|
||||
.then((dashboard: MetabaseDashboard) => {
|
||||
setCreatedDashboard(dashboard);
|
||||
});
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return createdDashboard?.name;
|
||||
};
|
||||
|
||||
it("should render the created dashboard name when called inside MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<ComponentWithHook />
|
||||
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("Test Dashboard").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the created dashboard name when called outside of MetabaseProvider", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("Test Dashboard").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the created dashboard name when called without rendered SDK components", () => {
|
||||
mountSdk(
|
||||
<>
|
||||
<ComponentWithHook />
|
||||
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG} />
|
||||
</>,
|
||||
);
|
||||
|
||||
cy.get("body").within(() => {
|
||||
cy.findByText("Test Dashboard").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
beforeEach(() => {
|
||||
sdkBundleCleanup();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import {
|
||||
FIRST_COLLECTION_ENTITY_ID,
|
||||
FIRST_COLLECTION_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
SECOND_COLLECTION_ENTITY_ID,
|
||||
THIRD_COLLECTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
@@ -572,4 +573,40 @@ describe("scenarios > embedding-sdk > interactive-question", () => {
|
||||
cy.findByText("Back to previous results").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the `Save` button when a visualization type was changed (metabase#62396)", () => {
|
||||
mountSdkContent(<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByTestId("chart-type-selector-button").click();
|
||||
|
||||
cy.findByTestId("sdk-question-save-button").should("not.exist");
|
||||
|
||||
cy.findByRole("menu").within(() => {
|
||||
cy.findByText("Trend").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("interactive-question-top-toolbar").within(() => {
|
||||
cy.findByText("Save").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the `Save` button when a visualization setting was changed (metabase#62396)", () => {
|
||||
mountSdkContent(<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
H.openVizSettingsSidebar();
|
||||
|
||||
cy.findByTestId("sdk-question-save-button").should("not.exist");
|
||||
|
||||
cy.findByTestId("chartsettings-sidebar").within(() => {
|
||||
cy.findByTestId("User ID-hide-button").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("interactive-question-top-toolbar").within(() => {
|
||||
cy.findByText("Save").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2194,6 +2194,33 @@ describe("scenarios > admin > datamodel", () => {
|
||||
H.modal().findByText("2.07").should("not.exist");
|
||||
});
|
||||
|
||||
it("should let you change field visibility to 'Do not include' even if Preview is opened (metabase#61806)", () => {
|
||||
H.DataModel.visit({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.TAX,
|
||||
});
|
||||
|
||||
TableSection.clickField("Tax");
|
||||
FieldSection.getPreviewButton().click();
|
||||
PreviewSection.get().within(() => {
|
||||
cy.findByText("Filtering").click();
|
||||
|
||||
cy.findByTestId("number-filter-picker").should("be.visible");
|
||||
});
|
||||
|
||||
FieldSection.getVisibilityInput()
|
||||
.should("have.value", "Everywhere")
|
||||
.click();
|
||||
H.popover().findByText("Do not include").click();
|
||||
cy.wait("@updateField");
|
||||
|
||||
PreviewSection.get()
|
||||
.findByText("This field is hidden")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should let you change field visibility to 'Only in detail views'", () => {
|
||||
H.DataModel.visit({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
|
||||
@@ -1264,6 +1264,25 @@ describe("issue 49304", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 41305", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
});
|
||||
|
||||
it("should allow to right click in the suggestion popover without closing it (metabase#41305)", () => {
|
||||
H.openProductsTable({ mode: "notebook" });
|
||||
H.addCustomColumn();
|
||||
H.enterCustomColumnDetails({ formula: "contains(", blur: false });
|
||||
H.popover()
|
||||
.should("have.length", 2)
|
||||
.last()
|
||||
.findByText("The column or text to check.")
|
||||
.rightclick();
|
||||
H.popover().should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 49305", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
|
||||
@@ -2191,12 +2191,12 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
|
||||
],
|
||||
expressions: ["Net"],
|
||||
filters: [
|
||||
"Product → Title is Doohickey",
|
||||
"Reviews - Product → Reviewer is Doohickey",
|
||||
"ID is 7021",
|
||||
"Net is equal to -80",
|
||||
"Product → Vendor is Doohickey",
|
||||
"User → Longitude is equal to -80",
|
||||
"Net is equal to -80",
|
||||
"ID is 7021",
|
||||
"Reviews - Product → Reviewer is Doohickey",
|
||||
"Product → Vendor is Doohickey",
|
||||
"Product → Title is Doohickey",
|
||||
],
|
||||
aggregations: ["Count", "Sum of Total"],
|
||||
breakouts: [
|
||||
|
||||
@@ -11,14 +11,19 @@ import {
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("documents", () => {
|
||||
H.describeWithSnowplowEE("documents", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.resetSnowplow();
|
||||
});
|
||||
|
||||
it("should allow you to create a new document from the new button and save", () => {
|
||||
const getDocumentStub = cy.stub();
|
||||
|
||||
cy.intercept("GET", "/api/ee/document/1", getDocumentStub);
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
H.newButton("Document").click();
|
||||
@@ -40,8 +45,14 @@ describe("documents", () => {
|
||||
);
|
||||
H.entityPickerModalItem(1, "First collection").click();
|
||||
H.entityPickerModal().findByRole("button", { name: "Select" }).click();
|
||||
|
||||
// We should not show a loading state in between creating a document and viewing the created document.
|
||||
cy.location("pathname").should("eq", "/document/1");
|
||||
cy.title().should("eq", "Test Document · Metabase");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({ event: "document_created" });
|
||||
cy.wrap(getDocumentStub).should("not.have.been.called");
|
||||
|
||||
H.appBar()
|
||||
.findByRole("link", { name: /First collection/ })
|
||||
.click();
|
||||
@@ -175,12 +186,35 @@ describe("documents", () => {
|
||||
});
|
||||
|
||||
it("not found", () => {
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id + 1}`));
|
||||
cy.get("@documentId").then((id) =>
|
||||
cy.visit(`/document/${(id as unknown as number) + 1}`),
|
||||
);
|
||||
H.main().within(() => {
|
||||
cy.findByText("We're a little lost...").should("exist");
|
||||
cy.findByText("The page you asked for couldn't be found.");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow you to print", () => {
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
cy.findByRole("button", { name: "More options" }).click();
|
||||
|
||||
// This needs to be *after* the page load to work
|
||||
cy.window().then((win: Window) => {
|
||||
cy.stub(win, "print").as("printStub");
|
||||
});
|
||||
|
||||
H.popover().findByText("Print Document").click();
|
||||
|
||||
cy.get("@printStub").should("have.been.calledOnce");
|
||||
|
||||
cy.get("@documentId").then((id) => {
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "document_print",
|
||||
target_id: id,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,6 +403,13 @@ describe("documents", () => {
|
||||
cy.realPress("{downarrow}");
|
||||
H.addToDocument("\n", false);
|
||||
|
||||
cy.get("@documentId").then((id) => {
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "document_add_card",
|
||||
target_id: id,
|
||||
});
|
||||
});
|
||||
|
||||
H.getDocumentCard(ACCOUNTS_COUNT_BY_CREATED_AT.name).should("exist");
|
||||
|
||||
cy.realPress("{downarrow}");
|
||||
@@ -458,6 +499,13 @@ describe("documents", () => {
|
||||
cy.findAllByTestId("result-item").findByText("Orders").click();
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => {
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "document_replace_card",
|
||||
target_id: id,
|
||||
});
|
||||
});
|
||||
|
||||
H.getDocumentCard(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name).should(
|
||||
"not.exist",
|
||||
);
|
||||
@@ -494,6 +542,7 @@ describe("documents", () => {
|
||||
H.addToDocument("");
|
||||
H.addToDocument("Adding a static link: /", false);
|
||||
H.commandSuggestionItem("Link").click();
|
||||
|
||||
H.addToDocument("Ord", false);
|
||||
H.commandSuggestionItem(/Orders, Count$/).click();
|
||||
H.addToDocument(" And continue typing", false);
|
||||
@@ -515,6 +564,17 @@ describe("documents", () => {
|
||||
|
||||
cy.wait("@documentGet");
|
||||
|
||||
cy.get("@documentId").then((id) => {
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "document_saved",
|
||||
target_id: id,
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "document_add_smart_link",
|
||||
target_id: id,
|
||||
});
|
||||
});
|
||||
|
||||
cy.findByTestId("toast-undo")
|
||||
.findByText("Document saved")
|
||||
.should("be.visible");
|
||||
@@ -531,6 +591,27 @@ describe("documents", () => {
|
||||
"not.include",
|
||||
ORDERS_BY_YEAR_QUESTION_ID.toString(),
|
||||
);
|
||||
|
||||
// Navigating to a question from a document should result in a back button
|
||||
cy.findByLabelText("Back to Foo Document").click();
|
||||
|
||||
cy.get("@documentId").then((id) =>
|
||||
cy.location("pathname").should("equal", `/document/${id}`),
|
||||
);
|
||||
|
||||
H.getDocumentCard("Orders, Count, Grouped by Created At (year)").within(
|
||||
() => {
|
||||
H.cartesianChartCircle().eq(1).click();
|
||||
},
|
||||
);
|
||||
|
||||
H.popover().findByText("See these Orders").click();
|
||||
|
||||
cy.findByLabelText("Back to Foo Document").click();
|
||||
|
||||
cy.get("@documentId").then((id) =>
|
||||
cy.location("pathname").should("equal", `/document/${id}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,195 +8,200 @@ const setupEmbed = (elementHtml: string) => {
|
||||
`);
|
||||
};
|
||||
|
||||
describe("scenarios > embedding > sdk iframe embedding > view and curate content", () => {
|
||||
beforeEach(() => {
|
||||
cy.signInAsAdmin();
|
||||
H.prepareSdkIframeEmbedTest({ withTokenFeatures: true });
|
||||
});
|
||||
|
||||
describe("<metabase-browser> (read-only mode)", () => {
|
||||
it("should show a collection browser with collection items", () => {
|
||||
setupEmbed('<metabase-browser initial-collection="root" />');
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.should("contain", "Name")
|
||||
.findAllByText("Orders")
|
||||
.first()
|
||||
.should("be.visible");
|
||||
describe(
|
||||
"scenarios > embedding > sdk iframe embedding > view and curate content",
|
||||
{ tags: "@flaky" },
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
cy.signInAsAdmin();
|
||||
H.prepareSdkIframeEmbedTest({ withTokenFeatures: true });
|
||||
});
|
||||
|
||||
it("should navigate to question when clicking on a question item", () => {
|
||||
setupEmbed('<metabase-browser initial-collection="root" />');
|
||||
describe("<metabase-browser> (read-only mode)", () => {
|
||||
it("should show a collection browser with collection items", () => {
|
||||
setupEmbed('<metabase-browser initial-collection="root" />');
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.should("contain", "Name")
|
||||
.findAllByText("Orders")
|
||||
.first()
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("should show question view");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("query-visualization-root")
|
||||
.should("be.visible");
|
||||
});
|
||||
it("should navigate to question when clicking on a question item", () => {
|
||||
setupEmbed('<metabase-browser initial-collection="root" />');
|
||||
|
||||
it("should show New Exploration button and open data picker when clicked", () => {
|
||||
setupEmbed('<metabase-browser initial-collection="root" />');
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Exploration")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
cy.log("should show question view");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("query-visualization-root")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Pick your starting data")
|
||||
.should("be.visible");
|
||||
});
|
||||
it("should show New Exploration button and open data picker when clicked", () => {
|
||||
setupEmbed('<metabase-browser initial-collection="root" />');
|
||||
|
||||
it("should pass through collection-visible-columns parameter", () => {
|
||||
setupEmbed(`
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Exploration")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Pick your starting data")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should pass through collection-visible-columns parameter", () => {
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="root"
|
||||
collection-visible-columns='["type", "name"]'
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Name");
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Type");
|
||||
H.getSimpleEmbedIframeContent().should("not.contain", "Last edited by");
|
||||
H.getSimpleEmbedIframeContent().should("not.contain", "Last edited at");
|
||||
});
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Name");
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Type");
|
||||
H.getSimpleEmbedIframeContent().should("not.contain", "Last edited by");
|
||||
H.getSimpleEmbedIframeContent().should("not.contain", "Last edited at");
|
||||
});
|
||||
|
||||
it("should pass through collection-page-size parameter", () => {
|
||||
setupEmbed(`
|
||||
it("should pass through collection-page-size parameter", () => {
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="root"
|
||||
collection-page-size="5"
|
||||
/>
|
||||
`);
|
||||
|
||||
cy.log("should show exactly 5 collection entries");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findAllByTestId("collection-entry-type")
|
||||
.should("have.length", 5);
|
||||
});
|
||||
cy.log("should show exactly 5 collection entries");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findAllByTestId("collection-entry-type")
|
||||
.should("have.length", 5);
|
||||
});
|
||||
|
||||
it("should hide New Exploration button when with-new-question is false", () => {
|
||||
setupEmbed(`
|
||||
it("should hide New Exploration button when with-new-question is false", () => {
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="root"
|
||||
with-new-question="false"
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Exploration")
|
||||
.should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<metabase-browser> (read-write mode)", () => {
|
||||
it("should show New Dashboard button and open modal when clicked", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Dashboard")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("should show create dashboard modal");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New dashboard")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should update breadcrumbs when creating dashboard in different collection", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
cy.log("verify initial breadcrumb");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Our analytics")
|
||||
.should("be.visible");
|
||||
|
||||
H.getSimpleEmbedIframeContent().findByText("New Dashboard").click();
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.log("change collection to save dashboard in");
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Which collection should this go in?").should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
cy.findByText("Our analytics").click();
|
||||
});
|
||||
|
||||
cy.findByText("First collection", { timeout: 20_000 }).click();
|
||||
cy.findByText("Select").click();
|
||||
|
||||
cy.log("create the dashboard");
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByPlaceholderText("What is the name of your dashboard?").type(
|
||||
"Test Dashboard",
|
||||
);
|
||||
cy.findByText("Create").click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.log(
|
||||
"verify breadcrumbs are updated to reflect the selected collection",
|
||||
);
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("sdk-breadcrumbs")
|
||||
.findByText("First collection")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should show New Exploration button and open data picker when clicked", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Exploration")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("should show data picker");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Pick your starting data")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should show Save button and save modal without entity picker when creating a new question", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
H.getSimpleEmbedIframeContent().findByText("New Exploration").click();
|
||||
|
||||
cy.log("select data model");
|
||||
H.getSimpleEmbedIframeContent().findByText("Orders").click();
|
||||
|
||||
cy.log("should show Save button");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Save")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("should show save modal without entity picker");
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Save new question").should("be.visible");
|
||||
cy.findByText("Where do you want to save this?").should("not.exist");
|
||||
});
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Exploration")
|
||||
.should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide New Dashboard button when with-new-dashboard is false", () => {
|
||||
setupEmbed(`
|
||||
describe("<metabase-browser> (read-write mode)", () => {
|
||||
it("should show New Dashboard button and open modal when clicked", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Dashboard")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("should show create dashboard modal");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New dashboard")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should update breadcrumbs when creating dashboard in different collection", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
cy.log("verify initial breadcrumb");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Our analytics")
|
||||
.should("be.visible");
|
||||
|
||||
H.getSimpleEmbedIframeContent().findByText("New Dashboard").click();
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.log("change collection to save dashboard in");
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Which collection should this go in?").should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
cy.findByText("Our analytics").click();
|
||||
});
|
||||
|
||||
cy.findByText("First collection", { timeout: 20_000 }).click();
|
||||
cy.findByText("Select").click();
|
||||
|
||||
cy.log("create the dashboard");
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByPlaceholderText(
|
||||
"What is the name of your dashboard?",
|
||||
).type("Test Dashboard");
|
||||
cy.findByText("Create").click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.log(
|
||||
"verify breadcrumbs are updated to reflect the selected collection",
|
||||
);
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("sdk-breadcrumbs")
|
||||
.findByText("First collection")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should show New Exploration button and open data picker when clicked", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Exploration")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("should show data picker");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Pick your starting data")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should show Save button and save modal without entity picker when creating a new question", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
H.getSimpleEmbedIframeContent().findByText("New Exploration").click();
|
||||
|
||||
cy.log("select data model");
|
||||
H.getSimpleEmbedIframeContent().findByText("Orders").click();
|
||||
|
||||
cy.log("should show Save button");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Save")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("should show save modal without entity picker");
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Save new question").should("be.visible");
|
||||
cy.findByText("Where do you want to save this?").should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide New Dashboard button when with-new-dashboard is false", () => {
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="root"
|
||||
read-only="false"
|
||||
@@ -204,77 +209,77 @@ describe("scenarios > embedding > sdk iframe embedding > view and curate content
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Dashboard")
|
||||
.should("not.exist");
|
||||
});
|
||||
|
||||
it("should create a dashboard in a new collection", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Dashboard")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByPlaceholderText("What is the name of your dashboard?").type(
|
||||
"Foo Bar Dashboard",
|
||||
);
|
||||
|
||||
cy.log("open the collection picker");
|
||||
cy.findByText("Our analytics").click();
|
||||
});
|
||||
|
||||
cy.findByText("Collections").click();
|
||||
cy.findByText("New collection").click();
|
||||
|
||||
cy.findAllByRole("dialog")
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.findByPlaceholderText("My new collection").type(
|
||||
"Foo Collection",
|
||||
);
|
||||
cy.findByText("Create").click();
|
||||
});
|
||||
|
||||
cy.findByText("Select").click();
|
||||
|
||||
cy.log("create new dashboard");
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Create").click();
|
||||
});
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Dashboard")
|
||||
.should("not.exist");
|
||||
});
|
||||
|
||||
cy.log("dashboard is created and shows empty state");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("This dashboard is empty")
|
||||
.should("be.visible");
|
||||
it("should create a dashboard in a new collection", () => {
|
||||
setupEmbed(
|
||||
'<metabase-browser initial-collection="root" read-only="false" />',
|
||||
);
|
||||
|
||||
cy.log("breadcrumbs show the new collection");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("sdk-breadcrumbs")
|
||||
.findByText("Foo Collection")
|
||||
.should("be.visible");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Dashboard")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.log("breadcrumbs show the new dashboard");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("sdk-breadcrumbs")
|
||||
.findByText("Foo Bar Dashboard")
|
||||
.should("be.visible");
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByPlaceholderText(
|
||||
"What is the name of your dashboard?",
|
||||
).type("Foo Bar Dashboard");
|
||||
|
||||
cy.log("dashboard title is visible in header");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("dashboard-header")
|
||||
.findByText("Foo Bar Dashboard")
|
||||
.should("be.visible");
|
||||
});
|
||||
cy.log("open the collection picker");
|
||||
cy.findByText("Our analytics").click();
|
||||
});
|
||||
|
||||
it("should hide New Exploration button when with-new-question is false", () => {
|
||||
setupEmbed(`
|
||||
cy.findByText("Collections").click();
|
||||
cy.findByText("New collection").click();
|
||||
|
||||
cy.findAllByRole("dialog")
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.findByPlaceholderText("My new collection").type(
|
||||
"Foo Collection",
|
||||
);
|
||||
cy.findByText("Create").click();
|
||||
});
|
||||
|
||||
cy.findByText("Select").click();
|
||||
|
||||
cy.log("create new dashboard");
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Create").click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.log("dashboard is created and shows empty state");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("This dashboard is empty")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("breadcrumbs show the new collection");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("sdk-breadcrumbs")
|
||||
.findByText("Foo Collection")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("breadcrumbs show the new dashboard");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("sdk-breadcrumbs")
|
||||
.findByText("Foo Bar Dashboard")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("dashboard title is visible in header");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("dashboard-header")
|
||||
.findByText("Foo Bar Dashboard")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should hide New Exploration button when with-new-question is false", () => {
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="root"
|
||||
read-only="false"
|
||||
@@ -282,13 +287,13 @@ describe("scenarios > embedding > sdk iframe embedding > view and curate content
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Exploration")
|
||||
.should("not.exist");
|
||||
});
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("New Exploration")
|
||||
.should("not.exist");
|
||||
});
|
||||
|
||||
it("should pass through data-picker-entity-types parameter", () => {
|
||||
setupEmbed(`
|
||||
it("should pass through data-picker-entity-types parameter", () => {
|
||||
setupEmbed(`
|
||||
<metabase-browser
|
||||
initial-collection="root"
|
||||
read-only="false"
|
||||
@@ -296,37 +301,38 @@ describe("scenarios > embedding > sdk iframe embedding > view and curate content
|
||||
/>
|
||||
`);
|
||||
|
||||
H.getSimpleEmbedIframeContent().findByText("New Exploration").click();
|
||||
H.getSimpleEmbedIframeContent().findByText("New Exploration").click();
|
||||
|
||||
cy.log("should show data picker with limited entity types");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Pick your starting data")
|
||||
.should("be.visible");
|
||||
cy.log("should show data picker with limited entity types");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Pick your starting data")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("should show Orders table but not Orders model");
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Orders");
|
||||
H.getSimpleEmbedIframeContent().should("not.contain", "Orders model");
|
||||
cy.log("should show Orders table but not Orders model");
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Orders");
|
||||
H.getSimpleEmbedIframeContent().should("not.contain", "Orders model");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("breadcrumb navigation", () => {
|
||||
it("should show breadcrumbs when navigating between content", () => {
|
||||
setupEmbed('<metabase-browser initial-collection="root" />');
|
||||
describe("breadcrumb navigation", () => {
|
||||
it("should show breadcrumbs when navigating between content", () => {
|
||||
setupEmbed('<metabase-browser initial-collection="root" />');
|
||||
|
||||
cy.log("should show initial breadcrumb");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Our analytics")
|
||||
.should("be.visible");
|
||||
cy.log("should show initial breadcrumb");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Our analytics")
|
||||
.should("be.visible");
|
||||
|
||||
H.getSimpleEmbedIframeContent().findAllByText("Orders").first().click();
|
||||
H.getSimpleEmbedIframeContent().findAllByText("Orders").first().click();
|
||||
|
||||
cy.log("should show breadcrumb for the question");
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Orders");
|
||||
cy.log("should show breadcrumb for the question");
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Orders");
|
||||
|
||||
H.getSimpleEmbedIframeContent().findByText("Our analytics").click();
|
||||
H.getSimpleEmbedIframeContent().findByText("Our analytics").click();
|
||||
|
||||
cy.log("should be back at collection browser");
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Name");
|
||||
cy.log("should be back at collection browser");
|
||||
H.getSimpleEmbedIframeContent().should("contain", "Name");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -201,3 +201,50 @@ describe("issue 32037", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 30574", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
});
|
||||
|
||||
it("should not throw when diving a metric by another metric with a custom aggregation expression with a custom name (metabase#30574)", () => {
|
||||
cy.visit("/browse/metrics");
|
||||
|
||||
cy.log("create the first metric");
|
||||
H.main().findByText("Create metric").click();
|
||||
H.entityPickerModal().within(() => {
|
||||
H.entityPickerModalTab("Tables").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
cy.findByTestId("edit-bar").button("Save").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").clear().type("M1");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
H.queryBuilderHeader().should("be.visible");
|
||||
|
||||
cy.log("create the second metric");
|
||||
H.openNavigationSidebar();
|
||||
H.navigationSidebar().findByText("Metrics").click();
|
||||
H.main().findByLabelText("Create a new metric").click();
|
||||
H.entityPickerModal().within(() => {
|
||||
H.entityPickerModalTab("Tables").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
H.getNotebookStep("summarize").findByText("Count").click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.enterCustomColumnDetails({
|
||||
name: "X",
|
||||
formula: "[M1]/[M1]",
|
||||
});
|
||||
H.popover().button("Update").click();
|
||||
cy.findByTestId("edit-bar").button("Save").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").clear().type("M2");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
H.queryBuilderHeader().should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
turnIntoModel,
|
||||
} from "./helpers/e2e-models-helpers";
|
||||
|
||||
const { PRODUCTS, ORDERS_ID, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
const { PRODUCTS, ORDERS_ID, PRODUCTS_ID, ACCOUNTS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
describe("scenarios > models", () => {
|
||||
beforeEach(() => {
|
||||
@@ -37,6 +37,18 @@ describe("scenarios > models", () => {
|
||||
});
|
||||
|
||||
it("allows to turn a GUI question into a model", () => {
|
||||
H.createQuestion(
|
||||
{
|
||||
name: "Accounts Model",
|
||||
query: { "source-table": ACCOUNTS_ID },
|
||||
type: "model",
|
||||
},
|
||||
{
|
||||
wrapId: true,
|
||||
idAlias: "accountsModelId",
|
||||
},
|
||||
);
|
||||
|
||||
cy.get("@productsQuestionId").then((id) => {
|
||||
cy.request("PUT", `/api/card/${id}`, {
|
||||
name: "Products Model",
|
||||
@@ -80,6 +92,37 @@ describe("scenarios > models", () => {
|
||||
|
||||
cy.url().should("not.include", "/question/" + id);
|
||||
});
|
||||
|
||||
cy.log(
|
||||
"Question Lineage should show link to archived models (metabase#52071)",
|
||||
);
|
||||
cy.get("@accountsModelId").then((modelId) => {
|
||||
H.createQuestion(
|
||||
{
|
||||
name: "Accounts Model Quest",
|
||||
query: { "source-table": `card__${modelId}` },
|
||||
},
|
||||
{
|
||||
wrapId: true,
|
||||
idAlias: "accountsQuestionId",
|
||||
},
|
||||
);
|
||||
|
||||
H.archiveQuestion(modelId);
|
||||
|
||||
cy.get("@accountsQuestionId").then((questionId) => {
|
||||
H.visitQuestion(questionId);
|
||||
cy.findByTestId("qb-header-left-side").within(() => {
|
||||
cy.icon("warning").should("exist");
|
||||
|
||||
cy.findByRole("link", { name: /accounts model/i }).should(
|
||||
"have.attr",
|
||||
"href",
|
||||
`/model/${modelId}-accounts-model`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("allows to turn a native question into a model", () => {
|
||||
|
||||
@@ -56,52 +56,56 @@ describe("issue 29943", () => {
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
});
|
||||
|
||||
it("selects the right column when clicking a column header (metabase#29943)", () => {
|
||||
H.createQuestion(
|
||||
{
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
expressions: {
|
||||
Custom: ["+", 1, 1],
|
||||
it(
|
||||
"selects the right column when clicking a column header (metabase#29943)",
|
||||
{ tags: "@flaky" },
|
||||
() => {
|
||||
H.createQuestion(
|
||||
{
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
expressions: {
|
||||
Custom: ["+", 1, 1],
|
||||
},
|
||||
fields: [
|
||||
["field", ORDERS.ID, { "base-type": "type/BigInteger" }],
|
||||
["field", ORDERS.TOTAL, { "base-type": "type/Float" }],
|
||||
["expression", "Custom", { "base-type": "type/Integer" }],
|
||||
],
|
||||
limit: 5, // optimization
|
||||
},
|
||||
fields: [
|
||||
["field", ORDERS.ID, { "base-type": "type/BigInteger" }],
|
||||
["field", ORDERS.TOTAL, { "base-type": "type/Float" }],
|
||||
["expression", "Custom", { "base-type": "type/Integer" }],
|
||||
],
|
||||
limit: 5, // optimization
|
||||
},
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metadata").click();
|
||||
H.waitForLoaderToBeRemoved();
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metadata").click();
|
||||
H.waitForLoaderToBeRemoved();
|
||||
|
||||
reorderTotalAndCustomColumns();
|
||||
cy.button("Save changes").click();
|
||||
cy.wait("@dataset");
|
||||
reorderTotalAndCustomColumns();
|
||||
cy.button("Save changes").click();
|
||||
cy.wait("@dataset");
|
||||
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metadata").click();
|
||||
H.waitForLoaderToBeRemoved();
|
||||
H.openQuestionActions();
|
||||
H.popover().findByText("Edit metadata").click();
|
||||
H.waitForLoaderToBeRemoved();
|
||||
|
||||
assertColumnSelected(0, "ID");
|
||||
assertColumnSelected(0, "ID");
|
||||
|
||||
getHeaderCell(1, "Custom");
|
||||
H.tableHeaderClick("Custom");
|
||||
assertColumnSelected(1, "Custom");
|
||||
getHeaderCell(1, "Custom");
|
||||
H.tableHeaderClick("Custom");
|
||||
assertColumnSelected(1, "Custom");
|
||||
|
||||
getHeaderCell(2, "Total");
|
||||
H.tableHeaderClick("Total");
|
||||
assertColumnSelected(2, "Total");
|
||||
getHeaderCell(2, "Total");
|
||||
H.tableHeaderClick("Total");
|
||||
assertColumnSelected(2, "Total");
|
||||
|
||||
getHeaderCell(0, "ID");
|
||||
H.tableHeaderClick("ID");
|
||||
assertColumnSelected(0, "ID");
|
||||
});
|
||||
getHeaderCell(0, "ID");
|
||||
H.tableHeaderClick("ID");
|
||||
assertColumnSelected(0, "ID");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("issue 35711", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const { H } = cy;
|
||||
|
||||
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import type {
|
||||
NativeQuestionDetails,
|
||||
@@ -8,6 +10,7 @@ import type { IconName } from "metabase/ui";
|
||||
import type { Database, ListDatabasesResponse } from "metabase-types/api";
|
||||
|
||||
import { getRunQueryButton } from "../native-filters/helpers/e2e-sql-filter-helpers";
|
||||
|
||||
const { ORDERS_ID, REVIEWS } = SAMPLE_DATABASE;
|
||||
|
||||
describe("issue 11727", { tags: "@external" }, () => {
|
||||
@@ -755,3 +758,62 @@ describe("issue 60719", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 59356", () => {
|
||||
function typeRunShortcut() {
|
||||
cy.get("body").type("{ctrl+enter}{cmd+enter}");
|
||||
}
|
||||
|
||||
function getLoader() {
|
||||
return H.queryBuilderMain().findByTestId("loading-indicator");
|
||||
}
|
||||
|
||||
function getEmptyStateMessage() {
|
||||
return H.queryBuilderMain().findByText(
|
||||
"Here's where your results will appear",
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore("postgres-writable");
|
||||
cy.signInAsAdmin();
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
});
|
||||
|
||||
it("should properly cancel the query via the keyboard shortcut (metabase#59356)", () => {
|
||||
cy.log("open the native query");
|
||||
H.startNewNativeQuestion({
|
||||
database: WRITABLE_DB_ID,
|
||||
query: "select pg_sleep(5000)",
|
||||
});
|
||||
|
||||
cy.log("verify that the query is not running");
|
||||
getLoader().should("not.exist");
|
||||
getEmptyStateMessage().should("be.visible");
|
||||
cy.get("@dataset.all").should("have.length", 0);
|
||||
|
||||
cy.log("run the query and verify that it is running");
|
||||
typeRunShortcut();
|
||||
getLoader().should("be.visible");
|
||||
getEmptyStateMessage().should("not.exist");
|
||||
cy.get("@dataset.all").should("have.length", 1);
|
||||
|
||||
cy.log("cancel the query and verify that no new query is running");
|
||||
typeRunShortcut();
|
||||
getLoader().should("not.exist");
|
||||
getEmptyStateMessage().should("be.visible");
|
||||
cy.get("@dataset.all").should("have.length", 1);
|
||||
|
||||
cy.log("run the query again and verify that it is running");
|
||||
typeRunShortcut();
|
||||
getLoader().should("be.visible");
|
||||
getEmptyStateMessage().should("not.exist");
|
||||
cy.get("@dataset.all").should("have.length", 2);
|
||||
|
||||
cy.log("cancel the query and verify that no new query is running");
|
||||
typeRunShortcut();
|
||||
getLoader().should("not.exist");
|
||||
getEmptyStateMessage().should("be.visible");
|
||||
cy.get("@dataset.all").should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,10 @@ const { admin } = USERS;
|
||||
const locales = ["en", "xx"];
|
||||
|
||||
describe("scenarios > setup", () => {
|
||||
beforeEach(() => H.restore("blank"));
|
||||
beforeEach(() => {
|
||||
H.restore("blank");
|
||||
H.resetSnowplow();
|
||||
});
|
||||
|
||||
locales.forEach((locale) => {
|
||||
it(
|
||||
@@ -244,27 +247,8 @@ describe("scenarios > setup", () => {
|
||||
it("should not show 'Sample Database' if env var is explicitly set to false during setup", () => {
|
||||
H.mockSessionProperty("has-sample-database?", false);
|
||||
|
||||
cy.visit(
|
||||
"/setup?first_name=John&last_name=Doe&email=john@doe.test&site_name=Doe%20Unlimited",
|
||||
);
|
||||
navigateToDatabaseStep();
|
||||
|
||||
skipWelcomePage();
|
||||
selectPreferredLanguageAndContinue();
|
||||
|
||||
cy.findByTestId("setup-forms").within(() => {
|
||||
const password = "12341234";
|
||||
cy.findByLabelText("Create a password").should("be.empty").type(password);
|
||||
cy.findByLabelText("Confirm your password").type(password);
|
||||
cy.button("Next").click();
|
||||
|
||||
cy.log("Just go through the usage questionaire");
|
||||
cy.findByLabelText("What will you use Metabase for?").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.button("Next").click();
|
||||
});
|
||||
|
||||
cy.log("We are now on the database step");
|
||||
cy.findByLabelText("Add your data").within(() => {
|
||||
cy.button("Continue with sample data").should("not.exist");
|
||||
cy.button("I'll add my data later").click();
|
||||
@@ -274,6 +258,38 @@ describe("scenarios > setup", () => {
|
||||
cy.findByLabelText("I'll add my own data later").should("be.visible");
|
||||
});
|
||||
|
||||
it("should create a new user upon inviting a teammate", () => {
|
||||
H.mockSessionProperty("email-configured?", true);
|
||||
|
||||
navigateToDatabaseStep();
|
||||
cy.findByTestId("step-number").should("have.text", "4");
|
||||
|
||||
cy.findByLabelText("Setup section").click();
|
||||
cy.findByLabelText("First name").type("TeammateFirstName");
|
||||
cy.findByLabelText("Last name").type("TeammateLastName");
|
||||
cy.findByLabelText("Email").type("teammate@metabase.test");
|
||||
cy.intercept("POST", "/api/user").as("createUser");
|
||||
|
||||
cy.button("Send invitation").click();
|
||||
|
||||
cy.wait("@createUser").then((interception) => {
|
||||
expect(interception.request.body).to.include({
|
||||
first_name: "TeammateFirstName",
|
||||
last_name: "TeammateLastName",
|
||||
email: "teammate@metabase.test",
|
||||
});
|
||||
});
|
||||
|
||||
// Checks invite event was sent
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "invite_sent",
|
||||
source: "setup",
|
||||
});
|
||||
|
||||
// Checks we are now in the next step
|
||||
cy.findByTestId("step-number").should("have.text", "5");
|
||||
});
|
||||
|
||||
it("should allow a quick setup for the 'embedding' use case", () => {
|
||||
cy.visit(
|
||||
"/setup?first_name=John&last_name=Doe&email=john@doe.test&site_name=Doe%20Unlimited&use_case=embedding",
|
||||
@@ -730,3 +746,26 @@ const typeToken = (token: string) => {
|
||||
.invoke("attr", "type", "password")
|
||||
.type(token, { log: false });
|
||||
};
|
||||
|
||||
// Navigate to the setup page, fills user data, password, skips usage questionnaire and proceeds to the database step
|
||||
const navigateToDatabaseStep = () => {
|
||||
cy.visit(
|
||||
"/setup?first_name=John&last_name=Doe&email=john@doe.test&site_name=Doe%20Unlimited",
|
||||
);
|
||||
|
||||
skipWelcomePage();
|
||||
selectPreferredLanguageAndContinue();
|
||||
|
||||
cy.findByTestId("setup-forms").within(() => {
|
||||
const password = "12341234";
|
||||
cy.findByLabelText("Create a password").should("be.empty").type(password);
|
||||
cy.findByLabelText("Confirm your password").type(password);
|
||||
cy.button("Next").click();
|
||||
|
||||
cy.log("Just go through the usage questionnaire");
|
||||
cy.findByLabelText("What will you use Metabase for?").should("be.visible");
|
||||
cy.button("Next").click();
|
||||
});
|
||||
|
||||
cy.log("We are now on the database step");
|
||||
};
|
||||
|
||||
@@ -2415,8 +2415,8 @@ describe("issue 50038", () => {
|
||||
strategy: "left-join",
|
||||
condition: [
|
||||
"=",
|
||||
["field", ORDERS_ID, {}],
|
||||
["field", PRODUCTS_ID, {}],
|
||||
["field", PRODUCTS.ID, null],
|
||||
["field", ORDERS.PRODUCT_ID, { "join-alias": "Orders" }],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1125,6 +1125,36 @@ describe("issue 55487", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 42723", () => {
|
||||
const questionDetails: StructuredQuestionDetails = {
|
||||
display: "line",
|
||||
query: {
|
||||
"source-table": PRODUCTS_ID,
|
||||
aggregation: [["count"]],
|
||||
breakout: [
|
||||
["field", PRODUCTS.CREATED_AT, { "base-type": "type/DateTime" }],
|
||||
["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
});
|
||||
|
||||
it("should be able to change the query without loosing the viz type (metabase#42723)", () => {
|
||||
H.createQuestion(questionDetails, { visitQuestion: true });
|
||||
H.queryBuilderFooter().findByLabelText("Switch to data").click();
|
||||
H.tableHeaderClick("Count");
|
||||
H.popover().icon("arrow_up").click();
|
||||
H.tableInteractiveHeader().icon("chevronup").should("be.visible");
|
||||
|
||||
H.queryBuilderFooter().findByLabelText("Switch to visualization").click();
|
||||
H.ensureChartIsActive();
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 58628", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { P, isMatching } from "ts-pattern";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
import { commandPaletteInput } from "../../../support/helpers/e2e-command-palette-helpers";
|
||||
@@ -24,24 +26,39 @@ H.describeWithSnowplow("scenarios > search > snowplow", () => {
|
||||
H.commandPaletteSearch("Orders", false);
|
||||
|
||||
//Passing a function to ensure that runtime_milliseconds is populated as a number
|
||||
H.expectUnstructuredSnowplowEvent((data) => {
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
data.event === NEW_SEARCH_QUERY_EVENT_NAME &&
|
||||
data.context === "command-palette" &&
|
||||
typeof data.runtime_milliseconds === "number"
|
||||
);
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent((event) =>
|
||||
isMatching(
|
||||
{
|
||||
event: NEW_SEARCH_QUERY_EVENT_NAME,
|
||||
context: "command-palette",
|
||||
runtime_milliseconds: P.number,
|
||||
search_engine: P.string,
|
||||
request_id: P.string,
|
||||
offset: null,
|
||||
search_term_hash: P.string,
|
||||
search_term: null,
|
||||
},
|
||||
event,
|
||||
),
|
||||
);
|
||||
|
||||
H.commandPalette().findByRole("option", { name: "Orders Model" }).click();
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: SEARCH_CLICK,
|
||||
context: "command-palette",
|
||||
position: 3,
|
||||
},
|
||||
(event) =>
|
||||
isMatching(
|
||||
{
|
||||
event: SEARCH_CLICK,
|
||||
target_type: "item",
|
||||
context: "command-palette",
|
||||
position: 3,
|
||||
search_engine: P.string,
|
||||
request_id: P.string,
|
||||
entity_model: P.string,
|
||||
search_term_hash: P.string,
|
||||
search_term: null,
|
||||
},
|
||||
event,
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -51,16 +68,16 @@ H.describeWithSnowplow("scenarios > search > snowplow", () => {
|
||||
H.commandPaletteSearch("Orders", false);
|
||||
|
||||
//Passing a function to ensure that runtime_milliseconds is populated as a number
|
||||
H.expectUnstructuredSnowplowEvent((data) => {
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
data.event === NEW_SEARCH_QUERY_EVENT_NAME &&
|
||||
data.context === "command-palette" &&
|
||||
typeof data.runtime_milliseconds === "number"
|
||||
);
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent((event) =>
|
||||
isMatching(
|
||||
{
|
||||
event: NEW_SEARCH_QUERY_EVENT_NAME,
|
||||
context: "command-palette",
|
||||
runtime_milliseconds: P.number,
|
||||
},
|
||||
event,
|
||||
),
|
||||
);
|
||||
|
||||
// FIX ME: We need to slow cypress down before we start inputting keyboard events.
|
||||
// Not clear why though :/.
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
"@testing-library/cypress",
|
||||
"cypress-real-events",
|
||||
"@cypress/grep",
|
||||
"../enterprise/frontend/src/embedding-sdk-bundle/types/globalTypes.d.ts"
|
||||
"../enterprise/frontend/src/embedding-sdk-shared/types/globals.d.ts"
|
||||
],
|
||||
"paths": {
|
||||
"e2e/*": ["./*"],
|
||||
"@metabase/embedding-sdk-react": ["../enterprise/frontend/src/embedding-sdk-bundle/index.ts"],
|
||||
"@metabase/embedding-sdk-react": ["../enterprise/frontend/src/embedding-sdk-package/index.ts"],
|
||||
"*": ["../frontend/src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
[metabase-enterprise.api.routes.common :as ee.api.common]
|
||||
[metabase-enterprise.audit-app.api.routes]
|
||||
[metabase-enterprise.billing.api.routes]
|
||||
[metabase-enterprise.branching.api]
|
||||
[metabase-enterprise.content-translation.routes]
|
||||
[metabase-enterprise.content-verification.api.routes]
|
||||
[metabase-enterprise.database-replication.api :as database-replication.api]
|
||||
[metabase-enterprise.database-routing.api]
|
||||
[metabase-enterprise.documents.api]
|
||||
[metabase-enterprise.email.api]
|
||||
[metabase-enterprise.git-source-of-truth.api]
|
||||
[metabase-enterprise.gsheets.api :as gsheets.api]
|
||||
[metabase-enterprise.llm.api]
|
||||
[metabase-enterprise.metabot-v3.api]
|
||||
@@ -40,27 +42,27 @@
|
||||
metabase-enterprise.llm.api/keep-me)
|
||||
|
||||
(def ^:private required-feature->message
|
||||
{:advanced-permissions (deferred-tru "Advanced Permissions")
|
||||
:attached-dwh (deferred-tru "Attached DWH")
|
||||
:audit-app (deferred-tru "Audit app")
|
||||
:ai-sql-fixer (deferred-tru "AI SQL Fixer")
|
||||
:ai-sql-generation (deferred-tru "AI SQL Generation")
|
||||
:ai-entity-analysis (deferred-tru "AI Entity Analysis")
|
||||
:collection-cleanup (deferred-tru "Collection Cleanup")
|
||||
:content-translation (deferred-tru "Content translation")
|
||||
:documents (deferred-tru "Documents")
|
||||
:etl-connections (deferred-tru "ETL Connections")
|
||||
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
|
||||
:llm-autodescription (deferred-tru "LLM Auto-description")
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
:scim (deferred-tru "SCIM configuration")
|
||||
:semantic-search (deferred-tru "Semantic Search")
|
||||
:serialization (deferred-tru "Serialization")
|
||||
:table-data-editing (deferred-tru "Table Data Editing")
|
||||
:transforms (deferred-tru "Transforms")
|
||||
:upload-management (deferred-tru "Upload Management")
|
||||
:database-routing (deferred-tru "Database Routing")
|
||||
:cloud-custom-smtp (deferred-tru "Custom SMTP")})
|
||||
{:advanced-permissions (deferred-tru "Advanced Permissions")
|
||||
:attached-dwh (deferred-tru "Attached DWH")
|
||||
:audit-app (deferred-tru "Audit app")
|
||||
:ai-sql-fixer (deferred-tru "AI SQL Fixer")
|
||||
:ai-sql-generation (deferred-tru "AI SQL Generation")
|
||||
:ai-entity-analysis (deferred-tru "AI Entity Analysis")
|
||||
:collection-cleanup (deferred-tru "Collection Cleanup")
|
||||
:content-translation (deferred-tru "Content translation")
|
||||
:documents (deferred-tru "Documents")
|
||||
:etl-connections (deferred-tru "ETL Connections")
|
||||
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
|
||||
:llm-autodescription (deferred-tru "LLM Auto-description")
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
:scim (deferred-tru "SCIM configuration")
|
||||
:semantic-search (deferred-tru "Semantic Search")
|
||||
:serialization (deferred-tru "Serialization")
|
||||
:table-data-editing (deferred-tru "Table Data Editing")
|
||||
:transforms (deferred-tru "Transforms")
|
||||
:upload-management (deferred-tru "Upload Management")
|
||||
:database-routing (deferred-tru "Database Routing")
|
||||
:cloud-custom-smtp (deferred-tru "Custom SMTP")})
|
||||
|
||||
(defn- premium-handler [handler required-feature]
|
||||
(let [handler (cond-> handler
|
||||
@@ -74,45 +76,47 @@
|
||||
|
||||
TODO -- Please fix them! See #22687"
|
||||
{"/moderation-review" metabase-enterprise.content-verification.api.routes/routes
|
||||
"/mt" metabase-enterprise.sandbox.api.routes/sandbox-routes
|
||||
"/table" metabase-enterprise.sandbox.api.routes/sandbox-table-routes})
|
||||
"/mt" metabase-enterprise.sandbox.api.routes/sandbox-routes
|
||||
"/table" metabase-enterprise.sandbox.api.routes/sandbox-table-routes})
|
||||
|
||||
;;; ↓↓↓ KEEP THIS SORTED OR ELSE! ↓↓↓
|
||||
(def ^:private ee-routes-map
|
||||
"/api/ee routes. The following routes are NICE and do follow the `/ee/<feature>/` naming convention. Please add new
|
||||
routes here and follow the convention."
|
||||
;; Postponing a granular flag for :actions until it's used more widely.
|
||||
{"/action-v2" (premium-handler metabase-enterprise.action-v2.api/routes :table-data-editing)
|
||||
"/advanced-permissions" (premium-handler metabase-enterprise.advanced-permissions.api.routes/routes :advanced-permissions)
|
||||
"/ai-entity-analysis" (premium-handler metabase-enterprise.ai-entity-analysis.api/routes :ai-entity-analysis)
|
||||
"/ai-sql-fixer" (premium-handler metabase-enterprise.ai-sql-fixer.api/routes :ai-sql-fixer)
|
||||
"/ai-sql-generation" (premium-handler metabase-enterprise.ai-sql-generation.api/routes :ai-sql-generation)
|
||||
"/audit-app" (premium-handler metabase-enterprise.audit-app.api.routes/routes :audit-app)
|
||||
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
|
||||
"/billing" metabase-enterprise.billing.api.routes/routes
|
||||
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
|
||||
"/database-replication" (-> database-replication.api/routes ;; database-replication requires all these features.
|
||||
(premium-handler :attached-dwh)
|
||||
(premium-handler :etl-connections)
|
||||
(premium-handler :etl-connections-pg))
|
||||
"/database-routing" (premium-handler metabase-enterprise.database-routing.api/routes :database-routing)
|
||||
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
|
||||
"/email" (premium-handler metabase-enterprise.email.api/routes :cloud-custom-smtp)
|
||||
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
|
||||
(premium-handler :attached-dwh)
|
||||
(premium-handler :etl-connections))
|
||||
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
|
||||
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
|
||||
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
|
||||
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
|
||||
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
|
||||
"/semantic-search" (premium-handler metabase-enterprise.semantic-search.api/routes :semantic-search)
|
||||
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
|
||||
"/stale" (premium-handler metabase-enterprise.stale.api/routes :collection-cleanup)
|
||||
"/transform" (premium-handler metabase-enterprise.transforms.api/routes :transforms)
|
||||
"/transform-job" (premium-handler metabase-enterprise.transforms.api/transform-job-routes :transforms)
|
||||
"/transform-tag" (premium-handler metabase-enterprise.transforms.api/transform-tag-routes :transforms)
|
||||
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)})
|
||||
{"/action-v2" (premium-handler metabase-enterprise.action-v2.api/routes :table-data-editing)
|
||||
"/advanced-permissions" (premium-handler metabase-enterprise.advanced-permissions.api.routes/routes :advanced-permissions)
|
||||
"/ai-entity-analysis" (premium-handler metabase-enterprise.ai-entity-analysis.api/routes :ai-entity-analysis)
|
||||
"/ai-sql-fixer" (premium-handler metabase-enterprise.ai-sql-fixer.api/routes :ai-sql-fixer)
|
||||
"/ai-sql-generation" (premium-handler metabase-enterprise.ai-sql-generation.api/routes :ai-sql-generation)
|
||||
"/audit-app" (premium-handler metabase-enterprise.audit-app.api.routes/routes :audit-app)
|
||||
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
|
||||
"/billing" metabase-enterprise.billing.api.routes/routes
|
||||
"/branch" metabase-enterprise.branching.api/routes
|
||||
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
|
||||
"/database-replication" (-> database-replication.api/routes ;; database-replication requires all these features.
|
||||
(premium-handler :attached-dwh)
|
||||
(premium-handler :etl-connections)
|
||||
(premium-handler :etl-connections-pg))
|
||||
"/database-routing" (premium-handler metabase-enterprise.database-routing.api/routes :database-routing)
|
||||
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
|
||||
"/email" (premium-handler metabase-enterprise.email.api/routes :cloud-custom-smtp)
|
||||
"/git-source-of-truth" (premium-handler metabase-enterprise.git-source-of-truth.api/routes :serialization)
|
||||
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
|
||||
(premium-handler :attached-dwh)
|
||||
(premium-handler :etl-connections))
|
||||
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
|
||||
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
|
||||
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
|
||||
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
|
||||
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
|
||||
"/semantic-search" (premium-handler metabase-enterprise.semantic-search.api/routes :semantic-search)
|
||||
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
|
||||
"/stale" (premium-handler metabase-enterprise.stale.api/routes :collection-cleanup)
|
||||
"/transform" (premium-handler metabase-enterprise.transforms.api/routes :transforms)
|
||||
"/transform-job" (premium-handler metabase-enterprise.transforms.api/transform-job-routes :transforms)
|
||||
"/transform-tag" (premium-handler metabase-enterprise.transforms.api/transform-tag-routes :transforms)
|
||||
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)})
|
||||
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑
|
||||
|
||||
(def ^:private routes-map
|
||||
|
||||
127
enterprise/backend/src/metabase_enterprise/branching/api.clj
Normal file
@@ -0,0 +1,127 @@
|
||||
(ns metabase-enterprise.branching.api
|
||||
"`/api/ee/branch/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.branching.models.branch :as branch]
|
||||
[metabase-enterprise.branching.models.branch-model-mapping :as mapping]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; ------------------------------------------------- Schemas -------------------------------------------------
|
||||
|
||||
(def BranchCreateRequest
|
||||
"Schema for creating a new branch."
|
||||
[:map
|
||||
[:name ms/NonBlankString]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:parent_branch_id {:optional true} [:maybe ms/PositiveInt]]])
|
||||
|
||||
(def BranchResponse
|
||||
"Schema for branch API responses."
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:name :string]
|
||||
[:slug :string]
|
||||
[:description [:maybe :string]]
|
||||
[:creator_id ms/PositiveInt]
|
||||
[:parent_branch_id [:maybe ms/PositiveInt]]
|
||||
[:created_at ms/TemporalString]
|
||||
[:updated_at ms/TemporalString]])
|
||||
|
||||
(def DiffResponse
|
||||
"Schema for branch diff API responses."
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:name :string]
|
||||
[:status [:enum "added" "modified" "deleted"]]
|
||||
[:content_type [:enum "card" "dashboard" "collection"]]
|
||||
[:current {:optional true} :map]
|
||||
[:original {:optional true} :map]])
|
||||
|
||||
;;; ------------------------------------------------- API Endpoints -------------------------------------------------
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
"Get all branches. Open access for authenticated users."
|
||||
[_route-params
|
||||
{:keys [parent_id]} :- [:map
|
||||
[:parent_id {:optional true} [:maybe ms/PositiveInt]]]]
|
||||
{:data (map #(select-keys % [:id :name :slug :description :creator_id :parent_branch_id :created_at :updated_at])
|
||||
(if parent_id
|
||||
(branch/branches-by-parent-id parent_id)
|
||||
(t2/select :model/Branch)))})
|
||||
|
||||
(api.macros/defendpoint :get "/:id/diff"
|
||||
"Get diff of changes in a branch compared to its parent branch. Open access for authenticated users."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(let [branch (t2/select-one :model/Branch :id id)]
|
||||
(when-not branch
|
||||
(throw (ex-info (tru "Branch not found.") {:status-code 404})))
|
||||
|
||||
(let [mappings (t2/select :model/BranchModelMapping :branch_id id)
|
||||
card-mappings (filter #(= (:model_type %) "report_card") mappings)
|
||||
diff-entries (for [{:keys [original_id branched_model_id]} card-mappings]
|
||||
(let [is-added? (= original_id branched_model_id)
|
||||
status (if is-added? "added" "modified")
|
||||
original-card (when-not is-added?
|
||||
(t2/select-one :model/Card :id original_id))
|
||||
branched-card (t2/select-one :model/Card :id branched_model_id)
|
||||
card-fields [:id :name :description :dataset_query :display :visualization_settings]]
|
||||
|
||||
{:id branched_model_id
|
||||
:name (or (:name branched-card) (:name original-card) "Unknown")
|
||||
:status status
|
||||
:content_type "card"
|
||||
:current (when branched-card (select-keys branched-card card-fields))
|
||||
:original (when original-card (select-keys original-card card-fields))}))]
|
||||
|
||||
{:data diff-entries})))
|
||||
|
||||
(api.macros/defendpoint :get "/:id"
|
||||
"Get a single branch by ID. Open access for authenticated users."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(or (some-> (t2/select-one :model/Branch :id id)
|
||||
(select-keys [:id :name :slug :description :creator_id :parent_branch_id :created_at :updated_at]))
|
||||
(throw (ex-info (tru "Branch not found.") {:status-code 404}))))
|
||||
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Create a new branch. Open access for authenticated users."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [name description parent_branch_id]} :- BranchCreateRequest]
|
||||
(when parent_branch_id
|
||||
;; Validate parent branch exists
|
||||
(when-not (t2/exists? :model/Branch :id parent_branch_id)
|
||||
(throw (ex-info (tru "Parent branch does not exist.") {:status-code 400}))))
|
||||
|
||||
;; Create the branch
|
||||
(let [branch-data (cond-> {:name name
|
||||
:creator_id api/*current-user-id*}
|
||||
description (assoc :description description)
|
||||
parent_branch_id (assoc :parent_branch_id parent_branch_id))
|
||||
new-id (t2/insert-returning-pk! :model/Branch branch-data)]
|
||||
(-> (t2/select-one :model/Branch :id new-id)
|
||||
(select-keys [:id :name :slug :description :creator_id :parent_branch_id :created_at :updated_at]))))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id"
|
||||
"Delete a branch by ID. Open access for authenticated users."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
|
||||
(let [branch (t2/select-one :model/Branch :id id)]
|
||||
(when-not branch
|
||||
(throw (ex-info (tru "Branch not found.") {:status-code 404})))
|
||||
|
||||
;; Check if there are child branches
|
||||
(when (branch/get-has-children? branch)
|
||||
(throw (ex-info (tru "Cannot delete branch with child branches.") {:status-code 400})))
|
||||
|
||||
(t2/delete! :model/Branch :id id)
|
||||
api/generic-204-no-content))
|
||||
|
||||
;;; ------------------------------------------------- Routes -------------------------------------------------
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/branch/` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,14 @@
|
||||
(ns metabase-enterprise.branching.core
|
||||
"Handles the logic for setting the current branch from an HTTP request and supports
|
||||
overriding t2/select to get branch-able models based on the current request."
|
||||
(:require
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[metabase.users.settings :as u.settings]))
|
||||
|
||||
(defenterprise get-current-branch
|
||||
"Get the current branch from the X-Metabase-Branch header."
|
||||
:feature :none
|
||||
[request]
|
||||
(or (get-in request [:headers "X-Metabase-Branch"])
|
||||
(get-in request [:headers "x-metabase-branch"])
|
||||
(u.settings/git-branch)))
|
||||
@@ -0,0 +1,22 @@
|
||||
(ns metabase-enterprise.branching.hook
|
||||
"Defines a toucan pipeline step hook that substitutes out tables that can be branched
|
||||
with CTEs that reflect their current state when branched."
|
||||
(:require
|
||||
[metabase-enterprise.branching.impl :as b.impl]
|
||||
[metabase.branching.core :as branching]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.pipeline :as pipeline]))
|
||||
|
||||
(methodical/defmethod pipeline/build [#_query-type :default
|
||||
#_model :default
|
||||
#_resolved-query :default]
|
||||
[query-type model parsed-args resolved-query]
|
||||
(let [built (next-method query-type model parsed-args resolved-query)]
|
||||
(if (and branching/*enable-branch-hook*
|
||||
(not= query-type :toucan2.tools.before-update/select-for-before-update)
|
||||
(map? built)
|
||||
(not= model :model/Branch)
|
||||
(or (contains? built :select) (contains? built :union) (contains? built :union-all))
|
||||
(not= (:from built) [[:information_schema.columns]]))
|
||||
(b.impl/replace built (keys @b.impl/branchables))
|
||||
built)))
|
||||
@@ -0,0 +1,81 @@
|
||||
(ns metabase-enterprise.branching.impl
|
||||
"Implementation for creating the branching CTE and updating and query to use it."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.walk :as walk]
|
||||
[medley.core :as m]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.branching.core :as branching]
|
||||
[metabase.models.resolution :as models.resolution]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def branchables
|
||||
"Map of tables derving from :hook/branchable -> their columns."
|
||||
(delay
|
||||
(reduce-kv (fn [acc k _]
|
||||
(cond-> acc
|
||||
(isa? k :hook/branchable) (assoc (t2/table-name k) (map :column_name
|
||||
(t2/query {:select [:column_name]
|
||||
:from [[:information_schema.columns]]
|
||||
:where [:and [:= :table_name (case (mdb/db-type)
|
||||
:h2 (u/upper-case-en (name (t2/table-name k)))
|
||||
(name (t2/table-name k)))]]})))))
|
||||
{}
|
||||
models.resolution/model->namespace)))
|
||||
|
||||
(defn- branched-cte
|
||||
[table]
|
||||
(let [current-branch-id (:id @branching/*current-branch*)]
|
||||
{:select (into [[[:coalesce :bm.original_id :branched.id] :id]]
|
||||
(for [col-name (table @branchables)
|
||||
:when (not (contains? #{"id" "ID"} col-name))]
|
||||
(keyword (str "branched." col-name))))
|
||||
:from [[table :branched]]
|
||||
:left-join [[:branch_model_mapping :bm]
|
||||
[:and
|
||||
[:= :bm.branched_model_id :branched.id]
|
||||
[:= :bm.model_type [:inline (name table)]]]]
|
||||
:where [:or
|
||||
[:and [:is-not :bm.branched_model_id nil]
|
||||
[:= :bm.branch_id current-branch-id]]
|
||||
[:and
|
||||
[:is :bm.branched_model_id nil]
|
||||
[:not-exists
|
||||
{:select [1]
|
||||
:from [[:branch_model_mapping :bm2]]
|
||||
:where [:and
|
||||
[:= :bm2.branch_id current-branch-id]
|
||||
[:= :bm2.original_id :branched.id]
|
||||
[:= :bm2.model_type [:inline (name table)]]]}]]]}))
|
||||
|
||||
(defn replace
|
||||
"Substitute out branchable tables in a query with their replacements."
|
||||
[query tables]
|
||||
(let [with (some->> query :with (m/index-by first))
|
||||
tables* (remove #(contains? with (keyword (str "branched_" (name %)))) tables)
|
||||
tables->cte-refs (zipmap tables* (map #(keyword (str "branched_" (name %))) tables*))
|
||||
str-tables->cte-refs (reduce-kv #(assoc %1 (name %2) (name %3)) {} tables->cte-refs)
|
||||
replaced (atom #{})]
|
||||
(letfn [(walker [node]
|
||||
(cond
|
||||
(not (keyword? node)) node
|
||||
(contains? tables->cte-refs node)
|
||||
(do (swap! replaced conj node)
|
||||
(node tables->cte-refs))
|
||||
|
||||
(contains? str-tables->cte-refs (namespace node))
|
||||
(keyword (get str-tables->cte-refs (namespace node)) (name node))
|
||||
|
||||
(some #(str/starts-with? (name node) (str % ".")) (keys str-tables->cte-refs))
|
||||
(let [[table col] (str/split (name node) #"\." 2)]
|
||||
(keyword (str (get str-tables->cte-refs table) "." col)))
|
||||
|
||||
:else node))]
|
||||
(if (empty? tables*)
|
||||
query
|
||||
(let [walked (walk/postwalk walker query)]
|
||||
(reduce (fn [query* table]
|
||||
(update query* :with conj [(table tables->cte-refs) (branched-cte table)]))
|
||||
walked
|
||||
@replaced))))))
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.branching.init
|
||||
(:require
|
||||
[metabase-enterprise.branching.hook]))
|
||||
@@ -0,0 +1,63 @@
|
||||
(ns metabase-enterprise.branching.models.branch
|
||||
(:require
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Branch
|
||||
[_model] :branch)
|
||||
|
||||
(doto :model/Branch
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(t2/define-before-insert :model/Branch
|
||||
[branch]
|
||||
(assoc branch :slug (u/slugify (:name branch))))
|
||||
|
||||
;;; ------------------------------------------------- Helper Functions -------------------------------------------------
|
||||
|
||||
(mu/defn get-parent :- [:maybe (ms/InstanceOf :model/Branch)]
|
||||
"Get the parent branch of the given branch, if it exists."
|
||||
[branch :- (ms/InstanceOf :model/Branch)]
|
||||
(when-let [parent-id (:parent_branch_id branch)]
|
||||
(t2/select-one :model/Branch :id parent-id)))
|
||||
|
||||
(mu/defn get-children :- [:set (ms/InstanceOf :model/Branch)]
|
||||
"Get all direct child branches of the given branch."
|
||||
[branch :- (ms/InstanceOf :model/Branch)]
|
||||
(set (t2/select :model/Branch :parent_branch_id (:id branch))))
|
||||
|
||||
(mu/defn get-has-children? :- :boolean
|
||||
"Check if the given branch has any child branches."
|
||||
[branch :- (ms/InstanceOf :model/Branch)]
|
||||
(t2/exists? :model/Branch :parent_branch_id (:id branch)))
|
||||
|
||||
(mu/defn branches-by-parent-id :- [:set (ms/InstanceOf :model/Branch)]
|
||||
"Get all branches with the specified parent_branch_id. If parent-id is nil,
|
||||
returns root-level branches (those with no parent)."
|
||||
[parent-id :- [:maybe ms/PositiveInt]]
|
||||
(set (t2/select :model/Branch :parent_branch_id parent-id)))
|
||||
|
||||
;;; ------------------------------------------------- Hydration Methods -------------------------------------------------
|
||||
|
||||
(mi/define-simple-hydration-method parent
|
||||
:parent
|
||||
"Hydrate a branch with its parent branch."
|
||||
[branch]
|
||||
(get-parent branch))
|
||||
|
||||
(mi/define-simple-hydration-method children
|
||||
:children
|
||||
"Hydrate a branch with its direct child branches."
|
||||
[branch]
|
||||
(get-children branch))
|
||||
|
||||
(mi/define-simple-hydration-method has-children?
|
||||
:has_children
|
||||
"Hydrate a branch with a boolean indicating whether it has child branches."
|
||||
[branch]
|
||||
(get-has-children? branch))
|
||||
@@ -0,0 +1,31 @@
|
||||
(ns metabase-enterprise.branching.models.branch-model-mapping
|
||||
(:require
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/BranchModelMapping
|
||||
[_model] :branch_model_mapping)
|
||||
|
||||
(doto :model/Branch
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(defn resolve-branched-id
|
||||
"Given an original model ID and branch, return the branched ID if it exists"
|
||||
[model-type original-id branch-id]
|
||||
(or (t2/select-one-fn :branched_model_id :model/BranchModelMapping
|
||||
:original_id original-id
|
||||
:model_type (name model-type)
|
||||
:branch_id branch-id)
|
||||
original-id))
|
||||
|
||||
(defn batch-resolve-branched-ids
|
||||
"Efficiently resolve multiple IDs for a branch"
|
||||
[model-type original-ids branch-id]
|
||||
(when (seq original-ids)
|
||||
(let [mappings (t2/select :model/BranchModelMapping
|
||||
:model_type (name model-type)
|
||||
:original_id [:in original-ids]
|
||||
:branch_id branch-id)
|
||||
mapping-map (into {} (map (juxt :original_id :branched_model_id)) mappings)]
|
||||
(map #(get mapping-map % %) original-ids))))
|
||||
@@ -0,0 +1,2 @@
|
||||
(ns metabase-enterprise.branching.settings
|
||||
"Settings definitions that control whether branching is on and how it is implemented.")
|
||||
@@ -3,6 +3,7 @@
|
||||
(:require
|
||||
[clojure.data.csv :as csv]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.util :as u]
|
||||
@@ -51,7 +52,12 @@
|
||||
(defn- format-row
|
||||
"Formats a row to be inserted into the content translation table. Locales are standardized, and all fields are trimmed. Extra fields are included as well."
|
||||
[headers row]
|
||||
(let [{locale :language msgid :string msgstr :translation} (zipmap headers row)
|
||||
(let [row-with-headers (zipmap headers row)
|
||||
locale (or (get row-with-headers "language")
|
||||
(get row-with-headers "locale")
|
||||
(get row-with-headers "locale code"))
|
||||
msgid (get row-with-headers "string")
|
||||
msgstr (get row-with-headers "translation")
|
||||
normalized-locale (i18n/normalized-locale-string (str/trim locale))
|
||||
formatted-locale (if (nil? normalized-locale)
|
||||
(str/trim locale)
|
||||
@@ -72,20 +78,33 @@
|
||||
(adjust-index index)))
|
||||
|
||||
(defn- header?
|
||||
"Checks if a row is likely a header by seeing if it has the set of values we expect in the header"
|
||||
[row]
|
||||
(and (vector? row)
|
||||
(= #{"language" "string" "translation"} (->> row
|
||||
(map u/lower-case-en)
|
||||
set))))
|
||||
"Checks if a row is likely a header by seeing if has a subset of the values we expect the header
|
||||
to have.
|
||||
|
||||
(def ^:private default-headers [:language :string :translation])
|
||||
We can only ~kinda~ guess when a row is header, but since it unlikely for two or more of these
|
||||
values to ever appear in a translation row we can say this is a header. "
|
||||
[row]
|
||||
(let [normalized-values (->> row
|
||||
(map u/lower-case-en)
|
||||
set)]
|
||||
(>= (count (set/intersection #{"language" "locale code" "locale" "string" "translation"} normalized-values))
|
||||
2)))
|
||||
|
||||
(def ^:private default-headers ["language" "string" "translation"])
|
||||
|
||||
(defn- parse-header
|
||||
[header-row]
|
||||
(->> (map u/lower-case-en header-row)
|
||||
(map keyword)
|
||||
vec))
|
||||
(let [parsed (mapv u/lower-case-en header-row)
|
||||
errors (cond-> []
|
||||
(not= 3 (count parsed)) (conj "Header must have only three columns")
|
||||
(empty? (filter #(contains? #{"language" "locale" "locale code"} %) parsed)) (conj "Header must have one of: Language, Locale, or Locale Code")
|
||||
(nil? (some #{"string"} parsed)) (conj "Header must have String")
|
||||
(nil? (some #{"translation"} parsed)) (conj "Header must have translation"))]
|
||||
(when (seq errors)
|
||||
(throw (ex-info "This file could not be uploaded due to the following error(s):"
|
||||
{:status-code http-status-unprocessable
|
||||
:errors errors})))
|
||||
parsed))
|
||||
|
||||
(defn- process-rows
|
||||
"Format, validate, and process rows from a CSV. Takes a collection of vectors and returns a map with the shape
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
translations (if (empty? translations)
|
||||
sample-translations
|
||||
translations)
|
||||
csv-data (cons ["Language" "String" "Translation"]
|
||||
csv-data (cons ["Locale Code" "String" "Translation"]
|
||||
(map (fn [{:keys [locale msgid msgstr]}]
|
||||
[locale msgid msgstr])
|
||||
translations))]
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
[metabase-enterprise.action-v2.init]
|
||||
[metabase-enterprise.advanced-config.init]
|
||||
[metabase-enterprise.audit-app.init]
|
||||
[metabase-enterprise.branching.init]
|
||||
[metabase-enterprise.cache.init]
|
||||
[metabase-enterprise.database-replication.init]
|
||||
[metabase-enterprise.documents.init]
|
||||
[metabase-enterprise.git-source-of-truth.init]
|
||||
[metabase-enterprise.gsheets.init]
|
||||
[metabase-enterprise.llm.init]
|
||||
[metabase-enterprise.metabot-v3.init]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.api
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.git-source-of-truth.config :as config]
|
||||
[metabase-enterprise.git-source-of-truth.git :as git]
|
||||
[metabase-enterprise.serialization.cmd :as serdes-cmd]
|
||||
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(defn- load-from-git-repository!
|
||||
"Load Metabase configuration from git repository using serdes."
|
||||
[repo-dir]
|
||||
(let [serdes-path (git/get-serdes-path (.toString repo-dir))]
|
||||
(log/infof "Loading Metabase configuration from serdes path: %s" serdes-path)
|
||||
|
||||
(try
|
||||
(serdes-cmd/v2-load-internal! serdes-path {}
|
||||
:require-initialized-db? false)
|
||||
(log/info "Successfully loaded configuration from git repository")
|
||||
(catch Exception e
|
||||
(log/error e "Failed to load configuration from git repository")
|
||||
(throw e)))))
|
||||
|
||||
(defn- reload-from-git!
|
||||
"Reloads the Metabase entities from the library"
|
||||
[]
|
||||
(if (config/enabled?)
|
||||
(do
|
||||
(log/info "Reloading Metabase configuration from git repository")
|
||||
|
||||
(git/with-temp-directory repo-dir
|
||||
(try
|
||||
;; Validate configuration first
|
||||
(config/validate-config!)
|
||||
(log/info "Git source of truth configuration validated successfully")
|
||||
|
||||
;; Clone the repository (this will get the latest version)
|
||||
(git/clone-repository! repo-dir)
|
||||
|
||||
;; Load configuration using serdes
|
||||
(load-from-git-repository! repo-dir)
|
||||
|
||||
(log/info "Successfully reloaded entities from git repository")
|
||||
{:status :success
|
||||
:message "Successfully reloaded from git repository"}
|
||||
|
||||
(catch Exception e
|
||||
(log/errorf e "Failed to reload from git repository: %s" (.getMessage e))
|
||||
(let [error-msg (cond
|
||||
(instance? java.net.UnknownHostException e)
|
||||
"Network error: Unable to reach git repository host"
|
||||
|
||||
(str/includes? (.getMessage e) "Authentication failed")
|
||||
"Authentication failed: Please check your git credentials"
|
||||
|
||||
(str/includes? (.getMessage e) "Repository not found")
|
||||
"Repository not found: Please check the repository URL"
|
||||
|
||||
(str/includes? (.getMessage e) "branch")
|
||||
"Branch error: Please check the specified branch exists"
|
||||
|
||||
:else
|
||||
(format "Failed to reload from git repository: %s" (.getMessage e)))]
|
||||
{:status :error
|
||||
:message error-msg
|
||||
:details {:error-type (type e)
|
||||
:repo-url (config/git-source-repo-url)
|
||||
:branch (config/git-source-branch)}})))))
|
||||
|
||||
{:status :error
|
||||
:message "Git source of truth is not enabled. Please configure MB_GIT_SOURCE_REPO_URL environment variable."}))
|
||||
|
||||
(api.macros/defendpoint :post "/reload"
|
||||
"Reload Metabase content from Git repository source of truth.
|
||||
|
||||
This endpoint will:
|
||||
1. Fetch the latest changes from the configured git repository
|
||||
2. Load the updated content using the serialization/deserialization system
|
||||
3. Ensure read-only mode remains enabled
|
||||
|
||||
Requires superuser permissions."
|
||||
[]
|
||||
(api/check-superuser)
|
||||
(log/info "API request to reload from git repository")
|
||||
|
||||
(let [result (reload-from-git!)]
|
||||
(case (:status result)
|
||||
:success
|
||||
{:status 200
|
||||
:body {:status "success"
|
||||
:message (:message result)}}
|
||||
|
||||
:error
|
||||
{:status 400
|
||||
:body {:status "error"
|
||||
:message (:message result)}}
|
||||
|
||||
;; Fallback for unexpected result format
|
||||
{:status 500
|
||||
:body {:status "error"
|
||||
:message "Unexpected error occurred during reload"}})))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/git-source-of-truth` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,63 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.config
|
||||
(:require
|
||||
[clojure.string]
|
||||
[metabase.settings.core :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
|
||||
(defsetting git-source-repo-url
|
||||
(deferred-tru "Git repository URL to use as the source of truth for library managed entities.")
|
||||
:visibility :admin
|
||||
:export? true
|
||||
:encryption :no)
|
||||
|
||||
(defsetting git-source-branch
|
||||
(deferred-tru "Git branch to use for the source of truth for library")
|
||||
:visibility :admin
|
||||
:export? true
|
||||
:encryption :no
|
||||
:default "main")
|
||||
|
||||
(defsetting git-source-path
|
||||
(deferred-tru "Path in the repository to use as the base of the source of truth.")
|
||||
:visibility :admin
|
||||
:export? true
|
||||
:encryption :no
|
||||
:default ".")
|
||||
|
||||
(defn enabled?
|
||||
"Check if git source of truth is enabled"
|
||||
[]
|
||||
(some? (git-source-repo-url)))
|
||||
|
||||
(defn validate-config!
|
||||
"Validate the git source of truth configuration. Throws an exception if invalid."
|
||||
[]
|
||||
(when (enabled?)
|
||||
(let [repo-url (git-source-repo-url)
|
||||
branch (git-source-branch)
|
||||
path (git-source-path)]
|
||||
|
||||
(when (empty? repo-url)
|
||||
(throw (ex-info "Git repository URL is required but empty"
|
||||
{:config-key :mb-git-source-repo-url})))
|
||||
|
||||
(when (empty? branch)
|
||||
(throw (ex-info "Git branch is required but empty"
|
||||
{:config-key :mb-git-source-branch})))
|
||||
|
||||
(when (empty? path)
|
||||
(throw (ex-info "Git source path is required but empty"
|
||||
{:config-key :mb-git-source-path})))
|
||||
|
||||
;; Validate branch name (basic check)
|
||||
(when-not (re-matches #"^[a-zA-Z0-9/_-]+$" branch)
|
||||
(throw (ex-info (format "Invalid git branch name: %s" branch)
|
||||
{:config-key :mb-git-source-branch
|
||||
:branch branch})))
|
||||
|
||||
;; Validate path (basic check)
|
||||
(when (or (clojure.string/includes? path "..")
|
||||
(clojure.string/starts-with? path "/"))
|
||||
(throw (ex-info (format "Invalid git source path (must be relative and not contain '..'): %s" path)
|
||||
{:config-key :mb-git-source-path
|
||||
:path path}))))))
|
||||
@@ -0,0 +1,7 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.core
|
||||
(:require
|
||||
[metabase-enterprise.serialization.cmd :as serdes-cmd]
|
||||
[metabase.cloud-migration.core :as cloud-migration]
|
||||
[metabase.util.log :as log]
|
||||
[potemkin :as p]))
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.git
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.java.shell :as shell]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.git-source-of-truth.config :as config]
|
||||
[metabase.util.log :as log])
|
||||
(:import
|
||||
(java.io File)
|
||||
(java.nio.file Files)
|
||||
(java.nio.file.attribute FileAttribute)))
|
||||
|
||||
(defn cleanup-temp-directory!
|
||||
"Clean up temporary directory and all its contents."
|
||||
[temp-dir]
|
||||
(when temp-dir
|
||||
(try
|
||||
(let [dir-file (io/file temp-dir)]
|
||||
(when (.exists ^File dir-file)
|
||||
(log/debugf "Cleaning up temporary directory: %s" temp-dir)
|
||||
|
||||
(letfn [(delete-file [file]
|
||||
(when (.isDirectory file)
|
||||
(doseq [child (.listFiles file)]
|
||||
(delete-file child)))
|
||||
(.delete file))]
|
||||
(delete-file dir-file))
|
||||
|
||||
(log/debugf "Successfully cleaned up temporary directory: %s" temp-dir)))
|
||||
(catch Exception e
|
||||
(log/warnf e "Failed to clean up temporary directory: %s" temp-dir)))))
|
||||
|
||||
(defmacro with-temp-directory
|
||||
"Makes a temp dir and then deletes it outside the form."
|
||||
[binding & body]
|
||||
`(let [~binding (.toFile (Files/createTempDirectory "git-source-of-truth-" (into-array FileAttribute [])))]
|
||||
(try ~@body
|
||||
(finally
|
||||
(cleanup-temp-directory! ~binding)))))
|
||||
|
||||
(defn- execute-git-command
|
||||
"Execute a git command in the specified directory. Returns {:exit-code :stdout :stderr}."
|
||||
[directory & args]
|
||||
(let [result (apply shell/sh "git" (concat args [:dir directory]))]
|
||||
(log/debugf "Git command: git %s (exit: %d)" (str/join " " args) (:exit result))
|
||||
(when-not (zero? (:exit result))
|
||||
(log/errorf "Git command failed: %s" (:err result)))
|
||||
result))
|
||||
|
||||
(defn clone-repository!
|
||||
"Clone git repository to a temporary directory. Returns the path to the cloned repository."
|
||||
[dir]
|
||||
(let [repo-url (config/git-source-repo-url)
|
||||
branch (config/git-source-branch)
|
||||
result (execute-git-command dir "clone" "--single-branch" "--branch" branch repo-url ".")]
|
||||
(if (zero? (:exit result))
|
||||
(log/infof "Successfully cloned repository to %s" dir)
|
||||
(throw (ex-info (format "Failed to clone git repository: %s" (:err result))
|
||||
{:repo-url repo-url
|
||||
:branch branch
|
||||
:exit-code (:exit result)
|
||||
:dir dir
|
||||
:stderr (:err result)})))))
|
||||
|
||||
(defn get-serdes-path
|
||||
"Get the full path to the serdes files within the repository."
|
||||
[repo-dir]
|
||||
(let [config-path (config/git-source-path)
|
||||
full-path (if (= config-path ".")
|
||||
repo-dir
|
||||
(str repo-dir File/separator config-path))]
|
||||
|
||||
(when-not (.exists (io/file full-path))
|
||||
(throw (ex-info (format "Serdes path does not exist: %s" full-path)
|
||||
{:repo-dir repo-dir
|
||||
:config-path config-path
|
||||
:full-path full-path})))
|
||||
|
||||
full-path))
|
||||
|
||||
(defn validate-repository-access!
|
||||
"Validate that the configured git repository is accessible."
|
||||
[]
|
||||
(let [repo-url (config/git-source-repo-url)
|
||||
branch (config/git-source-branch)]
|
||||
|
||||
(log/infof "Validating access to git repository %s (branch: %s)" repo-url branch)
|
||||
|
||||
(with-temp-directory temp-dir
|
||||
(let [result (execute-git-command temp-dir "ls-remote" "--heads" repo-url branch)]
|
||||
|
||||
(if (zero? (:exit result))
|
||||
(log/info "Git repository is accessible")
|
||||
(throw (ex-info (format "Git repository is not accessible: %s" (:err result))
|
||||
{:repo-url repo-url
|
||||
:branch branch
|
||||
:exit-code (:exit result)
|
||||
:stderr (:err result)})))))))
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.init
|
||||
(:require
|
||||
[metabase-enterprise.git-source-of-truth.settings]))
|
||||
@@ -0,0 +1,44 @@
|
||||
(ns metabase-enterprise.git-source-of-truth.settings
|
||||
(:require
|
||||
[metabase.settings.core :as setting :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
|
||||
(defsetting git-sync-key
|
||||
(deferred-tru "An RSA key with write permissions to the git repository.")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:doc true
|
||||
:export? false
|
||||
:encryption :when-encryption-key-set
|
||||
:audit :getter)
|
||||
|
||||
(defsetting git-sync-url
|
||||
(deferred-tru "The location of your git repository, e.g. https://github.com/acme-inco/metabase.git")
|
||||
:type :string
|
||||
:visibility :admin
|
||||
:encryption :no
|
||||
:export? false)
|
||||
|
||||
(defsetting git-sync-default-branch
|
||||
(deferred-tru "The default branch for git sync.")
|
||||
:type :string
|
||||
:visibility :authenticated
|
||||
:export? false
|
||||
:encryption :no
|
||||
:default nil)
|
||||
|
||||
;; TODO: flip this to true if we have successfully pulled from the repo
|
||||
(defsetting git-sync-configured
|
||||
(deferred-tru "Whether git sync is configured.")
|
||||
:type :boolean
|
||||
:visibility :authenticated
|
||||
:export? false
|
||||
:encryption :no
|
||||
:default false)
|
||||
|
||||
(defsetting git-sync-entities
|
||||
(deferred-tru "A map of which entities are synced to git.")
|
||||
:type :json
|
||||
:visibility :authenticated
|
||||
:encryption :no
|
||||
:export? false)
|
||||
@@ -648,7 +648,7 @@
|
||||
:regular-docs-count (count regular-docs)
|
||||
:time-ms time-ms})
|
||||
|
||||
(analytics/inc! :metabase-search/permission-filtering-ms time-ms)
|
||||
(analytics/inc! :metabase-search/semantic-permission-filter-ms time-ms)
|
||||
|
||||
result))
|
||||
|
||||
@@ -743,12 +743,14 @@
|
||||
(if (or (nil? filter-type) (= filter-type "all"))
|
||||
docs
|
||||
(let [timer (u/start-timer)
|
||||
filtered-docs (filter-by-collection docs search-context)]
|
||||
filtered-docs (filter-by-collection docs search-context)
|
||||
time-ms (u/since-ms timer)]
|
||||
(log/debug "Collection filter" {:filter filter-type
|
||||
:before (count docs)
|
||||
:after (count filtered-docs)
|
||||
:dropped (- (count docs) (count filtered-docs))
|
||||
:time_ms (u/since-ms timer)})
|
||||
:time_ms time-ms})
|
||||
(analytics/inc! :metabase-search/semantic-collection-filter-ms time-ms)
|
||||
filtered-docs))))
|
||||
|
||||
(defn query-index
|
||||
@@ -774,35 +776,45 @@
|
||||
raw-results (into [] xform reducible)
|
||||
db-query-time-ms (u/since-ms db-timer)
|
||||
|
||||
filter-timer (u/start-timer)
|
||||
filtered-results (->> raw-results
|
||||
filter-read-permitted
|
||||
(apply-collection-filter search-context)
|
||||
(mapv search/collapse-id))
|
||||
filter-time-ms (u/since-ms filter-timer)
|
||||
|
||||
appdb-scores-timer (u/start-timer)
|
||||
final-results (->> filtered-results
|
||||
(scoring/with-appdb-scores search-context))
|
||||
appdb-scores-time-ms (u/since-ms appdb-scores-timer)
|
||||
total-time-ms (u/since-ms timer)]
|
||||
|
||||
(log/debug "Semantic search"
|
||||
{:search-string-length (count search-string)
|
||||
:raw-results-count (count raw-results)
|
||||
:final-results-count (count final-results)
|
||||
:embedding-time-ms embedding-time-ms
|
||||
:db-query-time-ms db-query-time-ms
|
||||
:raw-results-count (count raw-results)
|
||||
:final-results-count (count filtered-results)
|
||||
:filter-time-ms filter-time-ms
|
||||
:appdb-scores-time-ms appdb-scores-time-ms
|
||||
:total-time-ms total-time-ms})
|
||||
|
||||
(analytics/inc! :metabase-search/semantic-search-ms
|
||||
{:embedding-model (:name embedding-model)}
|
||||
total-time-ms)
|
||||
(analytics/inc! :metabase-search/semantic-embedding-ms
|
||||
{:embedding-model (:name embedding-model)}
|
||||
embedding-time-ms)
|
||||
(analytics/inc! :metabase-search/semantic-db-query-ms
|
||||
{:embedding-model (:name embedding-model)}
|
||||
db-query-time-ms)
|
||||
(analytics/inc! :metabase-search/semantic-appdb-scores-ms
|
||||
appdb-scores-time-ms)
|
||||
(analytics/inc! :metabase-search/semantic-search-ms
|
||||
{:embedding-model (:name embedding-model)}
|
||||
total-time-ms)
|
||||
|
||||
(comment
|
||||
(jdbc/execute! db (sql-format-quoted query)))
|
||||
|
||||
{:results filtered-results
|
||||
{:results final-results
|
||||
:raw-count (count raw-results)}))))
|
||||
|
||||
(comment
|
||||
|
||||
@@ -3,14 +3,22 @@
|
||||
[clojure.core.memoize :as memoize]
|
||||
[honey.sql :as sql]
|
||||
[honey.sql.helpers :as sql.helpers]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.semantic-search.db.datasource :as semantic.db.datasource]
|
||||
[metabase.activity-feed.core :as activity-feed]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.search.config :as search.config]
|
||||
[metabase.search.scoring :as search.scoring]
|
||||
[metabase.util :as u]
|
||||
[next.jdbc :as jdbc]
|
||||
[next.jdbc.result-set :as jdbc.rs]))
|
||||
[next.jdbc.result-set :as jdbc.rs]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;
|
||||
;; index-based scorers: these scorers only rely on columns in the search index in the pgvector db
|
||||
;;
|
||||
|
||||
(defn- view-count-percentile-query
|
||||
[index-table p-value]
|
||||
@@ -133,3 +141,106 @@
|
||||
(def embedding-model ((requiring-resolve 'metabase-enterprise.semantic-search.embedding/get-configured-model)))
|
||||
(def index ((requiring-resolve 'metabase-enterprise.semantic-search.index/default-index) embedding-model))
|
||||
(def index-table (:table-name index)))
|
||||
|
||||
;;
|
||||
;; appdb-based scorers: these scorers rely on tables in the appdb
|
||||
;;
|
||||
|
||||
(defn- search-doc->values
|
||||
[{:keys [id model]}]
|
||||
;; :inline otherwise H2 can't deduce the types from the query params and the id is returned as text.
|
||||
[[:inline id] [:inline model]])
|
||||
|
||||
(defn- user-recency-query
|
||||
[{:keys [current-user-id]} search-results]
|
||||
{:with [[[:search_docs {:columns [:model_id :model]}]
|
||||
{:values (map search-doc->values search-results)}]]
|
||||
:select [[:sd.model_id :id]
|
||||
[:sd.model :model]
|
||||
[(search.scoring/inverse-duration [:max :rv.timestamp] [:now] search.config/stale-time-in-days)
|
||||
:user_recency]]
|
||||
:from [[:search_docs :sd]]
|
||||
:join [[:recent_views :rv]
|
||||
[:and
|
||||
[:= :rv.user_id current-user-id]
|
||||
[:= :rv.model_id :sd.model_id]
|
||||
[:=
|
||||
:rv.model
|
||||
[:case
|
||||
[:in :sd.model [[:inline "dataset"] [:inline "metric"]]]
|
||||
[:inline "card"]
|
||||
:else
|
||||
:sd.model]]]]
|
||||
:group-by [:sd.model_id :sd.model]})
|
||||
|
||||
(defn- execute-user-recency-query!
|
||||
[search-ctx search-results]
|
||||
(t2/query (user-recency-query search-ctx search-results)))
|
||||
|
||||
(defn- update-result-with-user-recency
|
||||
[weight grouped-recency-results search-result]
|
||||
(let [id-model-key ((juxt :id :model) search-result)
|
||||
user-recency-row (get grouped-recency-results id-model-key)
|
||||
user-recency (:user_recency user-recency-row 0)
|
||||
contribution (* weight user-recency)]
|
||||
(-> search-result
|
||||
(update :score + contribution)
|
||||
(update :all-scores conj {:score user-recency
|
||||
:name :user-recency
|
||||
:weight weight
|
||||
:contribution contribution}))))
|
||||
|
||||
(defn- update-results-with-user-recency
|
||||
[search-ctx search-results user-recency-results]
|
||||
(if-not (seq user-recency-results)
|
||||
search-results
|
||||
(map (let [weight (search.config/weight (:context search-ctx) :user-recency)
|
||||
grouped-recency-results (m/index-by (juxt :id :model) user-recency-results)]
|
||||
(partial update-result-with-user-recency weight grouped-recency-results))
|
||||
search-results)))
|
||||
|
||||
(def ^:private recent-views-models
|
||||
(into #{} (map name activity-feed/rv-models)))
|
||||
|
||||
(comment
|
||||
(require '[clojure.set :as set]
|
||||
'[metabase.search.spec :as search.spec])
|
||||
;; #{"segment" "database" "action" "indexed-entity"}
|
||||
(set/difference search.spec/search-models recent-views-models))
|
||||
|
||||
(defn with-appdb-scores
|
||||
"Add appdb-based scores to `search-results` and re-sort the results based on the new combined scores.
|
||||
|
||||
Supported appdb based scorers: `:user-recency` (postgres and H2)
|
||||
|
||||
This will extract required info from `search-results`, make an appdb query to select additional scorers, combine
|
||||
those with the existing `:score` and `:all-scores` in the `search-results`, then re-sort the results by the new
|
||||
combined `:score`."
|
||||
[search-ctx search-results]
|
||||
;; filtered-search-results are the search-results that have models that are tracked in the recent_views table,
|
||||
;; i.e. results that might possibly have user-recency info.
|
||||
(let [filtered-search-results (filter (comp recent-views-models :model) search-results)]
|
||||
(if-not (and (seq filtered-search-results)
|
||||
;; The user-recency-query needs to be modified to work with mysql / mariadb (BOT-360)
|
||||
(#{:postgres :h2} (mdb/db-type)))
|
||||
search-results
|
||||
(->> (execute-user-recency-query! search-ctx filtered-search-results)
|
||||
(update-results-with-user-recency search-ctx search-results)
|
||||
(sort-by :score >)
|
||||
vec))))
|
||||
|
||||
(comment
|
||||
(def search-ctx {:current-user-id 3})
|
||||
(def search-docs (-> (map-indexed
|
||||
(fn [idx doc]
|
||||
(assoc doc :score idx :all-scores []))
|
||||
[{:id 1 :model "dataset"}
|
||||
{:id 1 :model "dashboard"}
|
||||
{:id 2 :model "table"}
|
||||
{:id 2 :model "card"}
|
||||
{:id 4 :model "indexed-entity"}
|
||||
{:id 7 :model "card"}])
|
||||
vec))
|
||||
(with-appdb-scores search-ctx search-docs)
|
||||
(->> (execute-user-recency-query! search-ctx search-docs)
|
||||
(update-results-with-user-recency search-ctx search-docs)))
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.transforms.models.job-run :as transforms.job-run]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(methodical/defmethod t2/table-name :model/TransformJob [_model] :transform_job)
|
||||
|
||||
(doto :model/TransformJob
|
||||
@@ -90,3 +89,36 @@
|
||||
{:job_id job-id
|
||||
:tag_id tag-id
|
||||
:position (get new-positions tag-id)})))))))
|
||||
|
||||
(defn- translated-name-and-description [job]
|
||||
(let [values {"hourly"
|
||||
[(i18n/deferred-trs "Hourly job")
|
||||
(i18n/deferred-trs "Executes transforms tagged with ''hourly'' every hour")]
|
||||
|
||||
"daily"
|
||||
[(i18n/deferred-trs "Daily job")
|
||||
(i18n/deferred-trs "Executes transforms tagged with ''daily'' once per day")]
|
||||
|
||||
"weekly"
|
||||
[(i18n/deferred-trs "Weekly job")
|
||||
(i18n/deferred-trs "Executes transforms tagged with ''weekly'' once per week")]
|
||||
|
||||
"monthly"
|
||||
[(i18n/deferred-trs "Monthly job")
|
||||
(i18n/deferred-trs "Executes transforms tagged with ''monthly'' once per month")]}
|
||||
[name description] (get values (:built_in_type job))]
|
||||
{:name name :description description}))
|
||||
|
||||
(t2/define-after-select :model/TransformJob [job]
|
||||
(if (nil? (:built_in_type job))
|
||||
job
|
||||
(merge job (translated-name-and-description job))))
|
||||
|
||||
(t2/define-before-update :model/TransformJob [job]
|
||||
(if (nil? (:built_in_type job))
|
||||
job
|
||||
(-> (merge (translated-name-and-description job) ;; default translations
|
||||
{:built_in_type nil} ;; never translate again
|
||||
(t2/changes job)) ;; user edits
|
||||
(update :name str) ;; convert deferred to strings
|
||||
(update :description str))))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
(ns metabase-enterprise.transforms.models.transform-tag
|
||||
(:require
|
||||
[metabase.util.i18n :as i18n]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(methodical/defmethod t2/table-name :model/TransformTag [_model] :transform_tag)
|
||||
|
||||
(doto :model/TransformTag
|
||||
@@ -21,3 +20,24 @@
|
||||
"Check if a tag with the given name exists, excluding the specified ID"
|
||||
[tag-name tag-id]
|
||||
(t2/exists? :model/TransformTag :name tag-name :id [:not= tag-id]))
|
||||
|
||||
(defn- translate-name [tag]
|
||||
(let [values {"hourly" (i18n/deferred-trs "hourly")
|
||||
"daily" (i18n/deferred-trs "daily")
|
||||
"weekly" (i18n/deferred-trs "weekly")
|
||||
"monthly" (i18n/deferred-trs "monthly")}
|
||||
name (get values (:built_in_type tag))]
|
||||
{:name name}))
|
||||
|
||||
(t2/define-after-select :model/TransformTag [tag]
|
||||
(if (nil? (:built_in_type tag))
|
||||
tag
|
||||
(merge tag (translate-name tag))))
|
||||
|
||||
(t2/define-before-update :model/TransformTag [tag]
|
||||
(if (nil? (:built_in_type tag))
|
||||
tag
|
||||
(-> (merge (translate-name tag) ;; translated default names
|
||||
{:built_in_type nil} ;; never translate this again
|
||||
(t2/changes tag)) ;; include user edits
|
||||
(update :name str)))) ;; convert deferred to string
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
(ns metabase-enterprise.branching.api-test
|
||||
"Tests for /api/ee/branch/ endpoints"
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.test :refer :all]
|
||||
[metabase.api.response :as api.response]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; ------------------------------------------------- Test Fixtures -------------------------------------------------
|
||||
|
||||
(defn- create-test-branch!
|
||||
"Create a test branch with optional parent."
|
||||
([name] (create-test-branch! name nil))
|
||||
([name parent-id]
|
||||
(let [branch-data (cond-> {:name name
|
||||
:creator_id (mt/user->id :rasta)}
|
||||
parent-id (assoc :parent_branch_id parent-id))]
|
||||
(t2/insert-returning-instance! :model/Branch branch-data))))
|
||||
|
||||
(defn- cleanup-test-branches!
|
||||
"Delete all test branches created during tests."
|
||||
[]
|
||||
(t2/delete! :model/Branch))
|
||||
|
||||
;;; ------------------------------------------------- GET /api/ee/branch/ Tests -------------------------------------------------
|
||||
|
||||
(deftest list-branches-test
|
||||
(testing "GET /api/ee/branch/"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(let [branch1 (create-test-branch! "Main Branch")
|
||||
branch2 (create-test-branch! "Feature Branch" (:id branch1))
|
||||
branch3 (create-test-branch! "Another Root Branch")]
|
||||
|
||||
(testing "Lists all branches without filter"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/branch")]
|
||||
(is (= 3 (count (:data response))))
|
||||
(is (set/subset? #{"Main Branch" "Feature Branch" "Another Root Branch"}
|
||||
(set (map :name (:data response)))))))
|
||||
|
||||
(testing "Filters branches by parent_id"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/branch"
|
||||
{:parent_id (:id branch1)})]
|
||||
(is (= 1 (count (:data response))))
|
||||
(is (= "Feature Branch" (-> response :data first :name)))
|
||||
(is (= (:id branch1) (-> response :data first :parent_branch_id)))))
|
||||
|
||||
(testing "Returns empty list for non-existent parent_id"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/branch"
|
||||
{:parent_id 99999})]
|
||||
(is (= 0 (count (:data response))))))
|
||||
|
||||
(testing "Filters for root branches when parent_id is null"
|
||||
(let [response (mt/user-http-request :rasta :get 200 "ee/branch"
|
||||
{:parent_id nil})]
|
||||
(is (= 2 (count (:data response))))
|
||||
(is (set/subset? #{"Main Branch" "Another Root Branch"}
|
||||
(set (map :name (:data response))))))))))))
|
||||
|
||||
(deftest list-branches-authentication-test
|
||||
(testing "GET /api/ee/branch/ requires authentication"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(is (= (get api.response/response-unauthentic :body)
|
||||
(mt/client :get 401 "ee/branch"))))))
|
||||
|
||||
(deftest list-branches-premium-feature-test
|
||||
(testing "GET /api/ee/branch/ requires branching premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(create-test-branch! "Test Branch")
|
||||
;; Should return 402 Payment Required when premium feature is not enabled
|
||||
(mt/user-http-request :rasta :get 402 "ee/branch")))))
|
||||
|
||||
;;; ------------------------------------------------- GET /api/ee/branch/:id Tests -------------------------------------------------
|
||||
|
||||
(deftest get-branch-test
|
||||
(testing "GET /api/ee/branch/:id"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(let [branch (create-test-branch! "Test Branch")]
|
||||
|
||||
(testing "Returns branch by ID"
|
||||
(let [response (mt/user-http-request :rasta :get 200 (str "ee/branch/" (:id branch)))]
|
||||
(is (= (:id branch) (:id response)))
|
||||
(is (= "Test Branch" (:name response)))
|
||||
(is (= "test-branch" (:slug response)))
|
||||
(is (= (mt/user->id :rasta) (:creator_id response)))
|
||||
(is (nil? (:parent_branch_id response)))
|
||||
(is (contains? response :created_at))
|
||||
(is (contains? response :updated_at))))
|
||||
|
||||
(testing "Returns 404 for non-existent branch ID"
|
||||
(mt/user-http-request :rasta :get 404 "ee/branch/99999")))))))
|
||||
|
||||
(deftest get-branch-authentication-test
|
||||
(testing "GET /api/ee/branch/:id requires authentication"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(is (= (get api.response/response-unauthentic :body)
|
||||
(mt/client :get 401 "ee/branch/1"))))))
|
||||
|
||||
(deftest get-branch-premium-feature-test
|
||||
(testing "GET /api/ee/branch/:id requires branching premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(let [branch (create-test-branch! "Test Branch")]
|
||||
;; Should return 402 Payment Required when premium feature is not enabled
|
||||
(mt/user-http-request :rasta :get 402 (str "ee/branch/" (:id branch))))))))
|
||||
|
||||
;;; ------------------------------------------------- POST /api/ee/branch/ Tests -------------------------------------------------
|
||||
|
||||
(deftest create-branch-test
|
||||
(testing "POST /api/ee/branch/"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
|
||||
(testing "Creates branch with valid data"
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name "New Branch"
|
||||
:description "A test branch"})]
|
||||
(is (number? (:id response)))
|
||||
(is (= "New Branch" (:name response)))
|
||||
(is (= "new-branch" (:slug response)))
|
||||
(is (= "A test branch" (:description response)))
|
||||
(is (= (mt/user->id :rasta) (:creator_id response)))
|
||||
(is (nil? (:parent_branch_id response)))
|
||||
(is (contains? response :created_at))
|
||||
(is (contains? response :updated_at))))
|
||||
|
||||
(testing "Creates child branch with valid parent_branch_id"
|
||||
(let [parent-branch (create-test-branch! "Parent Branch")
|
||||
response (mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name "Child Branch"
|
||||
:parent_branch_id (:id parent-branch)})]
|
||||
(is (= (:id parent-branch) (:parent_branch_id response)))
|
||||
(is (= "Child Branch" (:name response)))))
|
||||
|
||||
(testing "Creates branch without description (optional field)"
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name "Minimal Branch"})]
|
||||
(is (= "Minimal Branch" (:name response)))
|
||||
(is (nil? (:description response)))))))))
|
||||
|
||||
(deftest create-branch-validation-test
|
||||
(testing "POST /api/ee/branch/ input validation"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
|
||||
(testing "Rejects request with missing name"
|
||||
(mt/user-http-request :rasta :post 400 "ee/branch" {}))
|
||||
|
||||
(testing "Rejects request with blank name"
|
||||
(mt/user-http-request :rasta :post 400 "ee/branch" {:name ""}))
|
||||
|
||||
(testing "Rejects request with invalid parent_branch_id"
|
||||
(mt/user-http-request :rasta :post 400 "ee/branch"
|
||||
{:name "Invalid Parent Branch"
|
||||
:parent_branch_id 99999}))
|
||||
|
||||
(testing "Rejects request with non-integer parent_branch_id"
|
||||
(mt/user-http-request :rasta :post 400 "ee/branch"
|
||||
{:name "Invalid Parent Type"
|
||||
:parent_branch_id "not-a-number"}))))))
|
||||
|
||||
(deftest create-branch-authentication-test
|
||||
(testing "POST /api/ee/branch/ requires authentication"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(is (= (get api.response/response-unauthentic :body)
|
||||
(mt/client :post 401 "ee/branch" {:name "Test Branch"}))))))
|
||||
|
||||
(deftest create-branch-premium-feature-test
|
||||
(testing "POST /api/ee/branch/ requires branching premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
;; Should return 402 Payment Required when premium feature is not enabled
|
||||
(mt/user-http-request :rasta :post 402 "ee/branch" {:name "Test Branch"}))))
|
||||
|
||||
;;; ------------------------------------------------- DELETE /api/ee/branch/:id Tests -------------------------------------------------
|
||||
|
||||
(deftest delete-branch-test
|
||||
(testing "DELETE /api/ee/branch/:id"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
|
||||
(testing "Deletes branch without children"
|
||||
(let [branch (create-test-branch! "Branch to Delete")]
|
||||
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" (:id branch)))
|
||||
(is (nil? (t2/select-one :model/Branch :id (:id branch))))))
|
||||
|
||||
(testing "Returns 404 when trying to delete non-existent branch"
|
||||
(mt/user-http-request :rasta :delete 404 "ee/branch/99999"))))))
|
||||
|
||||
(deftest delete-branch-with-children-test
|
||||
(testing "DELETE /api/ee/branch/:id prevents deletion of branch with children"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(let [parent-branch (create-test-branch! "Parent Branch")
|
||||
_child-branch (create-test-branch! "Child Branch" (:id parent-branch))]
|
||||
|
||||
(testing "Returns 400 when trying to delete branch with children"
|
||||
(mt/user-http-request :rasta :delete 400 (str "ee/branch/" (:id parent-branch))))
|
||||
|
||||
(testing "Parent branch still exists after failed deletion attempt"
|
||||
(is (some? (t2/select-one :model/Branch :id (:id parent-branch))))))))))
|
||||
|
||||
(deftest delete-branch-authentication-test
|
||||
(testing "DELETE /api/ee/branch/:id requires authentication"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(is (= (get api.response/response-unauthentic :body)
|
||||
(mt/client :delete 401 "ee/branch/1"))))))
|
||||
|
||||
(comment
|
||||
(deftest delete-branch-premium-feature-test
|
||||
(testing "DELETE /api/ee/branch/:id requires branching premium feature"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(let [branch (create-test-branch! "Test Branch")]
|
||||
;; Should return 402 Payment Required when premium feature is not enabled
|
||||
(mt/user-http-request :rasta :delete 402 (str "ee/branch/" (:id branch)))))))))
|
||||
|
||||
;;; ------------------------------------------------- Edge Cases and Error Handling -------------------------------------------------
|
||||
|
||||
(deftest branch-hierarchy-test
|
||||
(testing "Complex branch hierarchy operations"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(let [root-branch (create-test-branch! "Root")
|
||||
level1-branch (create-test-branch! "Level 1" (:id root-branch))
|
||||
level2-branch (create-test-branch! "Level 2" (:id level1-branch))]
|
||||
|
||||
(testing "Can delete leaf node (deepest branch)"
|
||||
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" (:id level2-branch)))
|
||||
(is (nil? (t2/select-one :model/Branch :id (:id level2-branch)))))
|
||||
|
||||
(testing "Can delete middle node after removing children"
|
||||
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" (:id level1-branch)))
|
||||
(is (nil? (t2/select-one :model/Branch :id (:id level1-branch)))))
|
||||
|
||||
(testing "Can delete root node after removing all children"
|
||||
(mt/user-http-request :rasta :delete 204 (str "ee/branch/" (:id root-branch)))
|
||||
(is (nil? (t2/select-one :model/Branch :id (:id root-branch))))))))))
|
||||
|
||||
(deftest parameter-validation-edge-cases-test
|
||||
(testing "Parameter validation edge cases"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
|
||||
(comment
|
||||
(testing "GET /api/ee/branch/ with invalid parent_id type"
|
||||
;; Should handle gracefully - invalid integer should return 400
|
||||
(mt/user-http-request :rasta :get 400 "ee/branch" {:parent_id "invalid"})))
|
||||
|
||||
(testing "GET /api/ee/branch/:id with invalid ID format"
|
||||
;; Should handle gracefully - invalid integer should return 400
|
||||
(mt/user-http-request :rasta :get 404 "ee/branch/invalid-id"))
|
||||
|
||||
(testing "POST /api/ee/branch/ with extra unexpected fields"
|
||||
;; Should ignore extra fields and create branch successfully
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name "Test Branch"
|
||||
:unexpected_field "should be ignored"})]
|
||||
(is (= "Test Branch" (:name response)))
|
||||
(is (not (contains? response :unexpected_field)))))))))
|
||||
|
||||
(deftest concurrent-operations-test
|
||||
(testing "Concurrent branch operations"
|
||||
(mt/with-premium-features #{:branching}
|
||||
(mt/with-model-cleanup [:model/Branch]
|
||||
(let [parent-branch (create-test-branch! "Parent")
|
||||
child-branch (create-test-branch! "Child" (:id parent-branch))]
|
||||
|
||||
(testing "Multiple child branch creation for same parent"
|
||||
(let [responses (doall
|
||||
(pmap (fn [i]
|
||||
(mt/user-http-request :rasta :post 200 "ee/branch"
|
||||
{:name (str "Concurrent Child " i)
|
||||
:parent_branch_id (:id parent-branch)}))
|
||||
(range 5)))]
|
||||
(is (= 5 (count responses)))
|
||||
(is (every? #(= (:id parent-branch) (:parent_branch_id %)) responses))
|
||||
(is (= 5 (count (distinct (map :id responses)))))))
|
||||
|
||||
(testing "Cannot delete parent while children exist (race condition protection)"
|
||||
(mt/user-http-request :rasta :delete 400 (str "ee/branch/" (:id parent-branch)))))))))
|
||||
@@ -114,6 +114,11 @@
|
||||
(is (thrown-with-msg?
|
||||
Exception #"Row 1.*CSV error.*unexpected character.*X"
|
||||
(dictionary/read-and-import-csv! file)))))
|
||||
(testing "Reads CSV with invalid header name and throws informative error"
|
||||
(let [file (.getBytes "Loca,String,Translation\nes-mx,hello,hola")] ; bad header
|
||||
(is (thrown-with-msg?
|
||||
Exception #"This file could not be uploaded due to the following error\(s\):"
|
||||
(dictionary/read-and-import-csv! file)))))
|
||||
(testing "Reads CSV with invalid data row and throws informative error"
|
||||
(let [file (.getBytes "Language,String,Translation\nde,Title,Titel\nde,Vendor,\"Anbieter\"X")] ; character outside quotation marks
|
||||
(is (thrown-with-msg?
|
||||
@@ -152,24 +157,103 @@
|
||||
(is (some #(and (= (:locale %) "es") (= (:msgid %) "Hello") (= (:msgstr %) "Hola")) translations))
|
||||
(is (some #(and (= (:locale %) "fr") (= (:msgid %) "Goodbye") (= (:msgstr %) "Au revoir")) translations))))))))
|
||||
|
||||
(deftest ^:parallel format-row-test
|
||||
(deftest read-and-import-csv-different-locale-headers-test
|
||||
(ct-utils/with-clean-translations!
|
||||
(testing "CSV with 'locale' header is imported correctly"
|
||||
(mt/with-premium-features #{:content-translation}
|
||||
(let [csv-with-locale "locale,string,translation\nes,Hello,Hola\nfr,Goodbye,Au revoir"
|
||||
file (.getBytes csv-with-locale)]
|
||||
(dictionary/read-and-import-csv! file)
|
||||
(is (= 2 (count-translations)))
|
||||
(let [translations (get-translations)]
|
||||
(is (some #(and (= (:locale %) "es") (= (:msgid %) "Hello") (= (:msgstr %) "Hola")) translations))
|
||||
(is (some #(and (= (:locale %) "fr") (= (:msgid %) "Goodbye") (= (:msgstr %) "Au revoir")) translations))))))
|
||||
|
||||
(testing "CSV with 'locale code' header is imported correctly"
|
||||
(mt/with-premium-features #{:content-translation}
|
||||
(let [csv-with-locale-code "locale code,string,translation\nde,Thank you,Danke\nit,Good morning,Buongiorno"
|
||||
file (.getBytes csv-with-locale-code)]
|
||||
(dictionary/read-and-import-csv! file)
|
||||
(is (= 2 (count-translations)))
|
||||
(let [translations (get-translations)]
|
||||
(is (some #(and (= (:locale %) "de") (= (:msgid %) "Thank you") (= (:msgstr %) "Danke")) translations))
|
||||
(is (some #(and (= (:locale %) "it") (= (:msgid %) "Good morning") (= (:msgstr %) "Buongiorno")) translations))))))
|
||||
|
||||
(testing "CSV with 'language' header is imported correctly"
|
||||
(mt/with-premium-features #{:content-translation}
|
||||
(let [csv-with-language "language,string,translation\npt_BR,Welcome,Bem-vindo\nzh_CN,Hello,你好"
|
||||
file (.getBytes csv-with-language)]
|
||||
(dictionary/read-and-import-csv! file)
|
||||
(is (= 2 (count-translations)))
|
||||
(let [translations (get-translations)]
|
||||
(is (some #(and (= (:locale %) "pt_BR") (= (:msgid %) "Welcome") (= (:msgstr %) "Bem-vindo")) translations))
|
||||
(is (some #(and (= (:locale %) "zh_CN") (= (:msgid %) "Hello") (= (:msgstr %) "你好")) translations))))))))
|
||||
|
||||
(deftest ^:parallel format-row-locale-standardization-test
|
||||
(testing "Format function standardizes locale"
|
||||
(is (=
|
||||
["pt_BR" "msgid" "msgstr"]
|
||||
(#'dictionary/format-row [:language :string :translation] ["pt-br" "msgid" "msgstr"])))
|
||||
(#'dictionary/format-row ["language" "string" "translation"] ["pt-br" "msgid" "msgstr"])))
|
||||
(is (=
|
||||
["pt_BR" "msgid" "msgstr"]
|
||||
(#'dictionary/format-row [:language :string :translation] ["Pt-bR" "msgid" "msgstr"])))
|
||||
(#'dictionary/format-row ["language" "string" "translation"] ["Pt-bR" "msgid" "msgstr"])))
|
||||
(is (=
|
||||
["pt_BR" "msgid" "msgstr"]
|
||||
(#'dictionary/format-row [:language :string :translation] ["pt_br" "msgid" "msgstr"])))
|
||||
(#'dictionary/format-row ["language" "string" "translation"] ["pt_br" "msgid" "msgstr"])))
|
||||
(is (=
|
||||
["zh_CN" "msgid" "msgstr"]
|
||||
(#'dictionary/format-row [:language :string :translation] ["ZH-cn" "msgid" "msgstr"]))))
|
||||
(#'dictionary/format-row ["language" "string" "translation"] ["ZH-cn" "msgid" "msgstr"])))))
|
||||
|
||||
(deftest ^:parallel format-row-trimming-test
|
||||
(testing "Format function trims all fields"
|
||||
(is (=
|
||||
["pt_BR" "msgid" "msgstr"]
|
||||
(#'dictionary/format-row [:language :string :translation] [" pt-BR " "msgid " " msgstr"])))))
|
||||
(#'dictionary/format-row ["language" "string" "translation"] [" pt-BR " "msgid " " msgstr"])))))
|
||||
|
||||
(deftest ^:parallel format-row-header-names-test
|
||||
(testing "Format function works with different locale header names"
|
||||
(is (=
|
||||
["pt_BR" "msgid" "msgstr"]
|
||||
(#'dictionary/format-row ["locale" "string" "translation"] ["pt-br" "msgid" "msgstr"])))
|
||||
(is (=
|
||||
["pt_BR" "msgid" "msgstr"]
|
||||
(#'dictionary/format-row ["locale code" "string" "translation"] ["pt-br" "msgid" "msgstr"])))
|
||||
(is (=
|
||||
["pt_BR" "msgid" "msgstr"]
|
||||
(#'dictionary/format-row ["language" "string" "translation"] ["pt-br" "msgid" "msgstr"])))))
|
||||
|
||||
(deftest ^:parallel format-row-field-order-test
|
||||
(testing "Format function extracts fields in correct order regardless of input order"
|
||||
(is (=
|
||||
["es" "Hello" "Hola"]
|
||||
(#'dictionary/format-row ["language" "string" "translation"] ["es" "Hello" "Hola"])))
|
||||
(is (=
|
||||
["es" "Hello" "Hola"]
|
||||
(#'dictionary/format-row ["string" "language" "translation"] ["Hello" "es" "Hola"])))
|
||||
(is (=
|
||||
["es" "Hello" "Hola"]
|
||||
(#'dictionary/format-row ["translation" "string" "language"] ["Hola" "Hello" "es"])))
|
||||
(is (=
|
||||
["es" "Hello" "Hola"]
|
||||
(#'dictionary/format-row ["string" "translation" "language"] ["Hello" "Hola" "es"])))
|
||||
(is (=
|
||||
["es" "Hello" "Hola"]
|
||||
(#'dictionary/format-row ["language" "translation" "string"] ["es" "Hola" "Hello"])))
|
||||
(is (=
|
||||
["es" "Hello" "Hola"]
|
||||
(#'dictionary/format-row ["translation" "language" "string"] ["Hola" "es" "Hello"])))))
|
||||
|
||||
(deftest ^:parallel format-row-mixed-headers-order-test
|
||||
(testing "Format function works with different locale header names in different orders"
|
||||
(is (=
|
||||
["fr" "Goodbye" "Au revoir"]
|
||||
(#'dictionary/format-row ["locale" "string" "translation"] ["fr" "Goodbye" "Au revoir"])))
|
||||
(is (=
|
||||
["fr" "Goodbye" "Au revoir"]
|
||||
(#'dictionary/format-row ["string" "locale" "translation"] ["Goodbye" "fr" "Au revoir"])))
|
||||
(is (=
|
||||
["fr" "Goodbye" "Au revoir"]
|
||||
(#'dictionary/format-row ["locale code" "translation" "string"] ["fr" "Au revoir" "Goodbye"])))))
|
||||
|
||||
(deftest import-translations-success-test
|
||||
(ct-utils/with-clean-translations!
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
|
||||
(defmethod router-dataset-name :default [_driver] "db-router-data")
|
||||
|
||||
(doseq [driver [:redshift :databricks :athena :presto-jdbc]]
|
||||
(doseq [driver [:redshift :databricks :presto-jdbc]]
|
||||
(defmethod router-dataset-name driver [_driver] "db-routing-data"))
|
||||
|
||||
(defmulti routed-dataset-name
|
||||
@@ -224,7 +224,7 @@
|
||||
|
||||
(defmethod routed-dataset-name :default [_driver] "db-routed-data")
|
||||
|
||||
(doseq [driver [:redshift :databricks :athena :presto-jdbc]]
|
||||
(doseq [driver [:redshift :databricks :presto-jdbc]]
|
||||
(defmethod routed-dataset-name driver [_driver] "db-routing-data"))
|
||||
|
||||
(deftest db-routing-e2e-test
|
||||
@@ -260,3 +260,47 @@
|
||||
(->> (mt/query t)
|
||||
(mt/process-query)
|
||||
(mt/formatted-rows [int str])))))))))))))))))
|
||||
|
||||
(deftest athena-region-bucket-routing-test
|
||||
(mt/test-driver :athena
|
||||
(mt/with-premium-features #{:database-routing}
|
||||
(binding [tx/*use-routing-details* true]
|
||||
(mt/dataset (mt/dataset-definition "db-routing-data"
|
||||
[["t_0"
|
||||
[{:field-name "f", :base-type :type/Text}]
|
||||
[["us-east-2-foo"]
|
||||
["us-east-2-bar"]]]])
|
||||
(let [routed (mt/db)]
|
||||
(binding [tx/*use-routing-details* false]
|
||||
(mt/dataset (mt/dataset-definition "db-routing-data"
|
||||
[["t_0"
|
||||
[{:field-name "f", :base-type :type/Text}]
|
||||
[["us-east-1-foo"]
|
||||
["us-east-1-bar"]]]])
|
||||
(let [router (mt/db)]
|
||||
(t2/update! :model/Database (u/the-id routed)
|
||||
{:details (assoc (:details routed)
|
||||
:dbname nil
|
||||
:s3_staging_dir (tx/db-test-env-var-or-throw :athena :s3-staging-dir-routing)
|
||||
:region (tx/db-test-env-var-or-throw :athena :region-routing))})
|
||||
(t2/update! :model/Database (u/the-id router)
|
||||
{:details (assoc (:details router)
|
||||
:dbname nil)})
|
||||
(let [router (t2/select-one :model/Database :id (u/the-id router))
|
||||
routed (t2/select-one :model/Database :id (u/the-id routed))]
|
||||
(sync/sync-database! router {:scan :schema})
|
||||
(sync/sync-database! routed {:scan :schema})
|
||||
(wire-routing {:parent router :children [routed]})
|
||||
(mt/with-temp [:model/DatabaseRouter _ {:database_id (u/the-id router)
|
||||
:user_attribute "db_name"}]
|
||||
(met/with-user-attributes! :rasta {"db_name" (:name routed)}
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (= [[1 "us-east-1-foo"] [2 "us-east-1-bar"]]
|
||||
(->> (mt/query t_0)
|
||||
(mt/process-query)
|
||||
(mt/formatted-rows [int str])))))
|
||||
(mt/with-current-user (mt/user->id :rasta)
|
||||
(is (= [[1 "us-east-2-foo"] [2 "us-east-2-bar"]]
|
||||
(->> (mt/query t_0)
|
||||
(mt/process-query)
|
||||
(mt/formatted-rows [int str])))))))))))))))))
|
||||
|
||||
@@ -346,7 +346,7 @@
|
||||
(semantic.index/query-index semantic.tu/db semantic.tu/mock-index
|
||||
{:search-string "dog training"}))
|
||||
|
||||
(let [permission-calls (filter #(= :metabase-search/permission-filtering-ms (first %)) @analytics-calls)]
|
||||
(let [permission-calls (filter #(= :metabase-search/semantic-permission-filter-ms (first %)) @analytics-calls)]
|
||||
(is (= 1 (count permission-calls)))
|
||||
(let [time-ms (first (second (first permission-calls)))]
|
||||
(is (number? time-ms))
|
||||
@@ -356,21 +356,29 @@
|
||||
(reset! analytics-calls [])
|
||||
(mt/with-test-user :crowberto
|
||||
(semantic.index/query-index semantic.tu/db semantic.tu/mock-index
|
||||
{:search-string "elephant migration"}))
|
||||
{:search-string "elephant migration"
|
||||
:filter-items-in-personal-collection "only"}))
|
||||
|
||||
(let [metric-names (set (map first @analytics-calls))]
|
||||
(is (contains? metric-names :metabase-search/semantic-search-ms))
|
||||
(is (contains? metric-names :metabase-search/semantic-embedding-ms))
|
||||
(is (contains? metric-names :metabase-search/semantic-db-query-ms))
|
||||
(is (contains? metric-names :metabase-search/permission-filtering-ms)))
|
||||
(is (contains? metric-names :metabase-search/semantic-permission-filter-ms))
|
||||
(is (contains? metric-names :metabase-search/semantic-collection-filter-ms))
|
||||
(is (contains? metric-names :metabase-search/semantic-appdb-scores-ms)))
|
||||
|
||||
(testing "timing values are reasonable"
|
||||
(doseq [[metric args] @analytics-calls
|
||||
:when (#{:metabase-search/semantic-search-ms
|
||||
:metabase-search/semantic-embedding-ms
|
||||
:metabase-search/semantic-db-query-ms
|
||||
:metabase-search/permission-filtering-ms} metric)]
|
||||
(let [time-ms (if (= metric :metabase-search/permission-filtering-ms)
|
||||
:metabase-search/semantic-permission-filter-ms
|
||||
:metabase-search/semantic-collection-filter-ms
|
||||
:metabase-search/semantic-appdb-scores-ms} metric)]
|
||||
(let [time-ms (if (#{:metabase-search/semantic-permission-filter-ms
|
||||
:metabase-search/semantic-collection-filter-ms
|
||||
:metabase-search/semantic-appdb-scores-ms}
|
||||
metric)
|
||||
(first args)
|
||||
(second args))]
|
||||
(is (number? time-ms) (str "Time for " metric " should be numeric"))
|
||||
|
||||
@@ -160,6 +160,37 @@
|
||||
["card" 1 "card ancient"]]
|
||||
(search-results :recency "card"))))))))
|
||||
|
||||
(deftest user-recency-test
|
||||
(let [user-id (mt/user->id :crowberto)
|
||||
right-now (Instant/now)
|
||||
long-ago (.minus right-now 10 ChronoUnit/DAYS)
|
||||
forever-ago (.minus right-now 30 ChronoUnit/DAYS)
|
||||
recent-view (fn [model-id timestamp]
|
||||
{:model "card"
|
||||
:model_id model-id
|
||||
:user_id user-id
|
||||
:timestamp timestamp})]
|
||||
(mt/with-temp [:model/Card {c1 :id} {}
|
||||
:model/Card {c2 :id} {}
|
||||
:model/Card {c3 :id} {}
|
||||
:model/Card {c4 :id} {}
|
||||
:model/RecentViews _ (recent-view c1 forever-ago)
|
||||
:model/RecentViews _ (recent-view c2 right-now)
|
||||
:model/RecentViews _ (recent-view c2 forever-ago)
|
||||
:model/RecentViews _ (recent-view c4 forever-ago)
|
||||
:model/RecentViews _ (recent-view c4 long-ago)]
|
||||
(with-index-contents!
|
||||
[{:model "card" :id c1 :name "card ancient"}
|
||||
{:model "metric" :id c2 :name "card recent"}
|
||||
{:model "dataset" :id c3 :name "card unseen"}
|
||||
{:model "dataset" :id c4 :name "card old"}]
|
||||
(testing "We prefer results more recently viewed by the current user"
|
||||
(is (= [["metric" c2 "card recent"]
|
||||
["dataset" c4 "card old"]
|
||||
["card" c1 "card ancient"]
|
||||
["dataset" c3 "card unseen"]]
|
||||
(search-results :user-recency "card" {:current-user-id user-id}))))))))
|
||||
|
||||
(deftest view-count-test
|
||||
(mt/with-premium-features #{:semantic-search}
|
||||
(testing "the more view count the better"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
(ns metabase-enterprise.transforms.models.transform-job-test
|
||||
"Tests for the transform job model."
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.transforms.models.transform-job :as transform-job]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(comment transform-job/keep-me)
|
||||
|
||||
(def ^:private values {"hourly"
|
||||
{:name (i18n/deferred-trs "Hourly job")
|
||||
:description (i18n/deferred-trs "Executes transforms tagged with ''hourly'' every hour")}
|
||||
|
||||
"daily"
|
||||
{:name (i18n/deferred-trs "Daily job")
|
||||
:description (i18n/deferred-trs "Executes transforms tagged with ''daily'' once per day")}
|
||||
|
||||
"weekly"
|
||||
{:name (i18n/deferred-trs "Weekly job")
|
||||
:description (i18n/deferred-trs "Executes transforms tagged with ''weekly'' once per week")}
|
||||
|
||||
"monthly"
|
||||
{:name (i18n/deferred-trs "Monthly job")
|
||||
:description (i18n/deferred-trs "Executes transforms tagged with ''monthly'' once per month")}})
|
||||
|
||||
(deftest initial-jobs-translated-on-select
|
||||
(doseq [[type translations] values]
|
||||
(mt/with-temp [:model/TransformJob job
|
||||
{:name "default"
|
||||
:description "default description"
|
||||
:schedule "* 0 0 0 0"
|
||||
:built_in_type type}]
|
||||
(is (= (str (:name translations))
|
||||
(str (:name (t2/select-one :model/TransformJob (:id job))))))
|
||||
(is (= (str (:description translations))
|
||||
(str (:description (t2/select-one :model/TransformJob (:id job)))))))))
|
||||
|
||||
(deftest initial-jobs-translated-on-update
|
||||
(testing "Setting name still translates description"
|
||||
(doseq [[type translations] values]
|
||||
(mt/with-temp [:model/TransformJob job
|
||||
{:name "default"
|
||||
:description "default description"
|
||||
:schedule "* 0 0 0 0"
|
||||
:built_in_type type}]
|
||||
(t2/update! :model/TransformJob :id (:id job)
|
||||
{:name "default2"})
|
||||
(is (= "default2"
|
||||
(str (:name (t2/select-one :model/TransformJob (:id job))))))
|
||||
(is (= (str (:description translations))
|
||||
(str (:description (t2/select-one :model/TransformJob (:id job)))))))))
|
||||
(testing "Setting description still translates name"
|
||||
(doseq [[type translations] values]
|
||||
(mt/with-temp [:model/TransformJob job
|
||||
{:name "default"
|
||||
:description "default description"
|
||||
:schedule "* 0 0 0 0"
|
||||
:built_in_type type}]
|
||||
(t2/update! :model/TransformJob :id (:id job)
|
||||
{:description "default description 2"})
|
||||
(is (= "default description 2"
|
||||
(str (:description (t2/select-one :model/TransformJob (:id job))))))
|
||||
(is (= (str (:name translations))
|
||||
(str (:name (t2/select-one :model/TransformJob (:id job)))))))))
|
||||
|
||||
(testing "Setting schedule translates description and name"
|
||||
(doseq [[type translations] values]
|
||||
(mt/with-temp [:model/TransformJob job
|
||||
{:name "default"
|
||||
:description "default description"
|
||||
:schedule "* 0 0 0 0"
|
||||
:built_in_type type}]
|
||||
(t2/update! :model/TransformJob :id (:id job)
|
||||
{:schedule "0 0 0 0 0"})
|
||||
(is (= (str (:description translations))
|
||||
(str (:description (t2/select-one :model/TransformJob (:id job))))))
|
||||
(is (= (str (:name translations))
|
||||
(str (:name (t2/select-one :model/TransformJob (:id job))))))))))
|
||||
@@ -0,0 +1,27 @@
|
||||
(ns metabase-enterprise.transforms.models.transform-tag-test
|
||||
"Tests for the transform tag model."
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.transforms.models.transform-tag :as transform-tag]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(comment transform-tag/keep-me)
|
||||
|
||||
(deftest initial-tags-translated-on-select
|
||||
(doseq [[type name] [["hourly" (i18n/trs "hourly")]
|
||||
["daily" (i18n/trs "daily")]
|
||||
["weekly" (i18n/trs "weekly")]
|
||||
["monthly" (i18n/trs "monthly")]]]
|
||||
(mt/with-temp [:model/TransformTag tag {:name "default" :built_in_type type}]
|
||||
(is (= name
|
||||
(str (:name (t2/select-one :model/TransformTag (:id tag)))))))))
|
||||
|
||||
(deftest initial-tags-translated-on-update
|
||||
(doseq [type ["hourly" "daily" "weekly" "monthly"]]
|
||||
(mt/with-temp [:model/TransformTag tag {:name "default" :built_in_type type}]
|
||||
(t2/update! :model/TransformTag :id (:id tag)
|
||||
{:name "default2"})
|
||||
(is (= "default2"
|
||||
(:name (t2/select-one :model/TransformTag (:id tag))))))))
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"rules": {
|
||||
// Note: adding this rule to a eslint config file in a subfolder will remove
|
||||
// *not* carry over the restricted imports from parent folders, you will
|
||||
// need to copy them over
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["cljs/metabase.lib*"],
|
||||
"paths": [
|
||||
{
|
||||
"name": "@mantine/core",
|
||||
"message": "Please import from `metabase/ui` instead.",
|
||||
},
|
||||
{
|
||||
"name": "moment",
|
||||
"message": "Moment is deprecated, please use dayjs",
|
||||
},
|
||||
{
|
||||
"name": "moment-timezone",
|
||||
"message": "Moment is deprecated, please use dayjs",
|
||||
},
|
||||
{
|
||||
"name": "@storybook/test",
|
||||
"message": "Please use `testing-library/react` or `@testing-library/user-event`",
|
||||
},
|
||||
{
|
||||
"name": "react-redux",
|
||||
"importNames": ["useSelector", "useDispatch", "connect"],
|
||||
"message": "Please import from `metabase/lib/redux` instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"ttag/no-module-declaration": 2,
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.stories.tsx"],
|
||||
"rules": {
|
||||
"import/no-default-export": 0,
|
||||
"no-restricted-imports": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
100
enterprise/frontend/src/.eslintrc.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/* eslint-disable no-undef,import/no-commonjs */
|
||||
const path = require("path");
|
||||
|
||||
const baseRestrictredConfig = {
|
||||
patterns: ["cljs/metabase.lib*"],
|
||||
paths: [
|
||||
{
|
||||
name: "@mantine/core",
|
||||
message: "Please import from `metabase/ui` instead.",
|
||||
},
|
||||
{
|
||||
name: "moment",
|
||||
message: "Moment is deprecated, please use dayjs",
|
||||
},
|
||||
{
|
||||
name: "moment-timezone",
|
||||
message: "Moment is deprecated, please use dayjs",
|
||||
},
|
||||
{
|
||||
name: "@storybook/test",
|
||||
message:
|
||||
"Please use `testing-library/react` or `@testing-library/user-event`",
|
||||
},
|
||||
{
|
||||
name: "react-redux",
|
||||
importNames: ["useSelector", "useDispatch", "connect"],
|
||||
message: 'Please use "useSdkSelector", "useSdkDispatch"',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
webpack: {
|
||||
config: path.resolve(
|
||||
__dirname,
|
||||
"../../../rspack.embedding-sdk-bundle.config.js",
|
||||
),
|
||||
typescript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Note: adding this rule to a eslint config file in a subfolder will remove
|
||||
// *not* carry over the restricted imports from parent folders, you will
|
||||
// need to copy them over
|
||||
"no-restricted-imports": ["error", baseRestrictredConfig],
|
||||
"ttag/no-module-declaration": 2,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["embedding-sdk-{package,bundle,shared}/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
...baseRestrictredConfig,
|
||||
paths: [
|
||||
...baseRestrictredConfig.paths,
|
||||
{
|
||||
name: "metabase/lib/redux",
|
||||
importNames: ["useStore", "useDispatch"],
|
||||
message: 'Please use "useSdkStore", "useSdkDispatch"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.stories.tsx"],
|
||||
rules: {
|
||||
"import/no-default-export": 0,
|
||||
"no-restricted-imports": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["embedding-sdk-{package,shared}/**/*.{ts,tsx,js,jsx}"],
|
||||
excludedFiles: [
|
||||
"embedding-sdk-package/{bin,cli}/**/*.{ts,tsx,js,jsx}",
|
||||
"**/jest/**",
|
||||
"**/test/**",
|
||||
"**/*.spec.{ts,tsx,js,jsx}",
|
||||
"**/*.stories.{ts,tsx,js,jsx}",
|
||||
],
|
||||
rules: {
|
||||
"no-external-references-for-sdk-package-code": [
|
||||
"error",
|
||||
{
|
||||
allowedPaths: [
|
||||
path.join(__dirname, "embedding-sdk-package"),
|
||||
path.join(__dirname, "embedding-sdk-shared"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
/* eslint-disable no-undef,import/no-commonjs */
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
webpack: {
|
||||
config: path.resolve(
|
||||
__dirname,
|
||||
"../../../../rspack.embedding-sdk-bundle.config.js",
|
||||
),
|
||||
typescript: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Note: adding this rule to a eslint config file in a subfolder will remove
|
||||
// *not* carry over the restricted imports from parent folders, you will
|
||||
// need to copy them over
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@mantine/core",
|
||||
message: "Please import from `metabase/ui` instead.",
|
||||
},
|
||||
{
|
||||
name: "moment",
|
||||
message: "Moment is deprecated, please use dayjs",
|
||||
},
|
||||
{
|
||||
name: "moment-timezone",
|
||||
message: "Moment is deprecated, please use dayjs",
|
||||
},
|
||||
{
|
||||
name: "@storybook/test",
|
||||
message:
|
||||
"Please use `testing-library/react` or `@testing-library/user-event`",
|
||||
},
|
||||
{
|
||||
name: "react-redux",
|
||||
importNames: ["useSelector", "useDispatch", "connect"],
|
||||
message: 'Please use "useSdkSelector", "useSdkDispatch"',
|
||||
},
|
||||
{
|
||||
name: "metabase/lib/redux",
|
||||
importNames: ["useStore", "useDispatch"],
|
||||
message: 'Please use "useSdkStore", "useSdkDispatch"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
"sdk-package/**/*.{ts,tsx,js,jsx}",
|
||||
"sdk-shared/**/*.{ts,tsx,js,jsx}",
|
||||
],
|
||||
excludedFiles: [
|
||||
"**/test/**",
|
||||
"**/*.spec.{ts,tsx,js,jsx}",
|
||||
"**/*.stories.{ts,tsx,js,jsx}",
|
||||
],
|
||||
rules: {
|
||||
"no-external-references-for-sdk-package-code": [
|
||||
"error",
|
||||
{
|
||||
allowedPaths: [
|
||||
path.join(__dirname, "sdk-package"),
|
||||
path.join(__dirname, "sdk-shared"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
/* eslint-disable import/order */
|
||||
|
||||
import { EMBEDDING_SDK_CONFIG } from "metabase/embedding-sdk/config";
|
||||
|
||||
// Enable SDK mode as we are in the SDK bundle
|
||||
// This applies to SDK derivatives such as new iframe embedding.
|
||||
EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true;
|
||||
|
||||
// Import the embedding SDK vendors side-effects
|
||||
import "metabase/embedding-sdk/vendors-side-effects";
|
||||
|
||||
// Import the EE plugins required by the embedding sdk.
|
||||
import "sdk-ee-plugins";
|
||||
|
||||
// Imports which are only applicable to the embedding sdk, and not the new iframe embedding.
|
||||
import "sdk-specific-imports";
|
||||
|
||||
import type { MetabaseEmbeddingSdkBundleExports } from "./types/sdk-bundle";
|
||||
|
||||
import { CollectionBrowser } from "./components/public/CollectionBrowser";
|
||||
import { CreateDashboardModal } from "./components/public/CreateDashboardModal";
|
||||
import { CreateQuestion } from "./components/public/CreateQuestion";
|
||||
import { InteractiveQuestion } from "./components/public/InteractiveQuestion";
|
||||
import { ComponentProvider } from "./components/public/ComponentProvider";
|
||||
import { MetabotQuestion } from "./components/public/MetabotQuestion";
|
||||
import { StaticQuestion } from "./components/public/StaticQuestion";
|
||||
import {
|
||||
EditableDashboard,
|
||||
InteractiveDashboard,
|
||||
StaticDashboard,
|
||||
} from "./components/public/dashboard";
|
||||
import { SdkDebugInfo } from "./components/public/debug/SdkDebugInfo";
|
||||
import { getApplicationName } from "metabase/selectors/whitelabel";
|
||||
import { getSdkStore } from "./store/index";
|
||||
import {
|
||||
getAvailableFonts,
|
||||
getLoginStatus,
|
||||
} from "embedding-sdk-bundle/store/selectors";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
import { useInitData } from "./hooks/private/use-init-data";
|
||||
import { useLogVersionInfo } from "embedding-sdk-bundle/hooks/private/use-log-version-info";
|
||||
import { createDashboard } from "embedding-sdk-bundle/lib/create-dashboard";
|
||||
|
||||
/**
|
||||
* IMPORTANT!
|
||||
* Any rename/removal change for object is a breaking change between the SDK Bundle and the SDK NPM package,
|
||||
* and should be done via the deprecation of the field first.
|
||||
*/
|
||||
const sdkBundleExports: MetabaseEmbeddingSdkBundleExports = {
|
||||
CollectionBrowser,
|
||||
CreateDashboardModal,
|
||||
CreateQuestion,
|
||||
EditableDashboard,
|
||||
InteractiveDashboard,
|
||||
InteractiveQuestion,
|
||||
ComponentProvider,
|
||||
MetabotQuestion,
|
||||
SdkDebugInfo,
|
||||
StaticDashboard,
|
||||
StaticQuestion,
|
||||
getSdkStore,
|
||||
createDashboard,
|
||||
getApplicationName,
|
||||
getAvailableFonts,
|
||||
getLoginStatus,
|
||||
getUser,
|
||||
useInitData,
|
||||
useLogVersionInfo,
|
||||
};
|
||||
|
||||
window.METABASE_EMBEDDING_SDK_BUNDLE = sdkBundleExports;
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import { useSdkBreadcrumbs } from "embedding-sdk-bundle/hooks/private/use-sdk-breadcrumb";
|
||||
import { DashboardBackButton } from "metabase/query_builder/components/view/ViewHeader/components";
|
||||
import { QueryBuilderBackButton } from "metabase/query_builder/components/view/ViewHeader/components";
|
||||
import type { ActionIconProps } from "metabase/ui";
|
||||
|
||||
import { useSdkQuestionContext } from "../../context";
|
||||
@@ -32,10 +32,10 @@ export const BackButton = ({ ...actionIconProps }: BackButtonProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardBackButton
|
||||
<QueryBuilderBackButton
|
||||
noLink
|
||||
onClick={onNavigateBack}
|
||||
dashboardOverride={backToDashboard}
|
||||
parentOverride={backToDashboard}
|
||||
{...actionIconProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MouseEventHandler } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { ButtonProps } from "embedding-sdk-bundle/types/ui";
|
||||
import { isQuestionDirty } from "metabase/query_builder/utils/question";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
@@ -29,7 +30,7 @@ export const shouldShowSaveButton = ({
|
||||
}) => {
|
||||
const canSave = question && Lib.canSave(question.query(), question.type());
|
||||
const isQuestionChanged = originalQuestion
|
||||
? question && question.isQueryDirtyComparedTo(originalQuestion)
|
||||
? isQuestionDirty(question, originalQuestion)
|
||||
: true;
|
||||
|
||||
return Boolean(isQuestionChanged && canSave);
|
||||
|
||||
@@ -79,10 +79,11 @@ type SdkQuestionConfig = {
|
||||
* @internal
|
||||
*
|
||||
* When provided, this dashboard will be used to navigate back to the dashboard from other view
|
||||
* instead of the state from Redux in `qb.parentDashboard.dashboardId`
|
||||
* instead of the state from Redux in `qb.parentEntity.dashboardId`
|
||||
*/
|
||||
backToDashboard?: {
|
||||
id: DashboardId;
|
||||
model: "dashboard";
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -164,7 +164,11 @@ export const SdkQuestionDefaultView = ({
|
||||
style={style}
|
||||
>
|
||||
<Stack className={InteractiveQuestionS.TopBar} gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-end"
|
||||
data-testid="interactive-question-top-toolbar"
|
||||
>
|
||||
<Group gap="xs">
|
||||
<Box mr="sm">
|
||||
<BackButton />
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useContext, useId, useMemo } from "react";
|
||||
|
||||
import { DEFAULT_FONT } from "embedding-sdk-bundle/config";
|
||||
import { getEmbeddingThemeOverride } from "embedding-sdk-bundle/lib/theme";
|
||||
import { EnsureSingleInstance } from "embedding-sdk-bundle/sdk-shared/components/EnsureSingleInstance/EnsureSingleInstance";
|
||||
import type { MetabaseTheme } from "embedding-sdk-bundle/types/ui";
|
||||
import { EnsureSingleInstance } from "embedding-sdk-shared/components/EnsureSingleInstance/EnsureSingleInstance";
|
||||
import { setGlobalEmbeddingColors } from "metabase/embedding-sdk/theme/embedding-color-palette";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getSettings } from "metabase/selectors/settings";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { type JSX, memo, useEffect, useId, useRef } from "react";
|
||||
import { SdkThemeProvider } from "embedding-sdk-bundle/components/private/SdkThemeProvider";
|
||||
import { SdkIncompatibilityWithInstanceBanner } from "embedding-sdk-bundle/components/private/SdkVersionCompatibilityHandler/SdkIncompatibilityWithInstanceBanner";
|
||||
import { useInitDataInternal } from "embedding-sdk-bundle/hooks/private/use-init-data";
|
||||
import { EnsureSingleInstance } from "embedding-sdk-bundle/sdk-shared/components/EnsureSingleInstance/EnsureSingleInstance";
|
||||
import { getSdkStore } from "embedding-sdk-bundle/store";
|
||||
import {
|
||||
setErrorComponent,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
} from "embedding-sdk-bundle/store/reducer";
|
||||
import type { SdkStore } from "embedding-sdk-bundle/store/types";
|
||||
import type { MetabaseProviderProps } from "embedding-sdk-bundle/types/metabase-provider";
|
||||
import { EnsureSingleInstance } from "embedding-sdk-shared/components/EnsureSingleInstance/EnsureSingleInstance";
|
||||
import { useInstanceLocale } from "metabase/common/hooks/use-instance-locale";
|
||||
import { MetabaseReduxProvider } from "metabase/lib/redux";
|
||||
import { LocaleProvider } from "metabase/public/LocaleProvider";
|
||||
|
||||
@@ -426,7 +426,11 @@ function DashboardQueryBuilder({
|
||||
}
|
||||
}}
|
||||
onNavigateBack={onNavigateBack}
|
||||
backToDashboard={dashboard}
|
||||
backToDashboard={{
|
||||
model: "dashboard",
|
||||
id: dashboard.id,
|
||||
name: dashboard.name,
|
||||
}}
|
||||
entityTypes={dataPickerProps?.entityTypes}
|
||||
withResetButton
|
||||
withChartTypeSelector
|
||||
|
||||
@@ -61,7 +61,14 @@ export const useCommonDashboardParams = ({
|
||||
});
|
||||
|
||||
if (url) {
|
||||
dispatch({ type: NAVIGATE_TO_NEW_CARD, payload: { dashboardId } });
|
||||
dispatch({
|
||||
type: NAVIGATE_TO_NEW_CARD,
|
||||
payload: {
|
||||
id: dashboard.id,
|
||||
name: dashboard.name,
|
||||
model: "dashboard",
|
||||
},
|
||||
});
|
||||
setAdhocQuestionUrl(url);
|
||||
}
|
||||
}
|
||||
@@ -77,10 +84,22 @@ export const useCommonDashboardParams = ({
|
||||
|
||||
const onEditQuestion = useCallback(
|
||||
(question: Question) => {
|
||||
dispatch({ type: NAVIGATE_TO_NEW_CARD, payload: { dashboardId } });
|
||||
setAdhocQuestionUrl(Urls.question(question.card()));
|
||||
const state = store.getState();
|
||||
const { dashboards } = state.dashboard;
|
||||
const dashboard = dashboardId && dashboards[dashboardId];
|
||||
if (dashboard) {
|
||||
dispatch({
|
||||
type: NAVIGATE_TO_NEW_CARD,
|
||||
payload: {
|
||||
id: dashboard.id,
|
||||
name: dashboard.name,
|
||||
model: "dashboard",
|
||||
},
|
||||
});
|
||||
setAdhocQuestionUrl(Urls.question(question.card()));
|
||||
}
|
||||
},
|
||||
[dashboardId, dispatch],
|
||||
[dashboardId, dispatch, store],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = {
|
||||
options: {
|
||||
preset: "angular",
|
||||
pkg: {
|
||||
path: "enterprise/frontend/src/embedding-sdk-bundle/package.template.json",
|
||||
path: "enterprise/frontend/src/embedding-sdk-package/package.template.json",
|
||||
},
|
||||
tagPrefix: "embedding-sdk-",
|
||||
transform: function (commit, cb) {
|
||||
|
||||
@@ -3,9 +3,6 @@ import { useMount } from "react-use";
|
||||
import _ from "underscore";
|
||||
|
||||
import { getEmbeddingSdkPackageBuildData } from "embedding-sdk-bundle/lib/get-embedding-sdk-package-build-data";
|
||||
import { useLazySelector } from "embedding-sdk-bundle/sdk-shared/hooks/use-lazy-selector";
|
||||
import { useMetabaseProviderPropsStore } from "embedding-sdk-bundle/sdk-shared/hooks/use-metabase-provider-props-store";
|
||||
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-bundle/sdk-shared/lib/ensure-metabase-provider-props-store";
|
||||
import { initAuth } from "embedding-sdk-bundle/store/auth";
|
||||
import {
|
||||
setFetchRefreshTokenFn,
|
||||
@@ -15,6 +12,9 @@ import {
|
||||
import { getFetchRefreshTokenFn } from "embedding-sdk-bundle/store/selectors";
|
||||
import type { SdkStore } from "embedding-sdk-bundle/store/types";
|
||||
import type { MetabaseAuthConfig } from "embedding-sdk-bundle/types";
|
||||
import { useLazySelector } from "embedding-sdk-shared/hooks/use-lazy-selector";
|
||||
import { useMetabaseProviderPropsStore } from "embedding-sdk-shared/hooks/use-metabase-provider-props-store";
|
||||
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store";
|
||||
import { EMBEDDING_SDK_CONFIG } from "metabase/embedding-sdk/config";
|
||||
import api from "metabase/lib/api";
|
||||
import registerVisualizations from "metabase/visualizations/register";
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEffect } from "react";
|
||||
|
||||
import { getEmbeddingSdkPackageBuildData } from "embedding-sdk-bundle/lib/get-embedding-sdk-package-build-data";
|
||||
import { isSdkPackageCompatibleWithSdkBundle } from "embedding-sdk-bundle/lib/version-utils";
|
||||
import { useLazySelector } from "embedding-sdk-bundle/sdk-shared/hooks/use-lazy-selector";
|
||||
import { useMetabaseProviderPropsStore } from "embedding-sdk-bundle/sdk-shared/hooks/use-metabase-provider-props-store";
|
||||
import { getMetabaseInstanceVersion } from "embedding-sdk-bundle/store/selectors";
|
||||
import { useLazySelector } from "embedding-sdk-shared/hooks/use-lazy-selector";
|
||||
import { useMetabaseProviderPropsStore } from "embedding-sdk-shared/hooks/use-metabase-provider-props-store";
|
||||
|
||||
export const useLogVersionInfo = () => {
|
||||
const {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import "embedding-sdk-bundle/bundle";
|
||||
import "embedding-sdk-bundle";
|
||||
|
||||
import { useLogVersionInfo } from "embedding-sdk-bundle/hooks/private/use-log-version-info";
|
||||
import { getEmbeddingSdkPackageBuildData } from "embedding-sdk-bundle/lib/get-embedding-sdk-package-build-data";
|
||||
import { getMetabaseInstanceVersion } from "embedding-sdk-bundle/store/selectors";
|
||||
|
||||
jest.mock("embedding-sdk-bundle/sdk-shared/hooks/use-lazy-selector", () => ({
|
||||
jest.mock("embedding-sdk-shared/hooks/use-lazy-selector", () => ({
|
||||
useLazySelector: jest.fn((selector) => selector()),
|
||||
}));
|
||||
jest.mock(
|
||||
|
||||
@@ -1,129 +1,71 @@
|
||||
import { EMBEDDING_SDK_CONFIG } from "metabase/embedding-sdk/config";
|
||||
import { defineEmbeddingSdkPackageBuildInfo } from "metabase/embedding-sdk/lib/define-embedding-sdk-package-build-info";
|
||||
import { defineGlobalDependencies } from "metabase/embedding-sdk/lib/define-global-dependencies";
|
||||
/* eslint-disable import/order */
|
||||
|
||||
// Enable SDK mode as we are in the SDK package
|
||||
import { EMBEDDING_SDK_CONFIG } from "metabase/embedding-sdk/config";
|
||||
|
||||
// Enable SDK mode as we are in the SDK bundle
|
||||
// This applies to SDK derivatives such as new iframe embedding.
|
||||
EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true;
|
||||
|
||||
defineEmbeddingSdkPackageBuildInfo();
|
||||
defineGlobalDependencies();
|
||||
// Import the embedding SDK vendors side-effects
|
||||
import "metabase/embedding-sdk/vendors-side-effects";
|
||||
|
||||
export { CollectionBrowser } from "embedding-sdk-bundle/sdk-package/components/public/CollectionBrowser";
|
||||
export { CreateQuestion } from "embedding-sdk-bundle/sdk-package/components/public/CreateQuestion";
|
||||
export { CreateDashboardModal } from "embedding-sdk-bundle/sdk-package/components/public/CreateDashboardModal";
|
||||
export { EditableDashboard } from "embedding-sdk-bundle/sdk-package/components/public/dashboard/EditableDashboard";
|
||||
export { InteractiveDashboard } from "embedding-sdk-bundle/sdk-package/components/public/dashboard/InteractiveDashboard";
|
||||
export { StaticDashboard } from "embedding-sdk-bundle/sdk-package/components/public/dashboard/StaticDashboard";
|
||||
export { InteractiveQuestion } from "embedding-sdk-bundle/sdk-package/components/public/InteractiveQuestion";
|
||||
export { StaticQuestion } from "embedding-sdk-bundle/sdk-package/components/public/StaticQuestion";
|
||||
export { MetabaseProvider } from "embedding-sdk-bundle/sdk-package/components/public/MetabaseProvider";
|
||||
export { MetabotQuestion } from "embedding-sdk-bundle/sdk-package/components/public/MetabotQuestion";
|
||||
export * from "embedding-sdk-bundle/sdk-package/components/public/debug/SdkDebugInfo";
|
||||
// Import the EE plugins required by the embedding sdk.
|
||||
import "sdk-ee-plugins";
|
||||
|
||||
export { useApplicationName } from "embedding-sdk-bundle/sdk-package/hooks/public/use-application-name";
|
||||
export { useAvailableFonts } from "embedding-sdk-bundle/sdk-package/hooks/public/use-available-fonts";
|
||||
export { useCurrentUser } from "embedding-sdk-bundle/sdk-package/hooks/public/use-current-user";
|
||||
export { useCreateDashboardApi } from "embedding-sdk-bundle/sdk-package/hooks/public/use-create-dashboard-api";
|
||||
export { useMetabaseAuthStatus } from "embedding-sdk-bundle/sdk-package/hooks/public/use-metabase-auth-status";
|
||||
// Imports which are only applicable to the embedding sdk, and not the new iframe embedding.
|
||||
import "sdk-specific-imports";
|
||||
|
||||
export { defineMetabaseAuthConfig } from "embedding-sdk-bundle/sdk-package/lib/public/define-metabase-auth-config";
|
||||
export { defineMetabaseTheme } from "embedding-sdk-bundle/sdk-package/lib/public/define-metabase-theme";
|
||||
import type { MetabaseEmbeddingSdkBundleExports } from "./types/sdk-bundle";
|
||||
|
||||
export {
|
||||
type CollectionBrowserProps,
|
||||
type CollectionBrowserListColumns,
|
||||
} from "./components/public/CollectionBrowser";
|
||||
export { type CreateDashboardModalProps } from "./components/public/CreateDashboardModal";
|
||||
export { type CreateQuestionProps } from "./components/public/CreateQuestion";
|
||||
export type {
|
||||
StaticDashboardProps,
|
||||
InteractiveDashboardProps,
|
||||
EditableDashboardProps,
|
||||
import { CollectionBrowser } from "./components/public/CollectionBrowser";
|
||||
import { CreateDashboardModal } from "./components/public/CreateDashboardModal";
|
||||
import { CreateQuestion } from "./components/public/CreateQuestion";
|
||||
import { InteractiveQuestion } from "./components/public/InteractiveQuestion";
|
||||
import { ComponentProvider } from "./components/public/ComponentProvider";
|
||||
import { MetabotQuestion } from "./components/public/MetabotQuestion";
|
||||
import { StaticQuestion } from "./components/public/StaticQuestion";
|
||||
import {
|
||||
EditableDashboard,
|
||||
InteractiveDashboard,
|
||||
StaticDashboard,
|
||||
} from "./components/public/dashboard";
|
||||
export {
|
||||
type SdkQuestionProps,
|
||||
type InteractiveQuestionBackButtonProps,
|
||||
type InteractiveQuestionBreakoutDropdownProps,
|
||||
type InteractiveQuestionChartTypeDropdownProps,
|
||||
type InteractiveQuestionChartTypeSelectorProps,
|
||||
type InteractiveQuestionDownloadWidgetProps,
|
||||
type InteractiveQuestionDownloadWidgetDropdownProps,
|
||||
type InteractiveQuestionEditorProps,
|
||||
type InteractiveQuestionEditorButtonProps,
|
||||
type InteractiveQuestionFilterProps,
|
||||
type InteractiveQuestionFilterDropdownProps,
|
||||
type InteractiveQuestionQuestionSettingsProps,
|
||||
type InteractiveQuestionQuestionSettingsDropdownProps,
|
||||
type InteractiveQuestionQuestionVisualizationProps,
|
||||
type InteractiveQuestionResetButtonProps,
|
||||
type InteractiveQuestionSaveButtonProps,
|
||||
type InteractiveQuestionSaveQuestionFormProps,
|
||||
type InteractiveQuestionSummarizeDropdownProps,
|
||||
type InteractiveQuestionTitleProps,
|
||||
type DrillThroughQuestionProps,
|
||||
} from "./components/public/SdkQuestion";
|
||||
export {
|
||||
type InteractiveQuestionComponents,
|
||||
type InteractiveQuestionProps,
|
||||
} from "./components/public/InteractiveQuestion";
|
||||
export {
|
||||
type StaticQuestionProps,
|
||||
type StaticQuestionComponents,
|
||||
} from "./components/public/StaticQuestion";
|
||||
export { type MetabaseProviderProps } from "./types/metabase-provider";
|
||||
import { SdkDebugInfo } from "./components/public/debug/SdkDebugInfo";
|
||||
import { getApplicationName } from "metabase/selectors/whitelabel";
|
||||
import { getSdkStore } from "./store/index";
|
||||
import {
|
||||
getAvailableFonts,
|
||||
getLoginStatus,
|
||||
} from "embedding-sdk-bundle/store/selectors";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
import { useInitData } from "./hooks/private/use-init-data";
|
||||
import { useLogVersionInfo } from "embedding-sdk-bundle/hooks/private/use-log-version-info";
|
||||
import { createDashboard } from "embedding-sdk-bundle/lib/create-dashboard";
|
||||
|
||||
export type {
|
||||
CustomDashboardCardMenuItem,
|
||||
DashCardMenuItem,
|
||||
DashboardCardCustomMenuItem,
|
||||
DashboardCardMenuCustomElement,
|
||||
DashboardCardMenu,
|
||||
} from "metabase/dashboard/components/DashCard/DashCardMenu/dashcard-menu";
|
||||
/**
|
||||
* IMPORTANT!
|
||||
* Any rename/removal change for object is a breaking change between the SDK Bundle and the SDK NPM package,
|
||||
* and should be done via the deprecation of the field first.
|
||||
*/
|
||||
const sdkBundleExports: MetabaseEmbeddingSdkBundleExports = {
|
||||
CollectionBrowser,
|
||||
CreateDashboardModal,
|
||||
CreateQuestion,
|
||||
EditableDashboard,
|
||||
InteractiveDashboard,
|
||||
InteractiveQuestion,
|
||||
ComponentProvider,
|
||||
MetabotQuestion,
|
||||
SdkDebugInfo,
|
||||
StaticDashboard,
|
||||
StaticQuestion,
|
||||
getSdkStore,
|
||||
createDashboard,
|
||||
getApplicationName,
|
||||
getAvailableFonts,
|
||||
getLoginStatus,
|
||||
getUser,
|
||||
useInitData,
|
||||
useLogVersionInfo,
|
||||
};
|
||||
|
||||
export type {
|
||||
ButtonProps,
|
||||
ChartColor,
|
||||
CreateDashboardValues,
|
||||
EntityTypeFilterKeys,
|
||||
LoginStatus,
|
||||
MetabaseAuthConfig,
|
||||
MetabaseAuthConfigWithApiKey,
|
||||
MetabaseAuthConfigWithJwt,
|
||||
MetabaseAuthConfigWithSaml,
|
||||
MetabaseClickActionPluginsConfig,
|
||||
MetabaseColors,
|
||||
MetabaseClickAction,
|
||||
MetabaseComponentTheme,
|
||||
MetabaseCollection,
|
||||
MetabaseCollectionItem,
|
||||
MetabaseDataPointObject,
|
||||
MetabaseDashboard,
|
||||
MetabaseDashboardPluginsConfig,
|
||||
MetabaseFontFamily,
|
||||
MetabasePluginsConfig,
|
||||
MetabaseQuestion,
|
||||
MetabaseTheme,
|
||||
MetabaseUser,
|
||||
SdkCollectionId,
|
||||
SdkDashboardId,
|
||||
SdkDashboardLoadEvent,
|
||||
SdkEntityId,
|
||||
SdkErrorComponent,
|
||||
SdkErrorComponentProps,
|
||||
SdkEventHandlersConfig,
|
||||
SdkQuestionId,
|
||||
SdkQuestionTitleProps,
|
||||
SdkUserId,
|
||||
SqlParameterValues,
|
||||
} from "./types";
|
||||
|
||||
export type {
|
||||
UserBackendJwtResponse,
|
||||
MetabaseFetchRequestTokenFn,
|
||||
MetabaseEmbeddingSessionToken,
|
||||
} from "./types/refresh-token";
|
||||
|
||||
export type { EmbeddingEntityType } from "metabase-types/store/embedding-data-picker";
|
||||
|
||||
export type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
|
||||
export type { IconName } from "metabase/embedding-sdk/types/icon";
|
||||
window.METABASE_EMBEDDING_SDK_BUNDLE = sdkBundleExports;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getCollectionNumericIdFromReference } from "embedding-sdk-bundle/store/collections";
|
||||
import type { SdkStore } from "embedding-sdk-bundle/store/types";
|
||||
import type {
|
||||
CreateDashboardValues,
|
||||
MetabaseDashboard,
|
||||
} from "embedding-sdk-bundle";
|
||||
import { getCollectionNumericIdFromReference } from "embedding-sdk-bundle/store/collections";
|
||||
import type { SdkStore } from "embedding-sdk-bundle/store/types";
|
||||
} from "embedding-sdk-package";
|
||||
import { createDashboard as createDashboardMutation } from "metabase/api/dashboard";
|
||||
|
||||
export const createDashboard =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getWindow } from "embedding-sdk-bundle/sdk-shared/lib/get-window";
|
||||
import { getWindow } from "embedding-sdk-shared/lib/get-window";
|
||||
import type { BuildInfo } from "metabase/embedding-sdk/types/build-info";
|
||||
|
||||
export const EMBEDDING_SDK_PACKAGE_UNKNOWN_VERSION = "unknown";
|
||||
|
||||
@@ -4,11 +4,11 @@ import _ from "underscore";
|
||||
|
||||
import { getStore } from "__support__/entities-store";
|
||||
import { ComponentProviderInternal } from "embedding-sdk-bundle/components/public/ComponentProvider";
|
||||
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-bundle/sdk-shared/lib/ensure-metabase-provider-props-store";
|
||||
import { sdkReducers } from "embedding-sdk-bundle/store";
|
||||
import type { SdkStore, SdkStoreState } from "embedding-sdk-bundle/store/types";
|
||||
import { createMockSdkState } from "embedding-sdk-bundle/test/mocks/state";
|
||||
import type { MetabaseProviderProps } from "embedding-sdk-bundle/types/metabase-provider";
|
||||
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store";
|
||||
import { Api } from "metabase/api";
|
||||
import { MetabaseReduxProvider } from "metabase/lib/redux";
|
||||
import type { MantineThemeOverride } from "metabase/ui";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { waitForRequest } from "__support__/utils";
|
||||
import { defineMetabaseAuthConfig } from "embedding-sdk-bundle/sdk-package/lib/public/define-metabase-auth-config";
|
||||
import { defineMetabaseAuthConfig } from "embedding-sdk-package/lib/public/define-metabase-auth-config";
|
||||
|
||||
import { MOCK_API_KEY, MOCK_INSTANCE_URL } from "../mocks/sso";
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { renderWithProviders, waitForLoaderToBeRemoved } from "__support__/ui";
|
||||
import { waitForRequest } from "__support__/utils";
|
||||
import { ComponentProvider } from "embedding-sdk-bundle/components/public/ComponentProvider";
|
||||
import { StaticQuestion } from "embedding-sdk-bundle/components/public/StaticQuestion";
|
||||
import { defineMetabaseAuthConfig } from "embedding-sdk-bundle/sdk-package/lib/public/define-metabase-auth-config";
|
||||
import type { MetabaseProviderProps } from "embedding-sdk-bundle/types/metabase-provider";
|
||||
import { defineMetabaseAuthConfig } from "embedding-sdk-package/lib/public/define-metabase-auth-config";
|
||||
|
||||
import {
|
||||
MOCK_INSTANCE_URL,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { waitForLoaderToBeRemoved } from "__support__/ui";
|
||||
import { waitForRequest } from "__support__/utils";
|
||||
import { ComponentProvider } from "embedding-sdk-bundle/components/public/ComponentProvider";
|
||||
import { StaticQuestion } from "embedding-sdk-bundle/components/public/StaticQuestion";
|
||||
import { defineMetabaseAuthConfig } from "embedding-sdk-bundle/sdk-package/lib/public/define-metabase-auth-config";
|
||||
import type { MetabaseProviderProps } from "embedding-sdk-bundle/types/metabase-provider";
|
||||
import { defineMetabaseAuthConfig } from "embedding-sdk-package/lib/public/define-metabase-auth-config";
|
||||
|
||||
import {
|
||||
MOCK_INSTANCE_URL,
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("SDK environment config", () => {
|
||||
(await import("metabase/embedding-sdk/config")).isEmbeddingSdk(),
|
||||
).toBe(false);
|
||||
|
||||
await import("embedding-sdk-bundle");
|
||||
await import("embedding-sdk-package");
|
||||
|
||||
expect(
|
||||
(await import("metabase/embedding-sdk/config")).isEmbeddingSdk(),
|
||||
@@ -22,7 +22,7 @@ describe("SDK environment config", () => {
|
||||
(await import("metabase/embedding-sdk/config")).isEmbeddingSdk(),
|
||||
).toBe(false);
|
||||
|
||||
await import("embedding-sdk-bundle/bundle");
|
||||
await import("embedding-sdk-bundle");
|
||||
|
||||
expect(
|
||||
(await import("metabase/embedding-sdk/config")).isEmbeddingSdk(),
|
||||
|
||||
@@ -8,8 +8,8 @@ import { waitForLoaderToBeRemoved } from "__support__/ui";
|
||||
import { waitForRequest } from "__support__/utils";
|
||||
import { ComponentProvider } from "embedding-sdk-bundle/components/public/ComponentProvider";
|
||||
import { StaticQuestion } from "embedding-sdk-bundle/components/public/StaticQuestion";
|
||||
import { defineMetabaseAuthConfig } from "embedding-sdk-bundle/sdk-package/lib/public/define-metabase-auth-config";
|
||||
import type { MetabaseAuthConfig } from "embedding-sdk-bundle/types";
|
||||
import { defineMetabaseAuthConfig } from "embedding-sdk-package/lib/public/define-metabase-auth-config";
|
||||
import { createMockCard } from "metabase-types/api/mocks";
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import type {
|
||||
CreateDashboardValues,
|
||||
MetabaseDashboard,
|
||||
} from "embedding-sdk-bundle";
|
||||
import type { CollectionBrowser } from "embedding-sdk-bundle/components/public/CollectionBrowser";
|
||||
import type { ComponentProvider } from "embedding-sdk-bundle/components/public/ComponentProvider";
|
||||
import type { CreateDashboardModal } from "embedding-sdk-bundle/components/public/CreateDashboardModal";
|
||||
@@ -15,6 +11,10 @@ import type { StaticDashboard } from "embedding-sdk-bundle/components/public/das
|
||||
import type { SdkDebugInfo } from "embedding-sdk-bundle/components/public/debug/SdkDebugInfo";
|
||||
import type { SdkStore, SdkStoreState } from "embedding-sdk-bundle/store/types";
|
||||
import type { LoginStatus } from "embedding-sdk-bundle/types/user";
|
||||
import type {
|
||||
CreateDashboardValues,
|
||||
MetabaseDashboard,
|
||||
} from "embedding-sdk-package";
|
||||
import type { User } from "metabase-types/api";
|
||||
|
||||
type InternalHook = () => void;
|
||||
|
||||
@@ -56,4 +56,4 @@ For the current limitations of the SDK, see the [SDK limitations section](https:
|
||||
|
||||
## Development docs
|
||||
|
||||
For developing the SDK, see the [dev docs](https://github.com/metabase/metabase/blob/master/enterprise/frontend/src/embedding-sdk-bundle/dev.md).
|
||||
For developing the SDK, see the [dev docs](https://github.com/metabase/metabase/blob/master/enterprise/frontend/src/embedding-sdk-package/dev.md).
|
||||
@@ -2,13 +2,13 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { MOCK_SERVER_PACKAGE_JSON } from "../cli/constants/mock-server-package-json";
|
||||
import { getExpressServerSnippet } from "../cli/snippets/express-server-snippet";
|
||||
import { getComponentSnippets } from "../cli/snippets/get-component-snippets";
|
||||
import { MOCK_SERVER_PACKAGE_JSON } from "embedding-sdk-package/cli/constants/mock-server-package-json";
|
||||
import { getExpressServerSnippet } from "embedding-sdk-package/cli/snippets/express-server-snippet";
|
||||
import { getComponentSnippets } from "embedding-sdk-package/cli/snippets/get-component-snippets";
|
||||
import {
|
||||
getNextJsAnalyticsPageSnippet,
|
||||
getNextJsPagesWrapperOrAppWrapperSnippet,
|
||||
} from "../cli/snippets/nextjs-snippets";
|
||||
} from "embedding-sdk-package/cli/snippets/nextjs-snippets";
|
||||
|
||||
function setupOutDir(outDir: string) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { getPublicComponents } from "embedding-sdk-bundle/bin/get-public-components";
|
||||
import { getPublicComponents } from "embedding-sdk-package/bin/get-public-components";
|
||||
|
||||
const destinationDir = path.resolve(
|
||||
__dirname,
|
||||