Compare commits

...

615 Commits

Author SHA1 Message Date
dan sutton
891847254b extend to snowflake 2025-08-13 13:18:45 -05:00
dan sutton
605778eb41 remove reader: only one db user is created 2025-08-11 15:17:01 -05:00
dan sutton
c01d20cedc finish out test of evaluator on arithmetic 2025-08-11 14:58:07 -05:00
dan sutton
d244b51eb9 make evaluator a bit more generic
rules don't have to be strings and subtrees aren't just vectors in the
evaluator but ones that are vectors with a keyword in the first
position. again a proxy and not exact
2025-08-11 09:30:07 -05:00
dan sutton
8c99830d22 bit of cleanup 2025-08-09 19:23:58 -05:00
dan sutton
ddea16883f Allow for recovering from a subtree
```clojure
(let [steps [:overall-with-continue
             {:error-strategy ::isolation-manager/continue-on-error}
             [:subgoal1-with-fail {:error-strategy ::isolation-manager/fail} "action1" "action2"]
             [:subgoal2 {}
              "action3"
              [:subsubgoal {} "action4"]]]]
  (#'isolation-manager/evaluate-steps steps
                                      (fn [x]
                                        (if (= x "action1")
                                          [:error x]
                                          [:success x]))))
[[[:tree :overall-with-continue]
  [:tree :subgoal1-with-fail]
  [:error "action1"]
  [:skipping-step "action2"] ;; fail in the subgroup1-with-fail
  [:tree :subgoal2]     ;; but outside tree continues
  [:success "action3"]
  [:tree :subsubgoal]
  [:success "action4"]]
 :running]
```
2025-08-09 18:52:15 -05:00
dan sutton
b1c8896fa1 Allow for error strategies in the step evaluator
added lexical scope and stackframes for the evaluator. Can set at any
level an error stragegy. Right now the only one strategy recognized is
continue on error

```clojure
isolation-manager=> (evaluate-steps [:schema
                                     {}
                                     "CREATE SCHEMA scratchpad"
                                     [:users
                                      {}
                                      [:populator
                                       {}
                                       "CREATE USER populator WITH PASSWORD 'populator-pw'"
                                       [:populator-privileges
                                        {}
                                        "GRANT USAGE ON SCHEMA public TO populator"
                                        "GRANT USAGE ON SCHEMA public TO populator"
                                        "GRANT USAGE ON SCHEMA scratchpad TO populator"
                                        "GRANT SELECT ON ALL TABLES IN SCHEMA public TO populator"
                                        "GRANT CREATE ON SCHEMA scratchpad TO populator"
                                        "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO populator"]]
                                      [:reader
                                       {}
                                       "CREATE USER reader WITH PASSWORD 'reader-pw'"
                                       [:reader-privileges
                                        {}
                                        "GRANT USAGE ON SCHEMA scratchpad TO reader"
                                        "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO reader"]]]]
                                    (fn [step] (if (= 0 (rand-int 10))
                                                 [:error (format "error during: %s" step)]
                                                 [:success (format "performed: %s" step)])))
[[[:tree :schema]
  [:success "performed: CREATE SCHEMA scratchpad"]
  [:tree :users]
  [:tree :populator]
  [:success
   "performed: CREATE USER populator WITH PASSWORD 'populator-pw'"]
  [:tree :populator-privileges]
  [:error "error during: GRANT USAGE ON SCHEMA public TO populator"]
  [:skipping-step "GRANT USAGE ON SCHEMA public TO populator"]
  [:skipping-step "GRANT USAGE ON SCHEMA scratchpad TO populator"]
  [:skipping-step
   "GRANT SELECT ON ALL TABLES IN SCHEMA public TO populator"]
  [:skipping-step "GRANT CREATE ON SCHEMA scratchpad TO populator"]
  [:skipping-step
   "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO populator"]
  [:skipping-tree :reader]]
 :error]
```

In the above evaluation note an error happens when granting usage on
schema public to the populator. Causing all steps, and then all trees,
to be skipped. Contrast this with this:

```clojure
isolation-manager=> (evaluate-steps [:schema
                                     {:error-strategy ::continue-on-error}
                                     "CREATE SCHEMA scratchpad"
                                     [:users
                                      {}
                                      [:populator
                                       {}
                                       "CREATE USER populator WITH PASSWORD 'populator-pw'"
                                       [:populator-privileges
                                        {}
                                        "GRANT USAGE ON SCHEMA public TO populator"
                                        "GRANT USAGE ON SCHEMA public TO populator"
                                        "GRANT USAGE ON SCHEMA scratchpad TO populator"
                                        "GRANT SELECT ON ALL TABLES IN SCHEMA public TO populator"
                                        "GRANT CREATE ON SCHEMA scratchpad TO populator"
                                        "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO populator"]]
                                      [:reader
                                       {}
                                       "CREATE USER reader WITH PASSWORD 'reader-pw'"
                                       [:reader-privileges
                                        {}
                                        "GRANT USAGE ON SCHEMA scratchpad TO reader"
                                        "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO reader"]]]]
                                    (fn [step] (if (= 0 (rand-int 10))
                                                 [:error (format "error during: %s" step)]
                                                 [:success (format "performed: %s" step)])))
[[[:tree :schema]
  [:success "performed: CREATE SCHEMA scratchpad"]
  [:tree :users]
  [:tree :populator]
  [:success
   "performed: CREATE USER populator WITH PASSWORD 'populator-pw'"]
  [:tree :populator-privileges]
  [:success "performed: GRANT USAGE ON SCHEMA public TO populator"]
  [:error "error during: GRANT USAGE ON SCHEMA public TO populator"]
  [:success
   "performed: GRANT USAGE ON SCHEMA scratchpad TO populator"]
  [:success
   "performed: GRANT SELECT ON ALL TABLES IN SCHEMA public TO populator"]
  [:success
   "performed: GRANT CREATE ON SCHEMA scratchpad TO populator"]
  [:success
   "performed: GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO populator"]
  [:tree :reader]
  [:success "performed: CREATE USER reader WITH PASSWORD 'reader-pw'"]
  [:tree :reader-privileges]
  [:success "performed: GRANT USAGE ON SCHEMA scratchpad TO reader"]
  [:success
   "performed: GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO reader"]]
 :running]
```

Note the error strategy allows for an error of grangint usage on public
schema to populator but it carries on.
2025-08-09 18:52:00 -05:00
dan sutton
ecda02cbd1 evluator with steps in a tree 2025-08-09 18:51:17 -05:00
Bryan Maass
3acd6d08a6 Merge branch 'workspace-api-layer' of github.com:metabase/metabase into workspace-api-layer 2025-08-08 16:23:22 -06:00
dan sutton
c92e4a9cc4 isolation manager 2025-08-08 17:16:07 -05:00
Bryan Maass
d9ab296660 fix whitespace 2025-08-08 16:05:21 -06:00
Bryan Maass
c943c2b9fa fix tests 2025-08-08 15:31:13 -06:00
Bryan Maass
ac97197f90 created-at -> created_at 2025-08-08 15:29:46 -06:00
Bryan Maass
a0b4a5a6df fe [no-verify] 2025-08-08 15:29:25 -06:00
Bryan Maass
fec9db182c fe: link workspace [no-verify] 2025-08-08 15:09:28 -06:00
Bryan Maass
d29351c314 link workspace 2025-08-08 15:09:02 -06:00
Bryan Maass
884793c2d3 API crud on plans, dwh, permissions, user + more 2025-08-08 13:42:55 -06:00
Bryan Maass
df3b36bbab vibe fe commit [no-verify] 2025-08-08 13:42:32 -06:00
Bryan Maass
57e47c49a4 add :put /:id/plan/:index 2025-08-08 10:59:44 -06:00
Bryan Maass
9ae8b86c3a [no-verify] vibe fe commit 1 2025-08-08 10:17:11 -06:00
Bryan Maass
ec776f91b7 backend cleanup 2025-08-08 09:59:05 -06:00
Bryan Maass
9dd5d35b21 move repl session 2025-08-08 09:48:26 -06:00
Bryan Maass
4c439abd41 tests pass 2025-08-08 09:30:23 -06:00
Bryan Maass
317be73022 many tests 2025-08-07 17:10:23 -06:00
Bryan Maass
22c688ce0b rename column 2025-08-06 17:04:47 -06:00
Bryan Maass
cbbc1b9cdf unify data model 2025-08-06 16:58:27 -06:00
Bryan Maass
e1c49ee7f1 add sort workspace test 2025-08-06 16:55:54 -06:00
Bryan Maass
829aa5fa34 sorting workspaces, getting good diffs 2025-08-06 16:52:25 -06:00
Bryan Maass
30b78cf81d demos + writing yaml output 2025-08-06 15:51:36 -06:00
Bryan Maass
96f8df736b workspace api + test 2025-08-06 15:29:21 -06:00
Bryan Maass
44a816c0e7 add test ns 2025-08-06 15:13:28 -06:00
Bryan Maass
0b973ee925 checkpoint
- need to make sure the table in migrations is aligned with the schema
  in m.workspace
2025-08-06 15:11:46 -06:00
Bryan Maass
a3b7926074 get api setup 2025-08-06 14:24:11 -06:00
Bryan Maass
b15deb0ea2 hacking it up 2025-08-06 13:58:32 -06:00
dan sutton
a2992ca98b Merge remote-tracking branch 'origin/feature/reports' into workspace-starting-point 2025-08-06 12:19:36 -05:00
dan sutton
90c83cbb6f Merge remote-tracking branch 'origin/poc-transform-table' into workspace-starting-point 2025-08-06 10:56:29 -05:00
Brad Anderson
7894f1e45e Fix issue where temporary implementation of metabot block can't create questions 2025-08-06 11:20:10 -04:00
Nick Fitzpatrick
fd4a621603 document entity 2025-08-06 12:15:28 -03:00
Tamás Benkő
93557a26a2 [skip ci] Configure worker module 2025-08-06 18:04:47 +03:00
Tamás Benkő
bdd10e1248 Merge remote-tracking branch 'origin/master' into poc-transform-table 2025-08-06 17:38:26 +03:00
Edward Paget
0486d547d3 feat: include revision info in collection tree res 2025-08-06 09:08:37 -05:00
Tamás Benkő
e46d720734 Move direct worker_run table access into the worker module 2025-08-06 17:00:45 +03:00
Edward Paget
ec7163091a feat: revision history for documents 2025-08-06 08:57:32 -05:00
Alexander Polyankin
eba58e0951 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-06 09:09:56 -04:00
Alexander Polyankin
9ece66c0ca Table 2025-08-06 09:09:38 -04:00
Alexander Polyankin
12d177f857 Table 2025-08-06 08:59:14 -04:00
Alexander Polyankin
6fe7b7e379 Table 2025-08-06 08:56:32 -04:00
William Zimrin
65de0ad883 refactor driver name matching 2025-08-05 22:26:03 -05:00
William Zimrin
dfc252996e handle name matching in a driver-specific manner 2025-08-05 22:19:51 -05:00
William Zimrin
fd173e472e basic dag sql parsing 2025-08-05 21:58:58 -05:00
Aleksandr Lesnenko
287e496a1b wip but working sql query editor in documents 2025-08-05 23:28:00 -03:00
Tamás Benkő
b57ba12432 Check transforms feature 2025-08-06 01:59:37 +03:00
Aleksandr Lesnenko
2ac44861b5 Persist cards when saving document (#61818)
* chore: remove document version model

* persist cards only when saving documents

* chore: remove in_document type

* feat: return document creator

* feat: save cards with parameters pass in as cards

in POST and PUT requests to documents

* chore: exclude doc cards from where shouldn't be

search, recents, collection items

* chore: fixup search and card tests

* contract

* feat: update document ast with new card ids

* fix: report -> document in collection api

* fix: ws

* feat: clone cards not associated with document

* chore: add tests for document permissions

* fix contract

* fetch root collection

* update message

* must be working now

* fix sidebar

---------

Co-authored-by: Edward Paget <ed.paget@gmail.com>
2025-08-05 19:59:20 -03:00
Tamás Benkő
b09dd8f7de Move migrations to 057_update_migrations.yaml 2025-08-06 00:56:03 +03:00
Tamás Benkő
8924c126d2 Hydrate last_execution 2025-08-06 00:41:43 +03:00
Denis Berezin
47d994182c Merge branch 'master' into feature/reports
# Conflicts:
#	frontend/src/metabase/redux/app.ts
2025-08-06 00:18:15 +03:00
Eric Normand
994d797a5e Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-05 16:06:25 -05:00
Eric Normand
7699bfc8d9 [ci skip] Cancelation
has api endpoint
moves worker/sync to transforms/sync
cancels local stuff in the frontend

Does not cancel stuff on the worker server (coming soon!)
2025-08-05 16:03:53 -05:00
Denis Berezin
17435642ef Fix some CI issues 2025-08-06 00:03:50 +03:00
Timothy Dean
e6be8f5579 Change run-id from UUID to NanoID 2025-08-05 14:37:49 -06:00
Denis Berezin
a917ade608 Merge branch 'master' into feature/reports
# Conflicts:
#	resources/migrations/056_update_migrations.yaml
#	test/metabase/test/util/namespace.clj
2025-08-05 22:30:13 +03:00
Alexander Polyankin
feb995d4c2 Table 2025-08-05 15:29:32 -04:00
Alexander Polyankin
f8664fb617 Table 2025-08-05 15:24:57 -04:00
Alexander Polyankin
eb154a11fb UI updates [skip ci] 2025-08-05 15:03:59 -04:00
Tamás Benkő
531e83d13e Drop execution_state, add last_execution, stop sync tracking 2025-08-05 21:34:53 +03:00
Alexander Polyankin
0bb9f85db6 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-05 14:12:30 -04:00
Alexander Polyankin
02ba5c3c6e UI updates [skip ci] 2025-08-05 14:09:08 -04:00
Alexander Polyankin
cc76cccad4 UI updates [skip ci] 2025-08-05 13:51:34 -04:00
Nick Fitzpatrick
1799892d0b Document Metadata 2025-08-05 14:45:26 -03:00
Tamás Benkő
fa13ad9eba Add cron-style execution test 2025-08-05 20:27:22 +03:00
Alexander Polyankin
a61da38453 UI updates [skip ci] 2025-08-05 13:19:25 -04:00
Alexander Polyankin
8353437442 UI updates [skip ci] 2025-08-05 12:55:40 -04:00
Alexander Polyankin
6439ca2712 UI updates [skip ci] 2025-08-05 12:53:20 -04:00
Eric Normand
23a519e0f2 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-05 11:32:42 -05:00
Eric Normand
1060963f98 [ci skip] Worker indicates timeout on GET 2025-08-05 11:32:06 -05:00
Alexander Polyankin
7e7b7e4829 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-05 12:19:59 -04:00
Alexander Polyankin
9559623942 UI changes [skip ci] 2025-08-05 12:19:43 -04:00
Tamás Benkő
43a035c59a Access settings at runtime only 2025-08-05 18:34:18 +03:00
Tamás Benkő
392b86e04e Add scheduled execution 2025-08-05 02:31:51 +03:00
Brad Anderson
4f7c22ed53 Adds subtle animation to "Working on it..." message 2025-08-04 17:34:07 -04:00
Brad Anderson
ba806688b6 Fix issues with multiple in-flight requests 2025-08-04 17:17:48 -04:00
Brad Anderson
18898a5fdf Tweaks metabot block styling, wires up "stop generating" functionality 2025-08-04 17:00:10 -04:00
Eric Normand
73bc9f35af [ci skip] use four-hour timeout 2025-08-04 15:33:43 -05:00
Eric Normand
225264716a [ci skip] Use instance id to identify jobs. 2025-08-04 15:31:53 -05:00
Eric Normand
8c434cd2ac Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-04 14:29:49 -05:00
Eric Normand
74407c341c Run worker timeout thread every 20 seconds 2025-08-04 14:29:24 -05:00
William Zimrin
47ef0aeaa3 disable remaps on transforms at execution time 2025-08-04 13:32:57 -05:00
William Zimrin
bd0e83f217 disable remaps on transforms 2025-08-04 13:22:08 -05:00
Eric Normand
5e4efad52a Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-04 12:44:18 -05:00
Eric Normand
6f139518f5 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-04 12:43:49 -05:00
William Zimrin
a75c4e16f5 ordering code doesn't use t2 directly 2025-08-04 12:43:45 -05:00
Eric Normand
857b465224 [ci skip] Timeout on worker 2025-08-04 12:43:33 -05:00
Eric Normand
59a4e89cef Fix execute.clj 2025-08-04 12:43:22 -05:00
Eric Normand
dfe7138ac4 [ci skip] Add ability to sync timeout status 2025-08-04 12:16:55 -05:00
Eric Normand
1d2d50aca2 [ci skip] better timeout update! 2025-08-04 12:15:59 -05:00
Brad Anderson
1ebe336a00 UXW-404 Improves image formatting 2025-08-04 13:05:36 -04:00
William Zimrin
c6bf4a6549 ordering code uses preprocess 2025-08-04 12:03:39 -05:00
Eric Normand
c17c189c2a [ci skip] Add timeout to sync task. 4 hour default 2025-08-04 11:52:05 -05:00
Aleksandr Lesnenko
36790affcf auto focus on title on new documents 2025-08-04 13:08:36 -03:00
Aleksandr Lesnenko
a15154cb84 fix layout 2025-08-04 11:51:08 -03:00
Tamás Benkő
a10c221505 Add find-cycle-test 2025-08-04 16:45:21 +03:00
Tamás Benkő
8d465a2854 Add execution job, detect cycles, split migration changeSets 2025-08-04 14:05:48 +03:00
Eric Normand
c06902d967 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-01 22:45:39 -05:00
Eric Normand
a885281433 Add /execution endpoint to list executions with filtering, sorting, and pagination 2025-08-01 22:45:10 -05:00
Aleksandr Lesnenko
095cae88af fix links to tables 2025-08-01 22:20:08 -03:00
Aleksandr Lesnenko
5314f9f976 missing import 2025-08-01 22:10:37 -03:00
Aleksandr Lesnenko
7189344bfd paste smartlinks 2025-08-01 22:01:48 -03:00
William Zimrin
d26969ec1d ordering code selects all relevant cards at once 2025-08-01 19:58:27 -05:00
Aleksandr Lesnenko
11b74e0e4b split mention vs command, allow mentioning other entities 2025-08-01 21:29:55 -03:00
Aleksandr Lesnenko
c5862c278d mention all entities 2025-08-01 20:47:30 -03:00
William Zimrin
eb3698c076 ordering finds joined tables and card dependences 2025-08-01 18:24:52 -05:00
Aleksandr Lesnenko
0af4b1c0de uxw-366: Navigation back to the report from a drill through can be difficult - can we add the back button like for dashboards 2025-08-01 20:18:35 -03:00
Eric Normand
518dafaddc Remove hydration 2025-08-01 18:17:35 -05:00
Eric Normand
51041d6fca [ci skip] Restrict to specific work types 2025-08-01 18:08:56 -05:00
Eric Normand
e4f962b92d [ci skip] Add hydration for worker runs 2025-08-01 18:01:31 -05:00
Eric Normand
0f33f4ae5a [ci skip] forget the promise! 2025-08-01 17:48:04 -05:00
Eric Normand
826259a206 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-01 17:40:28 -05:00
Eric Normand
2e2e874386 Remote syncing works 2025-08-01 17:40:16 -05:00
Tamás Benkő
354821c166 Add driver/drop-transform-target! to support view cleanup 2025-08-02 01:37:54 +03:00
Aleksandr Lesnenko
4cd96fa6a2 fix embedding dashboard-only cards to documents 2025-08-01 19:30:19 -03:00
William Zimrin
f69374afa7 fix name 2025-08-01 16:48:30 -05:00
Aleksandr Lesnenko
b47ccb6123 fix FE usage of documents api 2025-08-01 18:43:27 -03:00
Eric Normand
a77f40809b Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-01 15:58:14 -05:00
Eric Normand
fb49882cae WIP 2025-08-01 15:56:31 -05:00
Maz Ameli
424c570fdf Add runs page (#61748) 2025-08-01 13:42:25 -07:00
Edward Paget
5271f4092d chore: rename everything to document (#61743)
strip out snapshotting code
2025-08-01 15:04:32 -05:00
Tamás Benkő
7764bb7ced [skip ci] Fix transform execution test for postgres 2025-08-01 21:50:30 +03:00
Tamás Benkő
266e5d99f5 [skip ci] Bless HACKS for now 2025-08-01 20:52:53 +03:00
Aleksandr Lesnenko
c06ea7d391 do not leak prosemirror styles into charts 2025-08-01 14:17:20 -03:00
William Zimrin
8beea297c1 minimal dag construction 2025-08-01 12:05:43 -05:00
Alexander Polyankin
6aa63fbf89 HACKS 2025-08-01 12:19:55 -04:00
Tamás Benkő
59010b95cc [skip ci] Remove inactive table from response 2025-08-01 18:46:42 +03:00
Alexander Polyankin
3d393413b3 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-01 11:45:26 -04:00
Alexander Polyankin
47df083512 Merge remote-tracking branch 'origin/master' into poc-transform-table
# Conflicts:
#	docs/developers-guide/driver-changelog.md
#	modules/drivers/snowflake/src/metabase/driver/snowflake.clj
2025-08-01 11:44:26 -04:00
William Zimrin
53705b4e0e rename health check and make port configurable 2025-08-01 10:43:09 -05:00
Alexander Polyankin
8c9d6b2a6d [skip ci] 2025-08-01 11:42:50 -04:00
Alexander Polyankin
c2278512c5 [skip ci] 2025-08-01 11:39:50 -04:00
Edward Paget
e2e2e5fdf3 keep report cards and collections in sync (#61686) 2025-08-01 10:04:50 -05:00
Alexander Polyankin
c844ac4211 [skip ci] 2025-08-01 10:26:29 -04:00
Alexander Polyankin
958e0a7e3c [skip ci] 2025-08-01 10:22:14 -04:00
Alexander Polyankin
1ae17a92e4 [skip ci] 2025-08-01 09:47:43 -04:00
Alexander Polyankin
bb4ed09e80 [skip ci] 2025-08-01 09:37:14 -04:00
Alexander Polyankin
892031c6f6 [skip ci] 2025-08-01 09:35:31 -04:00
Alexander Polyankin
7765ddc263 [skip ci] 2025-08-01 09:30:26 -04:00
Ryan Laurie
329b903a8b update metabot question creation parsing 2025-08-01 07:14:18 -06:00
Alexander Polyankin
a75ad79643 [skip ci] 2025-08-01 03:26:15 -04:00
Alexander Polyankin
eb1f0ae78f [skip ci] 2025-08-01 03:21:47 -04:00
Alexander Polyankin
a601d3bfc5 [skip ci] 2025-08-01 03:18:57 -04:00
Alexander Polyankin
25f6c3142b Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-01 02:48:07 -04:00
Alexander Polyankin
77a96f847a [skip ci] 2025-08-01 02:46:51 -04:00
Alexander Polyankin
c2278a5818 [skip ci] 2025-08-01 02:43:32 -04:00
Alexander Polyankin
8040f2cc12 [skip ci] 2025-08-01 02:41:39 -04:00
Tamás Benkő
019a375bd6 Set the required feature properly in api-test.execute-test 2025-08-01 09:11:17 +03:00
Alexander Polyankin
31641fad7d [skip ci] 2025-08-01 02:06:28 -04:00
Alexander Polyankin
5bbf5906cd [skip ci] 2025-08-01 01:54:04 -04:00
Alexander Polyankin
e165f7ce9e [skip ci] 2025-08-01 01:15:16 -04:00
Alexander Polyankin
058dad267c [skip ci] 2025-08-01 01:04:57 -04:00
Alexander Polyankin
c7cf63fede [skip ci] 2025-08-01 00:43:31 -04:00
Alexander Polyankin
61063dec9d [skip ci] 2025-08-01 00:36:24 -04:00
Alexander Polyankin
a9bdd199e4 [skip ci] 2025-08-01 00:35:40 -04:00
Alexander Polyankin
486a45e724 [skip ci] 2025-08-01 00:32:41 -04:00
Alexander Polyankin
45576d45fd [skip ci] 2025-08-01 00:22:41 -04:00
Alexander Polyankin
02cde50808 [skip ci] 2025-07-31 23:45:50 -04:00
Alexander Polyankin
56e5d52791 [skip ci] 2025-07-31 23:42:26 -04:00
Alexander Polyankin
fb4a4e85e5 [skip ci] 2025-07-31 23:34:28 -04:00
Alexander Polyankin
a1508f49ad [skip ci] 2025-07-31 23:33:34 -04:00
Alexander Polyankin
32256e3463 [skip ci] 2025-07-31 23:27:58 -04:00
Alexander Polyankin
0be8ef8dc4 [skip ci] 2025-07-31 23:21:40 -04:00
Alexander Polyankin
da8d743b7b [skip ci] 2025-07-31 22:51:35 -04:00
Alexander Polyankin
9887170517 [skip ci] 2025-07-31 22:38:52 -04:00
Alexander Polyankin
ef6fdda187 [skip ci] 2025-07-31 22:36:09 -04:00
Alexander Polyankin
9eddb96417 [skip ci] 2025-07-31 22:09:25 -04:00
Alexander Polyankin
4dbc959b5d [skip ci] 2025-07-31 22:06:42 -04:00
Alexander Polyankin
903e9f9754 [skip ci] 2025-07-31 21:55:03 -04:00
Alexander Polyankin
b04d9b9973 [skip ci] 2025-07-31 21:53:32 -04:00
Alexander Polyankin
72d89ea81f [skip ci] 2025-07-31 21:39:54 -04:00
Alexander Polyankin
cedab9b146 [skip ci] 2025-07-31 21:31:45 -04:00
Alexander Polyankin
eacb72c1c2 [skip ci] 2025-07-31 21:29:23 -04:00
Alexander Polyankin
4bd8baf4b1 [skip ci] 2025-07-31 21:25:22 -04:00
Alexander Polyankin
a33f07a5f2 [skip ci] 2025-07-31 21:17:32 -04:00
Alexander Polyankin
91e9161c09 [skip ci] 2025-07-31 21:12:46 -04:00
Alexander Polyankin
507b219897 [skip ci] 2025-07-31 21:10:23 -04:00
Aleksandr Lesnenko
82acc79f82 fe: reports -> documents 2025-07-31 21:58:28 -03:00
Alexander Polyankin
10e55dedd9 [skip ci] 2025-07-31 20:53:00 -04:00
Alexander Polyankin
db67174292 [skip ci] 2025-07-31 20:42:35 -04:00
Alexander Polyankin
d2b79d570a [skip ci] 2025-07-31 20:28:58 -04:00
Alexander Polyankin
2ee9f49b04 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-31 20:23:39 -04:00
Aleksandr Lesnenko
240862340a icons 2025-07-31 21:00:14 -03:00
Tamás Benkő
bc1870fc1d Delete legos/actions_test.clj 2025-08-01 02:13:58 +03:00
Tamás Benkő
40d2ec1d49 Merge remote-tracking branch 'origin/master' into poc-transform-table 2025-08-01 02:08:49 +03:00
Tamás Benkő
2df2f7c38b Add view support 2025-08-01 02:05:02 +03:00
Nick Fitzpatrick
7849e0394c cleaning up emoji ui a bit 2025-07-31 19:10:19 -03:00
William Zimrin
574af1d732 slight logging tweak 2025-07-31 16:53:25 -05:00
William Zimrin
364624bd9d worker round trip works with uuid run-ids 2025-07-31 16:24:41 -05:00
Nick Fitzpatrick
8fa264cb17 gross emojis 2025-07-31 18:08:02 -03:00
William Zimrin
9e06858b86 use malli specs more 2025-07-31 15:53:10 -05:00
Eric Normand
6660169b07 [ci skip] forgot to stage worker/tracking.clj 2025-07-31 15:52:25 -05:00
Aleksandr Lesnenko
ca4744dc08 why scroll to bottom when opened 2025-07-31 17:51:25 -03:00
Aleksandr Lesnenko
78e9dda8dd jumping layout when loading 2025-07-31 17:51:25 -03:00
Ryan Laurie
b36f00cf80 stop header bouncing 2025-07-31 14:42:55 -06:00
Eric Normand
e28323011f Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-31 15:42:13 -05:00
Nick Fitzpatrick
3f24ba5687 disable metabot sidebar on mob+b 2025-07-31 17:40:18 -03:00
Aleksandr Lesnenko
d7dc44feb2 always clone non in_report cards 2025-07-31 17:34:38 -03:00
Aleksandr Lesnenko
0450ba6a67 always create a card clone when embedding 2025-07-31 17:34:38 -03:00
Aleksandr Lesnenko
b30ecea5ca cleanup 2025-07-31 17:34:38 -03:00
Eric Normand
f95451e821 [ci skip] Make worker server PUT /transforms/:run-id idempotent 2025-07-31 15:34:18 -05:00
Tamás Benkő
188ac08c97 Add setting for the global schedule 2025-07-31 23:07:49 +03:00
Nick Fitzpatrick
fc8ca9b602 Print Report 2025-07-31 17:01:10 -03:00
Eric Normand
c3f2dee553 [ci skip] Logs instead of prinlns 2025-07-31 14:17:35 -05:00
William Zimrin
980edd8134 driver/execute-transform! exists 2025-07-31 14:16:06 -05:00
Tamás Benkő
c6514d724f Add execution_trigger field 2025-07-31 22:03:29 +03:00
Alexander Polyankin
b288270e19 revert 2025-07-31 14:59:11 -04:00
Tamás Benkő
85ac70539a Restore alphabetical order of :lint-as in kondo's config 2025-07-31 21:21:03 +03:00
Luiz Arakaki
68366d7d37 remove trigger for uberjar 2025-07-31 15:20:36 -03:00
Tamás Benkő
73ecaff97b Merge remote-tracking branch 'origin/master' into poc-transform-table 2025-07-31 21:16:41 +03:00
Luiz Arakaki
63a94c8788 run build and docker uberjar on push 2025-07-31 14:47:46 -03:00
Ryan Laurie
10bd9d68ce Insert into reports from metabot (#61642)
* insert from metabot

* Metabot plugin and hook

* make stuff nicer

* cleanup

* no more snapshots
2025-07-31 11:19:18 -06:00
William Zimrin
e8a9827594 make worker server not transform specific 2025-07-31 11:21:08 -05:00
Tamás Benkő
937d79aa45 Remove live_target and automatic old target deletion 2025-07-31 19:19:09 +03:00
William Zimrin
f14049c3f7 add dev functions for starting and stopping a worker 2025-07-31 11:05:17 -05:00
Eric Normand
3eb5c58e4f [ci skip] Change GET /transform to GET /status 2025-07-31 10:56:37 -05:00
Eric Normand
ab76b6ec5d [ci skip] Better hack for clickhouse 2025-07-31 10:26:30 -05:00
Aleksandr Lesnenko
7221915503 polish 2025-07-31 11:59:02 -03:00
William Zimrin
d808b10d42 polish server/http code a bit 2025-07-31 02:17:45 -05:00
Aleksandr Lesnenko
4b57f82089 saving new report should not show LeaveRouteConfirmModal 2025-07-30 20:19:56 -03:00
Aleksandr Lesnenko
b8d2ac9ebc fix backend not starting 2025-07-30 20:00:53 -03:00
Aleksandr Lesnenko
00e3499af8 remove replace question, drop refresh question 2025-07-30 19:29:04 -03:00
Aleksandr Lesnenko
d81a470a57 drop flushSync error 2025-07-30 19:28:58 -03:00
Aleksandr Lesnenko
c329272406 send used card ids 2025-07-30 19:28:49 -03:00
Aleksandr Lesnenko
75ec895750 less redux 2025-07-30 19:28:42 -03:00
Aleksandr Lesnenko
60ca78c873 remove snapshots 2025-07-30 19:28:35 -03:00
Tamás Benkő
4e8471b305 Throw if the remote execution fails
Also randomize wait times and time out after four hours instead of one.
2025-07-31 00:56:18 +03:00
Tamás Benkő
1247a64fd3 Clean up a bit 2025-07-31 00:09:32 +03:00
William Zimrin
43a13caa9d re-track transform execution 2025-07-30 13:40:12 -05:00
Alexander Polyankin
ad14c66b40 Merge remote-tracking branch 'origin/master' into poc-transform-table 2025-07-30 11:39:26 -04:00
Alexander Polyankin
fa3ff7f21f Merge remote-tracking branch 'origin/master' into poc-transform-table
# Conflicts:
#	resources/migrations/056_update_migrations.yaml
2025-07-30 10:18:58 -04:00
Edward Paget
45ee83044e chore: add enterprise card test 2025-07-30 08:37:43 -05:00
Alexander Polyankin
1a83f3b2a6 navbar [skip ci] 2025-07-29 22:02:25 -04:00
Aleksandr Lesnenko
c43a1c5ca5 switch to prosemirror ast 2025-07-29 23:02:07 -03:00
Alexander Polyankin
3fc49ad4cf navbar [skip ci] 2025-07-29 22:00:42 -04:00
Alexander Polyankin
45a6dfde65 navbar [skip ci] 2025-07-29 21:50:52 -04:00
Alexander Polyankin
f7cddd1d08 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-29 21:46:05 -04:00
Alexander Polyankin
1a0b75cdf9 navbar [skip ci] 2025-07-29 21:45:09 -04:00
Alexander Polyankin
3150aa88a8 navbar [skip ci] 2025-07-29 21:42:12 -04:00
William Zimrin
4cb0b20b6d handle empty worker uris 2025-07-29 20:42:07 -05:00
William Zimrin
2c1f2a7ff3 can run transforms remotely 2025-07-29 20:37:39 -05:00
Alexander Polyankin
5bb0bc8f4c navbar [skip ci] 2025-07-29 21:33:10 -04:00
Alexander Polyankin
0609af8aa8 navbar [skip ci] 2025-07-29 21:31:07 -04:00
William Zimrin
2c17fc4262 execute polish [skip ci] 2025-07-29 20:28:13 -05:00
Eric Normand
69f11c8ebf [ci skip] WIP server 2025-07-29 20:27:25 -05:00
Eric Normand
7bba2731e8 [ci skip] worker run tracking 2025-07-29 20:27:25 -05:00
Eric Normand
84fc383ee5 [ci skip] correct migration env var 2025-07-29 20:27:25 -05:00
Eric Normand
b2e8e0e966 Various fixes to make things compile 2025-07-29 20:27:24 -05:00
Alexander Polyankin
36f8088cdd navbar [skip ci] 2025-07-29 21:25:07 -04:00
Eric Normand
9f264b57a1 [ci skip] Add worker db migrations 2025-07-29 20:24:49 -05:00
William Zimrin
c7aae620a7 add command for starting dummy worker server 2025-07-29 20:24:49 -05:00
Eric Normand
2ff9e7d6d3 Cleanup 2025-07-29 20:24:49 -05:00
Eric Normand
ebbe2e9987 [ci skip] separate transforms for local and remote execution 2025-07-29 20:24:46 -05:00
Alexander Polyankin
71905fca4c navbar [skip ci] 2025-07-29 21:22:20 -04:00
Alexander Polyankin
3de578818a navbar [skip ci] 2025-07-29 21:20:39 -04:00
Aleksandr Lesnenko
a7930e7395 better markdown serde 2025-07-29 22:16:50 -03:00
Alexander Polyankin
5c0fcd368d navbar [skip ci] 2025-07-29 21:10:18 -04:00
Alexander Polyankin
93aa5c4247 navbar [skip ci] 2025-07-29 21:01:35 -04:00
Alexander Polyankin
4e1d08dd1e navbar [skip ci] 2025-07-29 20:57:10 -04:00
Alexander Polyankin
5122bbf99c navbar [skip ci] 2025-07-29 20:46:49 -04:00
Alexander Polyankin
f64b29b62b navbar [skip ci] 2025-07-29 20:34:54 -04:00
Tamás Benkő
e3469a4443 Decouple create/update from execution 2025-07-30 00:53:33 +03:00
Edward Paget
ad58a25fc6 feat: associate cards with reports in create 2025-07-29 16:30:49 -05:00
Edward Paget
7c5f3f11db feat: associate in_report cards w/ report_document 2025-07-29 15:04:47 -05:00
Alexander Polyankin
c24c38ffb5 schedule page [skip ci] 2025-07-29 15:37:29 -04:00
Alexander Polyankin
8dbd232b94 schedule page [skip ci] 2025-07-29 15:31:32 -04:00
Alexander Polyankin
fc67ea2c67 navbar [skip ci] 2025-07-29 13:22:34 -04:00
Alexander Polyankin
3d23c54c79 navbar [skip ci] 2025-07-29 13:17:49 -04:00
Alexander Polyankin
d22e6b3af6 navbar [skip ci] 2025-07-29 13:15:59 -04:00
Alexander Polyankin
2cff4b355b navbar [skip ci] 2025-07-29 13:12:24 -04:00
Alexander Polyankin
188f2b7aea navbar [skip ci] 2025-07-29 13:04:15 -04:00
Alexander Polyankin
b2ca21d29d navbar [skip ci] 2025-07-29 12:54:22 -04:00
Alexander Polyankin
5934f16d16 schedule page [skip ci] 2025-07-29 12:40:37 -04:00
Alexander Polyankin
128a811f65 [skip ci] 2025-07-29 12:26:38 -04:00
Alexander Polyankin
cfa69c2a42 [skip ci] 2025-07-29 12:23:20 -04:00
Alexander Polyankin
e3aa5749e1 [skip ci] 2025-07-29 12:07:14 -04:00
Alexander Polyankin
4dc79d954a [skip ci] 2025-07-29 12:05:07 -04:00
Alexander Polyankin
a615cc4cd1 [skip ci] 2025-07-29 11:58:55 -04:00
Alexander Polyankin
99a52fc794 [skip ci] 2025-07-29 11:56:39 -04:00
Alexander Polyankin
105b0c43f5 [skip ci] 2025-07-29 11:44:15 -04:00
Alexander Polyankin
02e268dee4 [skip ci] 2025-07-29 11:42:17 -04:00
Alexander Polyankin
274acf55a3 pr env 2025-07-29 11:40:10 -04:00
Alexander Polyankin
5fda0f5233 pr env 2025-07-29 11:35:25 -04:00
Alexander Polyankin
2bbd79f443 pr env 2025-07-29 11:29:58 -04:00
Alexander Polyankin
d3fe209827 pr env 2025-07-29 11:21:18 -04:00
Alexander Polyankin
2dd560196d pr env 2025-07-29 11:17:42 -04:00
Alexander Polyankin
c206e276b8 pr env 2025-07-29 10:58:45 -04:00
Alexander Polyankin
9e63e9a14e pr env 2025-07-29 10:54:49 -04:00
Alexander Polyankin
ca01bb83d0 pr env 2025-07-29 10:54:01 -04:00
Alexander Polyankin
1e5315670f pr env 2025-07-29 10:46:54 -04:00
Alexander Polyankin
b51bc9834a pr env 2025-07-29 10:43:30 -04:00
Alexander Polyankin
960c2ad927 pr env 2025-07-29 10:15:27 -04:00
Alexander Polyankin
96f0a64d5e pr env 2025-07-29 10:09:16 -04:00
Alexander Polyankin
351127f13e pr env 2025-07-29 10:04:12 -04:00
Alexander Polyankin
d5900f0ac9 pr env 2025-07-29 09:59:35 -04:00
Alexander Polyankin
2590dfc694 pr env 2025-07-29 09:43:06 -04:00
Alexander Polyankin
fce642921a pr env 2025-07-29 09:27:43 -04:00
Alexander Polyankin
475e5396df pr env 2025-07-29 09:10:41 -04:00
Alexander Polyankin
1bfbdae0e1 pr env 2025-07-29 09:10:03 -04:00
Alexander Polyankin
720f9912e8 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-28 21:29:01 -04:00
Alexander Polyankin
7edd0c374f pr env 2025-07-28 21:27:52 -04:00
Alexander Polyankin
9b708648d1 pr env 2025-07-28 20:41:15 -04:00
Alexander Polyankin
1ad9812411 pr env 2025-07-28 20:29:14 -04:00
Denis Berezin
cff5e706f9 Ditch overall sidebar for now (#61523)
* Remove list of cards sidebar

* Some type fixes

* Review fixes
2025-07-29 01:24:14 +03:00
Tamás Benkő
af7bbb4df6 Drop old table and create the new one in a transaction 2025-07-28 23:11:53 +03:00
Alexander Polyankin
d4e0ba10e2 pr env 2025-07-28 15:31:58 -04:00
Alexander Polyankin
ca2d37115a pr env 2025-07-28 15:15:05 -04:00
Nick Fitzpatrick
f889695914 remove references to static questions 2025-07-28 13:28:00 -03:00
metamben
329bf084db Merge branch 'master' into poc-transform-table 2025-07-28 15:59:18 +03:00
Aleksandr Lesnenko
db61438bd8 cleanup 2025-07-28 03:10:45 -03:00
Ryan Laurie
6191275795 more leave confirmation 2025-07-25 15:24:18 -06:00
Alexander Polyankin
de503b936e Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-25 17:20:51 -04:00
Alexander Polyankin
b608d502be pr env 2025-07-25 17:20:42 -04:00
Ryan Laurie
319cf701dc allow saving in personal collections and our analytics 2025-07-25 15:10:29 -06:00
Tamás Benkő
70eb6821ba [skip ci] Use (possibly qualified) keywords as table names
This is instead of manual quoting.
2025-07-25 22:00:47 +03:00
Tamás Benkő
779cab4b12 [skip ci] Add change target table test 2025-07-25 21:43:13 +03:00
Alexander Polyankin
f795a73c79 [skip ci] 2025-07-25 14:30:56 -04:00
Alexander Polyankin
42b2c620f1 [skip ci] 2025-07-25 14:29:55 -04:00
Alexander Polyankin
b18ef50c42 [skip ci] 2025-07-25 14:24:00 -04:00
Alexander Polyankin
336beeac82 [skip ci] 2025-07-25 14:18:30 -04:00
Alexander Polyankin
4d232ae2a3 [skip ci] 2025-07-25 14:13:29 -04:00
Alexander Polyankin
daab0d5c47 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-25 14:06:50 -04:00
Alexander Polyankin
5c5b133c2d [skip ci] 2025-07-25 14:06:37 -04:00
Alexander Polyankin
c59c17134a [skip ci] 2025-07-25 14:04:27 -04:00
Alexander Polyankin
a3fe7cf37c Fix loading 2025-07-25 14:00:52 -04:00
Tamás Benkő
c32bdd5190 [skip ci] Rename target.table to target.name 2025-07-25 20:52:55 +03:00
Tamás Benkő
2240f0421a [skip ci] Check driver feature before create/update/execution 2025-07-25 20:27:42 +03:00
Alexander Polyankin
2bc92b0f24 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-25 13:21:44 -04:00
Alexander Polyankin
2588c8c45c Fix loading 2025-07-25 13:21:09 -04:00
William Zimrin
b8418c7ca0 remove more stuff 2025-07-25 12:15:27 -05:00
William Zimrin
330f1db962 remove assorted python/transfer code 2025-07-25 12:14:03 -05:00
Alexander Polyankin
bfc1acf7cf Fix loading 2025-07-25 13:12:29 -04:00
Brad Anderson
0995bf3526 Fixes issue where dropping images into reports doesn't work because the events are getting swallowed by react-dnd 2025-07-25 13:01:25 -04:00
Alexander Polyankin
db14555683 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-25 13:01:23 -04:00
Alexander Polyankin
c930bb6bf7 Fix loading 2025-07-25 13:01:11 -04:00
Tamás Benkő
f0bf109b71 Add transform driver features 2025-07-25 19:43:20 +03:00
Alexander Polyankin
1e2bc4a81b Move notebook 2025-07-25 12:31:51 -04:00
Alexander Polyankin
2a64da4037 Move notebook 2025-07-25 12:13:54 -04:00
Tamás Benkő
1a5e648f4f [skip ci] Remove unused required namespaces 2025-07-25 19:10:01 +03:00
Alexander Polyankin
fa64759ed3 Move notebook 2025-07-25 12:07:03 -04:00
Aleksandr Lesnenko
00d8e50082 fix lists 2025-07-25 11:39:47 -04:00
Nick Fitzpatrick
606416ba23 Fixing UX jank with Code Jank 2025-07-25 11:42:09 -03:00
Alexander Polyankin
f0e3873b50 Move notebook 2025-07-25 10:27:33 -04:00
Alexander Polyankin
4ff2e80092 Move notebook 2025-07-25 10:24:25 -04:00
Alexander Polyankin
b7f05e0712 Move notebook 2025-07-25 09:59:23 -04:00
Alexander Polyankin
407ffb01c4 Move notebook 2025-07-25 09:37:54 -04:00
John Swanson
86a55c97d9 fix startup more 2025-07-25 05:40:18 -07:00
John Swanson
850afa8366 fix startup
@nvoxland i *think* this was a stray and not necessary for your metabot
changes but we can add it back along with `dev/src/api/routes.clj` if
i'm wrong!
2025-07-25 05:26:58 -07:00
Tamás Benkő
0aaa3861d5 Fix create and delete issues 2025-07-25 15:05:29 +03:00
Nick Fitzpatrick
0f1eb823d3 adding column blocks and colmns 2025-07-25 09:05:25 -03:00
Vamsi Peri
e437887fb6 Merge branch 'master' into poc-transform-table 2025-07-25 04:25:27 -04:00
Vamsi Peri
c1b1137b59 run CI 2025-07-25 04:22:14 -04:00
Nathan Voxland
64448e927d Added ability to ask metabot about the report you are on 2025-07-24 23:23:27 -05:00
Alexander Polyankin
614f574f4b [skip ci] 2025-07-24 22:39:55 -04:00
Alexander Polyankin
c83231abaa [skip ci] 2025-07-24 22:39:06 -04:00
Aleksandr Lesnenko
f2a3859e32 fix editing viz settings when multiple copied entries 2025-07-24 22:34:08 -04:00
Alexander Polyankin
d690b9b3d1 [skip ci] 2025-07-24 22:29:34 -04:00
Alexander Polyankin
16fa5c09ae [skip ci] 2025-07-24 21:47:08 -04:00
Alexander Polyankin
0aa0fc2f39 [skip ci] 2025-07-24 21:45:22 -04:00
Alexander Polyankin
4d86edd990 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-24 21:16:12 -04:00
Alexander Polyankin
29a500c0e2 [skip ci] 2025-07-24 21:15:57 -04:00
Alexander Polyankin
7544897ab7 [skip ci] 2025-07-24 21:12:52 -04:00
Aleksandr Lesnenko
2c85824963 saved images should render as images 2025-07-24 20:36:42 -04:00
Aleksandr Lesnenko
27420b5cb9 fix excessive space 2025-07-24 20:33:37 -04:00
Alexander Polyankin
d409ac181e [skip ci] 2025-07-24 20:28:12 -04:00
Aleksandr Lesnenko
c3e67fd73e can write 2025-07-24 20:14:06 -04:00
Alexander Polyankin
a7ab60060f [skip ci] 2025-07-24 19:49:57 -04:00
Tamás Benkő
6143a75f9e [skip test] Add support for transform scheduling 2025-07-25 01:33:05 +03:00
Aleksandr Lesnenko
18ab9af893 hack in_report card type to return just question 2025-07-24 18:18:36 -04:00
Ryan Laurie
d3469efb09 frontend readonly support 2025-07-24 15:58:12 -06:00
Brad Anderson
e00d09cc37 Adds ability to add images via markdown to reports 2025-07-24 17:00:47 -04:00
Ryan Laurie
ef602ae2d0 show and save reports in collections 2025-07-24 14:52:38 -06:00
William Zimrin
0eb562cf1c VERY minimal python execution
to set up env:

set up pyenv
  brew install pyenv
  pyenv init # setup .zprofile

set up venv
  pyenv install graalpy-24.2.0
  pyenv shell graalpy-24.2.1
  graalpy -m venv ./venv
  source ./venv/bin/activate
  pip install psycopg2-binary

can run `(execute "import psycopg2; print('hello world')")`
2025-07-24 15:47:52 -05:00
Aleksandr Lesnenko
65c5af5155 refresh snapshot 2025-07-24 16:34:58 -04:00
Aleksandr Lesnenko
eb0e823d1e title click opens question 2025-07-24 16:28:17 -04:00
Eric Normand
02e1014a6b Disable transforms/basic feature for redshift 2025-07-24 15:21:40 -05:00
Eric Normand
23189653d8 Hack for clickhouse (but it works!) 2025-07-24 15:16:53 -05:00
Aleksandr Lesnenko
c6eeb8e6ba drill-through 2025-07-24 16:06:52 -04:00
Eric Normand
22d1f30e24 Fix tests and driver feature 2025-07-24 14:42:32 -05:00
John Swanson
af496e167d check perms on snapshot read/write
to read a snapshot, you need:

- read perms on the collection its report is in, if it has a report
attached, OR

- to be the person who created the snapshot in the first place.

to write a snapshot, you need to be a superuser.
2025-07-24 12:16:36 -07:00
John Swanson
48199ff2f7 add reports to collection items
we'll need to update the frontend to request `report` models, and then
handle them correctly there (with a special icon and links). For now
this won't have any effect, since the FE is selecting specific models
and that doesn't include reports.
2025-07-24 12:16:36 -07:00
Brad Anderson
83ca395ccf Allows links to be pasted into reports 2025-07-24 15:08:45 -04:00
Eric Normand
fa59b190e8 Better tests 2025-07-24 14:03:42 -05:00
Ryan Laurie
bd83d0691b keyboard shortcut for saving 2025-07-24 13:02:05 -06:00
Eric Normand
068fed491b Run tests again with new impl 2025-07-24 13:52:42 -05:00
Eric Normand
9368abef4d Better feature name 2025-07-24 13:27:43 -05:00
Eric Normand
8bfed17f4d Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-24 13:25:56 -05:00
Eric Normand
6f2dff895a Fix tests 2025-07-24 13:25:09 -05:00
Alexander Polyankin
cbfd98f44f [skip ci] 2025-07-24 14:23:30 -04:00
Ryan Laurie
5b2fa90fdb fix new report navigation 2025-07-24 12:19:43 -06:00
Alexander Polyankin
b4bba9efd6 [skip ci] 2025-07-24 14:16:23 -04:00
Alexander Polyankin
eb087c4192 [skip ci] 2025-07-24 13:59:31 -04:00
Alexander Polyankin
f686175537 Add delete confirmation 2025-07-24 13:49:57 -04:00
Alexander Polyankin
5a1ebe95df Add delete confirmation 2025-07-24 13:48:35 -04:00
Alexander Polyankin
c1ea464137 Add delete confirmation 2025-07-24 13:47:30 -04:00
Alexander Polyankin
f6e00a40f5 Add delete confirmation 2025-07-24 13:46:51 -04:00
Alexander Polyankin
776c225467 Add delete confirmation 2025-07-24 13:44:34 -04:00
Alexander Polyankin
0ee16ea060 Add delete confirmation 2025-07-24 13:28:32 -04:00
Alexander Polyankin
6b28bce4ba Add delete confirmation 2025-07-24 13:25:26 -04:00
Alexander Polyankin
7949fe641a Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-24 13:18:13 -04:00
Alexander Polyankin
2b07b83ca5 [skip ci] 2025-07-24 13:18:03 -04:00
Alexander Polyankin
8b46adb273 [skip ci] 2025-07-24 13:17:44 -04:00
Nick Fitzpatrick
436341ab76 Viz Settings for Static Questions 2025-07-24 14:11:43 -03:00
Alexander Polyankin
cd681616bf [skip ci] 2025-07-24 13:02:58 -04:00
John Swanson
aabc1d165f Enforce permissions on reports
Add collection_id to reports, optionally allow setting it in API
endpoints (POST and PUT), enforce perms as follows:

- POST and PUT require superuser

- GET endpoints require read perms on the container collection
2025-07-24 09:44:57 -07:00
Edward Paget
a4747ed263 Add snapshotted timestamp 2025-07-24 11:35:21 -05:00
Ryan Laurie
cb5f72cad4 prevent losing unsaved changes 2025-07-24 10:28:54 -06:00
Brad Anderson
6c0b2d81ad UXW-9 Adds smart links. Changes "insert chart" mechanism from "@" to "starting with /" so @'s can be used for aforementioned smart links. 2025-07-24 12:26:13 -04:00
Alexander Polyankin
504b1836ee [skip ci] 2025-07-24 12:17:08 -04:00
Eric Normand
ed65b849ef [ci skip] two more yaml plans generated by claude 2025-07-24 11:16:07 -05:00
Eric Normand
f060565489 kick ci 2025-07-24 11:14:12 -05:00
Alexander Polyankin
4306da592c [skip ci] 2025-07-24 12:10:30 -04:00
Eric Normand
a39502c482 [ci skip] Multi-file plans implemented 2025-07-24 10:34:45 -05:00
Edward Paget
e0f1ecee2d exclude cards from recent views 2025-07-24 10:11:43 -05:00
Edward Paget
6b8124421a Add in_report card type;
make sure snapshotted cards have it set, exclude it from the collection
view and entity picker.

update the report editor to use the returned card id from the snapshot endpoint
2025-07-24 09:50:37 -05:00
Eric Normand
9814449153 kick ci 2025-07-24 09:20:23 -05:00
Eric Normand
aa014299af Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-24 09:20:17 -05:00
Eric Normand
b8ba1b4997 kick ci 2025-07-24 09:20:08 -05:00
Alexander Polyankin
071489ca25 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 23:44:24 -04:00
Alexander Polyankin
ef1fe0df28 [skip ci] 2025-07-23 23:43:56 -04:00
Alexander Polyankin
f956bca44e [skip ci] 2025-07-23 23:41:07 -04:00
Alexander Polyankin
caa0810c77 [skip ci] 2025-07-23 23:18:10 -04:00
Alexander Polyankin
c29b56e71e [skip ci] 2025-07-23 23:07:18 -04:00
Alexander Polyankin
6ae23161ec [skip ci] 2025-07-23 23:05:18 -04:00
Alexander Polyankin
e284745696 [skip ci] 2025-07-23 22:52:36 -04:00
Aleksandr Lesnenko
df462d07d5 ensure question selected 2025-07-23 22:29:19 -04:00
Aleksandr Lesnenko
4f908179ec there has to be something to save to show the save button 2025-07-23 22:27:01 -04:00
Alexander Polyankin
fb159d257d [skip ci] 2025-07-23 22:20:37 -04:00
Alexander Polyankin
1cb1106954 [skip ci] 2025-07-23 22:20:06 -04:00
Aleksandr Lesnenko
9cbdac4de4 deselect question after embedding so you can type further 2025-07-23 22:19:12 -04:00
Aleksandr Lesnenko
0b4b4dfced padding is too huge 2025-07-23 22:13:19 -04:00
Aleksandr Lesnenko
19f4980a7e fix-hack magical new lines 2025-07-23 22:10:10 -04:00
Alexander Polyankin
d53506d41e [skip ci] 2025-07-23 22:05:31 -04:00
Alexander Polyankin
f7380aa927 [skip ci] 2025-07-23 21:52:21 -04:00
Aleksandr Lesnenko
38e9f21dfb debloat report page, fix used content sidebar 2025-07-23 21:51:46 -04:00
Alexander Polyankin
0a4c544aa9 [skip ci] 2025-07-23 21:49:24 -04:00
Alexander Polyankin
38d0b3e2dd [skip ci] 2025-07-23 21:47:23 -04:00
Alexander Polyankin
2ed5047d90 [skip ci] 2025-07-23 21:37:11 -04:00
Aleksandr Lesnenko
2775a7e069 sidebar button 2025-07-23 21:33:24 -04:00
Aleksandr Lesnenko
d92773df06 use snapshots, fix a few bugs, change viz settings sidebar behavior 2025-07-23 21:16:01 -04:00
Alexander Polyankin
dc093d4722 [skip ci] 2025-07-23 21:04:39 -04:00
Alexander Polyankin
e1494a1442 [skip ci] 2025-07-23 20:33:14 -04:00
Alexander Polyankin
03b9b74699 [skip ci] 2025-07-23 20:12:50 -04:00
Ryan Laurie
023864e72f correct ci skip label check (#61370) 2025-07-23 16:52:43 -06:00
Ryan Laurie
9c479154a5 versions frontend 2025-07-23 15:58:42 -06:00
Eric Normand
c3edf1d082 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-23 16:57:51 -05:00
Eric Normand
1079267144 [ci skip] 6-step analysis plan! 2025-07-23 16:57:22 -05:00
Eric Normand
611d221b12 [ci skip] print progress messages 2025-07-23 16:48:43 -05:00
Nathan Voxland
747781ee29 Renamed duplicate constraints in migrations 2025-07-23 16:44:53 -05:00
Eric Normand
7337c26226 [ci skip] save transfer.yaml example 2025-07-23 16:42:22 -05:00
Eric Normand
10d49abef3 [ci skip] Add rich comment 2025-07-23 16:42:01 -05:00
Nathan Voxland
31503487b3 Merge remote-tracking branch 'origin/feature/reports' into feature/reports 2025-07-23 16:09:37 -05:00
Nathan Voxland
9c7f08c2ae Added ?version argument to GET report-id 2025-07-23 16:09:24 -05:00
Nick Fitzpatrick
8693547be1 Static Quesiton Node 2025-07-23 17:57:43 -03:00
Tamás Benkő
b59e4aa1e1 [skip ci] Fix target table existence check and add cleanup to tests 2025-07-23 23:51:29 +03:00
Edward Paget
29786d318c Replace runs with snapshoting endpoints 2025-07-23 15:33:05 -05:00
Alexander Polyankin
8e83fbbfe7 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 15:54:55 -04:00
Alexander Polyankin
721394d2bc [skip ci] 2025-07-23 15:54:43 -04:00
Alexander Polyankin
6f78bc4107 [skip ci] 2025-07-23 15:39:59 -04:00
Alexander Polyankin
a2b0c4e9e6 [skip ci] 2025-07-23 15:38:51 -04:00
William Zimrin
cff26c60ed fix actions ref [skip ci] 2025-07-23 13:57:58 -05:00
William Zimrin
a92f7f4d51 clean up naming and module refs
[skip ci]
2025-07-23 13:45:35 -05:00
Alexander Polyankin
4dfa96be17 [skip ci] 2025-07-23 14:08:58 -04:00
Eric Normand
90c0d5ad4a Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-23 13:03:39 -05:00
Eric Normand
3bbe285a33 [ci skip] tests for transfers lego 2025-07-23 13:03:17 -05:00
Alexander Polyankin
caaa2e8da5 [skip ci] 2025-07-23 13:59:58 -04:00
Eric Normand
d89c57cd70 [ci skip] return non-nil for success 2025-07-23 12:54:00 -05:00
Alexander Polyankin
e42d8420f0 [skip ci] 2025-07-23 13:53:43 -04:00
Alexander Polyankin
aa05a564da [skip ci] 2025-07-23 13:47:39 -04:00
Tamás Benkő
0416b84c60 [skip ci] Delete transform created in tests 2025-07-23 20:30:55 +03:00
Tamás Benkő
c3b6f4e1ad [skip ci] Hydrate target table at the top level in the get endpoint 2025-07-23 20:26:04 +03:00
Nathan Voxland
9307c56d7b Added versions and reports API 2025-07-23 12:12:01 -05:00
Tamás Benkő
b806d8e2ca [skip ci] Merge list-transform-test vars 2025-07-23 19:54:11 +03:00
Alexander Polyankin
6f29ee6f0d [skip ci] 2025-07-23 12:50:27 -04:00
Alexander Polyankin
75994b3ca9 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 12:49:13 -04:00
Alexander Polyankin
a4e8364b22 [skip ci] 2025-07-23 12:49:00 -04:00
Tamás Benkő
416f44ddcd [skip ci] Add database_id to transform and filter the list by it 2025-07-23 19:48:51 +03:00
Alexander Polyankin
a2aed5ce1b Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 12:31:21 -04:00
Alexander Polyankin
f52fce74d2 [skip ci] 2025-07-23 12:31:04 -04:00
Alexander Polyankin
d1f0e0e6e1 [skip ci] 2025-07-23 12:23:14 -04:00
Eric Normand
0504b74133 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-23 11:19:59 -05:00
Alexander Polyankin
6c74aa8158 [skip ci] 2025-07-23 12:19:19 -04:00
Eric Normand
ff738679f2 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-23 11:18:34 -05:00
Eric Normand
bbe8bc760a [ci skip] Fix actions test 2025-07-23 11:17:51 -05:00
Alexander Polyankin
bd2cc27a5f Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 12:15:58 -04:00
Alexander Polyankin
5803b4f68e [skip ci] 2025-07-23 12:15:48 -04:00
Eric Normand
7dd7c25365 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-23 11:02:22 -05:00
Eric Normand
8f57302804 Fix actions test 2025-07-23 11:01:07 -05:00
Alexander Polyankin
2383c6da0a Modal 2025-07-23 11:56:50 -04:00
Tamás Benkő
c8b01f0336 [skip ci] Add description to transforms 2025-07-23 18:50:02 +03:00
Eric Normand
f711c11b67 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-23 10:48:24 -05:00
Eric Normand
e01545767e [ci skip] Add tests for hippie parse 2025-07-23 10:48:02 -05:00
William Zimrin
ff1f1272ab update execute! schema for transfer 2025-07-23 10:41:20 -05:00
William Zimrin
9beb14ab41 [skip ci] transfers can be called via legos/execute! 2025-07-23 10:38:07 -05:00
Alexander Polyankin
fd48958f63 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 11:35:44 -04:00
Alexander Polyankin
bbe476774e Modal 2025-07-23 11:35:21 -04:00
Eric Normand
b9291ceef2 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-23 10:28:15 -05:00
Eric Normand
5b2fc3dbae Move actions under src 2025-07-23 10:27:54 -05:00
Eric Normand
be2337d27f [ci skip] Add hippie parse 2025-07-23 10:24:00 -05:00
Alexander Polyankin
ea07fdf2f9 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 11:21:20 -04:00
Alexander Polyankin
29a6635dac Modal 2025-07-23 11:21:10 -04:00
Alexander Polyankin
29d46589ed Modal 2025-07-23 11:18:51 -04:00
Tamás Benkő
d22050e590 [skip ci] Remove bad require 2025-07-23 18:10:40 +03:00
Alexander Polyankin
ac3bfcd534 Modal 2025-07-23 11:08:58 -04:00
Alexander Polyankin
380fe7cd08 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 11:02:40 -04:00
Alexander Polyankin
5f576eac43 Modal 2025-07-23 11:02:30 -04:00
Alexander Polyankin
b8a263a0e0 Modal 2025-07-23 11:01:01 -04:00
Edward Paget
2583528cb3 Convert get run endpoints to stream 2025-07-23 09:53:21 -05:00
William Zimrin
0ade82a77a add necessary require to transfers.execute
[skip ci]
2025-07-23 09:44:31 -05:00
Eric Normand
41390fd132 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-23 09:41:40 -05:00
Eric Normand
13f74c882a [ci skip] REVERT LATER Only run transforms api tests for drivers 2025-07-23 09:41:09 -05:00
Edward Paget
33edd890a3 Working save and fetch of run data 2025-07-23 09:13:08 -05:00
Edward Paget
1e8dbe446b Towards working save of runs 2025-07-23 09:13:08 -05:00
Alexander Polyankin
663d0d235e Modal 2025-07-23 10:12:12 -04:00
William Zimrin
4e242895da update call to execute
[skip ci]
2025-07-23 09:00:19 -05:00
Alexander Polyankin
99cbfacba2 Add execute 2025-07-23 09:53:16 -04:00
Alexander Polyankin
042ae3a26a Add execute 2025-07-23 09:50:29 -04:00
Alexander Polyankin
05005f9117 Basic page 2025-07-23 09:39:13 -04:00
Alexander Polyankin
658c2f101d Basic page 2025-07-23 09:32:52 -04:00
Alexander Polyankin
e02d4d0fbc Basic page 2025-07-23 09:31:24 -04:00
Alexander Polyankin
6bdeb7f383 Basic page 2025-07-23 09:16:35 -04:00
Alexander Polyankin
3815358411 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-23 09:10:30 -04:00
Alexander Polyankin
c85ec06431 Basic page 2025-07-23 09:10:01 -04:00
Alexander Polyankin
5322f761c7 Basic page 2025-07-23 09:00:56 -04:00
Alexander Polyankin
cbaa883075 Basic page 2025-07-23 08:49:09 -04:00
Alexander Polyankin
259ca021b8 Basic page 2025-07-23 08:47:56 -04:00
William Zimrin
a753f2ed0a use original case 2025-07-23 02:23:38 -05:00
Alexander Polyankin
a254fc1462 Basic page 2025-07-23 00:01:31 -04:00
Alexander Polyankin
397e369a2c Basic page 2025-07-22 23:46:11 -04:00
Alexander Polyankin
f41c0b171f Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-22 22:53:29 -04:00
Alexander Polyankin
f941af8a14 Basic page 2025-07-22 22:53:14 -04:00
Alexander Polyankin
b340f8ed7d Basic page 2025-07-22 22:33:38 -04:00
Alexander Polyankin
461519495c Basic page 2025-07-22 21:23:46 -04:00
William Zimrin
f7d947ff72 basic transfers work
[ci skip]
2025-07-22 18:19:03 -05:00
Ryan Laurie
ce0e349f21 picard_facepalm 2025-07-22 16:59:54 -06:00
Ryan Laurie
fc5a6469ba saving fixes 2025-07-22 16:42:51 -06:00
Aleksandr Lesnenko
8523bf0412 ui 2025-07-22 18:11:00 -04:00
Aleksandr Lesnenko
13d11d33cd used content 2025-07-22 18:04:05 -04:00
Aleksandr Lesnenko
bb15f10b44 select chart type 2025-07-22 17:59:07 -04:00
Aleksandr Lesnenko
f9241dd461 show viz settings sidebar for embedded questions (#61341)
* show viz settings sidebar for embedded questions

* some redux

* ensure sidebar open

* settings sidebar edits charts viz settings
2025-07-22 17:27:28 -04:00
Nathan Voxland
a4a135903b Added report crud operations 2025-07-22 16:25:19 -05:00
Brad Anderson
67d31cea38 UXW-20 Adds “Browse all” option to SearchResults and RecentsList dropdowns 2025-07-22 17:20:07 -04:00
Eric Normand
2585f352a1 [ci skip] test edn, json, yaml versions 2025-07-22 16:04:21 -05:00
Eric Normand
7443a2d8bc [ci skip] Add provisional transfer code 2025-07-22 15:11:17 -05:00
Eric Normand
d2f02f7846 [ci skip] Add plan 2025-07-22 15:03:00 -05:00
Eric Normand
1e498de06b [ci skip] lego definition (interpreter) for transform 2025-07-22 14:55:58 -05:00
Ryan Laurie
b7e77ac43c add reports API Frontend 2025-07-22 13:41:57 -06:00
Kyle Doherty
89fd0b5c2b text fix 2025-07-22 14:58:17 -04:00
Kyle Doherty
5339aaa1ca layout and styling tweaks 2025-07-22 14:58:17 -04:00
Tamás Benkő
1d9c6c71f3 [skip ci] Sync transform on create an update too 2025-07-22 21:52:31 +03:00
Alexander Polyankin
9e4bca97ce Basic page 2025-07-22 14:45:10 -04:00
Edward Paget
809896ee0a fixup! names on the migrations 2025-07-22 13:26:56 -05:00
Edward Paget
aae3788116 fix: remove namespace test const 2025-07-22 13:07:55 -05:00
Tamás Benkő
821df47bac [skip ci] Sync transform output table on execution 2025-07-22 20:51:58 +03:00
Edward Paget
d743daaf20 feat: add migration and models for reports (#61300)
* feat: add migration and models for reports

* chore: add test for pivot card query caching

* Change column name markdown_docuemnt -> document

Add a content_type column to the table

* feat: add report run table and fk on cards

* feat: report run api start

* feat: run report api skeleton fixes for migration issues

* fixup! make qp run work
2025-07-22 12:46:09 -05:00
Aleksandr Lesnenko
853d9b0cf0 collapse sidebar in the reports view 2025-07-22 13:42:52 -04:00
Eric Normand
d49b886527 [ci skip] Use crowberto in tests and fix api 2025-07-22 12:39:04 -05:00
Tamás Benkő
bb438cee88 [skip ci] Remove superfluous require 2025-07-22 20:07:16 +03:00
Tamás Benkő
c54de85f0a [skip ci] Require superuser 2025-07-22 20:06:47 +03:00
Tamás Benkő
d87da10b7e [skip ci] Fix table existence check 2025-07-22 20:06:40 +03:00
Aleksandr Lesnenko
4e88f438e9 reorganize question embed node files 2025-07-22 13:01:35 -04:00
Ryan Laurie
5b6d472c96 cleanup some llm garbage 2025-07-22 10:38:00 -06:00
Eric Normand
9967f4fa07 [ci skip] Transforms api tests 2025-07-22 11:34:30 -05:00
Eric Normand
9bf91fea0e [ci skip] Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-22 11:11:22 -05:00
Eric Normand
d989b5b8c3 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-22 11:09:50 -05:00
Alexander Polyankin
706d7f5dc4 Fix 2025-07-22 12:07:12 -04:00
Eric Normand
bb85233eff Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-22 11:06:29 -05:00
Alexander Polyankin
0e1450f72d Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table
# Conflicts:
#	enterprise/backend/src/metabase_enterprise/transforms/api.clj
2025-07-22 12:06:15 -04:00
Tamás Benkő
e64ecbbcad Fix table exists check 2025-07-22 19:04:02 +03:00
Ryan Laurie
60373ead57 add markdown exports
# Conflicts:
#	enterprise/frontend/src/metabase-enterprise/reports/components/ReportPage.tsx
2025-07-22 10:01:30 -06:00
Alexander Polyankin
febfce8260 Fix 2025-07-22 12:00:10 -04:00
Ryan Laurie
a4a927799d Enable CI skipping on PRs (#61307)
* dont run tests on pr with ci skip

* static viz too

* label instead of title
2025-07-22 09:59:24 -06:00
Eric Normand
38c5fa5892 [ci skip] more parsing 2025-07-22 10:57:21 -05:00
Eric Normand
b7247b23e8 [ci skip] Better api parameter handling 2025-07-22 10:56:27 -05:00
Aleksandr Lesnenko
8e75cd74f4 Report editing (#61290)
* basic tiptap

* new menu item

* vibecoded embedding

* lint, prettier

* layout

* refactor vibecoded layout

* ui

* useless placeholder

* fix selection

* lint

* ui

* ui

* ux

* ui

* experimental, possibly revert: notebook editor
2025-07-22 11:52:14 -04:00
Tamás Benkő
a550e694b8 [skip ci] Drop target database-id, check if the target table exists 2025-07-22 15:57:35 +03:00
Tamás Benkő
aaee0896e6 Require metabase-enterprise.transforms.models.transform in init 2025-07-22 15:11:55 +03:00
Alexander Polyankin
d495f23ea5 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-21 20:41:49 -04:00
Alexander Polyankin
0648c78e1a Transforms page 2025-07-21 20:38:35 -04:00
metamben
1964438bbe Merge branch 'master' into poc-transform-table 2025-07-22 01:42:24 +03:00
Tamás Benkő
87c5dd88d1 [ci skip] Prepend schema to table if provided 2025-07-22 01:37:40 +03:00
Tamás Benkő
a8d1dcc496 [ci skip] Enable selective updates 2025-07-22 01:27:06 +03:00
Tamás Benkő
9b8f61def1 Check native query execution right when executing a transform 2025-07-22 00:54:33 +03:00
Eric Normand
27edaf23e6 Delete table. 2025-07-21 16:46:39 -05:00
Tamás Benkő
fccfa9460b Placate clj-kondo 2025-07-22 00:04:58 +03:00
Eric Normand
eba97140f7 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-21 15:45:45 -05:00
Eric Normand
9320a8fcc2 Use toucan to implement API endpoints 2025-07-21 15:44:19 -05:00
William Zimrin
f276dabad9 h2 doesn't support transforms 2025-07-21 15:32:47 -05:00
Eric Normand
f1fdf99562 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-07-21 15:19:40 -05:00
Eric Normand
eb4e329cd2 Transform migration + toucan model 2025-07-21 15:19:19 -05:00
William Zimrin
ec44b108ff make execute actually execute 2025-07-21 15:16:28 -05:00
William Zimrin
d37fdca122 actually fetch the query from the result of compile 2025-07-21 15:03:48 -05:00
William Zimrin
717b6bda5a use compile-with-inline-parameters 2025-07-21 15:02:31 -05:00
William Zimrin
7a9c5ba1ee some untested dummy routes 2025-07-21 15:00:48 -05:00
Tamás Benkő
7620abe8f7 Fix POST input schema 2025-07-21 22:33:18 +03:00
Alexander Polyankin
d87a7ba751 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-21 15:32:09 -04:00
William Zimrin
29ebc2e48b basic transform execution 2025-07-21 14:31:55 -05:00
Alexander Polyankin
fa0c36e0c8 Transforms page 2025-07-21 15:29:44 -04:00
Tamás Benkő
316c16f333 Return an entry from the list endpoint 2025-07-21 22:27:52 +03:00
Alexander Polyankin
5f60c16f30 Transforms page 2025-07-21 15:25:00 -04:00
Alexander Polyankin
9c30b51ec0 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-07-21 15:00:22 -04:00
Alexander Polyankin
09ec30117f Add a turn into transform modal 2025-07-21 15:00:02 -04:00
William Zimrin
64f799a046 add dummy execute function 2025-07-21 13:53:43 -05:00
Tamás Benkő
117e923663 Refer to module with plural 2025-07-21 21:52:25 +03:00
Tamás Benkő
87ed3b5965 Add metabase-enterprise.transform module 2025-07-21 21:46:40 +03:00
Alexander Polyankin
2f13cb033d Basic UI 2025-07-21 14:34:31 -04:00
Alexander Polyankin
1434d05ebd Empty 2025-07-21 13:04:28 -04:00
Alexander Polyankin
b5e258f80d Empty 2025-07-21 13:04:03 -04:00
Aleksandr Lesnenko
8252df95f2 actually tsx 2025-07-21 12:47:54 -04:00
Aleksandr Lesnenko
1cd6a43417 report route 2025-07-21 12:18:23 -04:00
299 changed files with 61920 additions and 394 deletions

View File

@@ -410,9 +410,10 @@
clojurewerkz.quartzite.jobs/defjob clojure.core/defn
instaparse.core/defparser clojure.core/def
metabase-enterprise.action-v2.test-util/with-test-tables! clojure.core/let
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
metabase-enterprise.serialization.test-util/with-dbs clojure.core/fn
metabase-enterprise.serialization.test-util/with-random-dump-dir clojure.core/let
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
metabase-enterprise.transforms.test-util/with-transform-cleanup! clojure.core/let
metabase.actions.test-util/with-actions clojure.core/let
metabase.api.common/let-404 clojure.core/let
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn

View File

@@ -1852,6 +1852,25 @@
settings
util}}
enterprise/transforms
{:team "Querying"
:api #{metabase-enterprise.transforms.api
metabase-enterprise.transforms.init}
:uses #{api
config
driver
events
lib
models
permissions
query-processor
settings
sync
task
task-history
util
enterprise/worker}}
enterprise/upload-management
{:team "Admin Webapp"
:api #{metabase-enterprise.upload-management.api}
@@ -1859,6 +1878,14 @@
models
premium-features
upload
util}}
enterprise/worker
{:team "Querying"
:api #{metabase-enterprise.transforms.api
metabase-enterprise.transforms.core}
:uses #{config
system
util}}}
;; namespaces matching these patterns (with `re-find`) are excluded from the module linter. Since regex literals

View File

@@ -27,7 +27,7 @@ runs:
env:
MB_EDITION: ee
- name: Test database driver
run: clojure -X:dev:ci:ee:ee-dev:drivers:drivers-dev:test ${{ inputs.test-args }}
run: clojure -X:dev:ci:ee:ee-dev:drivers:drivers-dev:test :only metabase-enterprise.transforms.api-test ${{ inputs.test-args }}
shell: bash
- name: Publish Test Report (JUnit)
uses: dorny/test-reporter@v1

View File

@@ -51,6 +51,7 @@
"Put everything needed for REPL development within easy reach"
(:require
[clojure.core.async :as a]
[clojure.main]
[clojure.string :as str]
[clojure.test]
[dev.debug-qp :as debug-qp]
@@ -100,6 +101,8 @@
model-tracking/keep-me
dev.h2/keep-me)
(apply require clojure.main/repl-requires)
#_:clj-kondo/ignore
(defn tap>-spy [x]
(doto x tap>))
@@ -194,6 +197,16 @@
(stop!)
(start!))
(defn start-worker!
"Start metabase worker"
[]
((requiring-resolve 'metabase-enterprise.worker.core/start!) {:dev true}))
(defn stop-worker!
"Stop metabase worker"
[]
((requiring-resolve 'metabase-enterprise.worker.core/stop!)))
(defn ns-unmap-all
"Unmap all interned vars in a namespace. Reset the namespace to a blank slate! Perfect for when you rename everything
and want to make sure you didn't miss a reference or when you redefine a multimethod.

View File

@@ -22,7 +22,6 @@
[toucan2.tools.before-update :as t2.before-update]
[toucan2.util :as t2.util]))
(def changes*
"An atom to store all the changes of models that we currently track."
(atom {}))
@@ -79,7 +78,7 @@
:update
(into {} (t2/changes row-or-instance))
(into {} row-or-instance))
clean-change)
clean-change)
path [(t2/table-name model) action]]
;; ideally this should be debug, but for some reasons this doesn't get logged
(on-change path change-info)
@@ -117,6 +116,9 @@
(track-one! model)
(swap! tracked-models conj model)))
(comment
(track! :model/Collection :model/Workspace))
(defn- untrack-one!
[model]
(doseq [[hook aux-method _action] hook+aux-method+action+deriveable]

View File

@@ -29,11 +29,13 @@ title: Driver interface changelog
- Added the driver multi-method `driver/set-database-used!` for drivers to set a database on the connection with statements like `USE DATABASE`.
- Added the driver features `:transforms/table`, `:transforms/view`, and `:transforms/matarialized-view` for drivers
that support transforms with table, view, and materialized view as target, respectively.
## Metabase 0.55.9
- Add multi-method `driver/do-with-resilient-connection` for executing functions in a context where closed connections may be automatically reopened
## Metabase 0.55.0
- Add the multi-method `->date` that allows the driver to control how to cast strings and temporal types to dates.

View File

@@ -18,6 +18,7 @@
[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.gsheets.api :as gsheets.api]
[metabase-enterprise.llm.api]
@@ -28,7 +29,9 @@
[metabase-enterprise.scim.routes]
[metabase-enterprise.serialization.api]
[metabase-enterprise.stale.api]
[metabase-enterprise.transforms.api]
[metabase-enterprise.upload-management.api]
[metabase-enterprise.workspaces.api]
[metabase.api.macros :as api.macros]
[metabase.api.util.handlers :as handlers]
[metabase.util.i18n :refer [deferred-tru]]))
@@ -49,12 +52,15 @@
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
:llm-autodescription (deferred-tru "LLM Auto-description")
:metabot-v3 (deferred-tru "MetaBot")
:documents (deferred-tru "Documents")
:scim (deferred-tru "SCIM configuration")
: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")})
:cloud-custom-smtp (deferred-tru "Custom SMTP")
:workspaces (deferred-tru "Workspaces")})
(defn- premium-handler [handler required-feature]
(let [handler (cond-> handler
@@ -98,10 +104,13 @@
"/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)
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
"/stale" (premium-handler metabase-enterprise.stale.api/routes :collection-cleanup)
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)})
"/transform" (premium-handler metabase-enterprise.transforms.api/routes :transforms)
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)
"/workspace" (premium-handler metabase-enterprise.workspaces.api/routes :workspaces)})
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑
(def ^:private routes-map

View File

@@ -11,9 +11,11 @@
[metabase-enterprise.audit-app.init]
[metabase-enterprise.cache.init]
[metabase-enterprise.database-replication.init]
[metabase-enterprise.documents.init]
[metabase-enterprise.gsheets.init]
[metabase-enterprise.llm.init]
[metabase-enterprise.metabot-v3.init]
[metabase-enterprise.scim.init]
[metabase-enterprise.sso.init]
[metabase-enterprise.stale.init]))
[metabase-enterprise.stale.init]
[metabase-enterprise.transforms.init]))

View File

@@ -0,0 +1,13 @@
(ns metabase-enterprise.documents.api
"`/api/ee/document/` routes"
(:require
[metabase-enterprise.documents.api.document]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/document` routes."
(handlers/routes
(api.macros/ns-handler *ns* +auth)
metabase-enterprise.documents.api.document/routes))

View File

@@ -0,0 +1,201 @@
(ns metabase-enterprise.documents.api.document
(:require
[metabase-enterprise.documents.models.document :as m.document]
[metabase-enterprise.documents.prose-mirror :as prose-mirror]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.collections.models.collection :as collection]
[metabase.events.core :as events]
[metabase.models.interface :as mi]
[metabase.queries.models.card :as card]
[metabase.query-permissions.core :as query-perms]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(def ^:private CardCreateSchema
"Schema for creating a new card - simplified version to avoid circular dependencies"
[:map
[:name ms/NonBlankString]
[:type {:optional true} [:maybe [:= "question"]]]
[:dataset_query ms/Map]
[:entity_id {:optional true} [:maybe ms/NonBlankString]]
[:parameters {:optional true} [:maybe [:sequential ms/Map]]]
[:parameter_mappings {:optional true} [:maybe [:sequential ms/Map]]]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:display ms/NonBlankString]
[:visualization_settings ms/Map]
[:result_metadata {:optional true} [:maybe [:sequential ms/Map]]]
[:cache_ttl {:optional true} [:maybe ms/PositiveInt]]])
(defn- create-card!
"Checks that the query is runnable by the current user then saves"
[{query :dataset_query :as card} creator]
(query-perms/check-run-permissions-for-query query)
(card/create-card! card creator))
(mu/defn- update-cards-in-ast :- [:map [:document :any]
[:content_type :string]]
[document :- [:map
[:document :any]
[:content_type :string]]
card-id-map :- [:maybe [:map-of :int ms/PositiveInt]]]
(cond-> document
(map? document)
(prose-mirror/update-ast (fn match-card-to-update [{:keys [type attrs]}]
(and (= type prose-mirror/card-embed-type)
(contains? card-id-map (:id attrs))))
(fn update-card-id [embed]
(update-in embed [:attrs :id] card-id-map)))))
(mu/defn- create-cards-for-document! :- [:map-of ms/NegativeInt ms/PositiveInt]
"Creates cards for a document from the cards map.
Returns a mapping from the original negative integer keys to the newly created card IDs.
Args:
- cards-to-create: Map of negative-int -> CardCreateSchema data
- document-id: ID of the document these cards belong to
- document-collection-id: Collection ID of the document (for inheritance)
- creator: User creating the cards
Returns:
- Map of negative-int -> actual-card-id"
[cards-to-create :- [:map-of [:int {:max -1}] CardCreateSchema]
document-id :- ms/PositiveInt
document-collection-id :- [:or :nil ms/PositiveInt]
creator :- [:map [:id ms/PositiveInt]]]
(when (seq cards-to-create)
(reduce-kv
(fn [result-map original-key card-data]
(let [;; Merge document info into card data
;; Cards inherit document's collection_id if not explicitly specified
merged-card-data (-> card-data
(assoc :document_id document-id)
(cond-> (nil? (:collection_id card-data))
(assoc :collection_id document-collection-id)))
;; Create the card using the queries core function
new-card (create-card! merged-card-data creator)]
(assoc result-map original-key (:id new-card))))
{}
cards-to-create)))
(mu/defn- clone-cards-in-document! :- [:map-of ms/PositiveInt ms/PositiveInt]
"Finds all cards in the document that are not associated with the document and clones the cards.
Args:
- document: the document model to clone cards within
Returns:
- map of old-card-id -> cloned-card-id"
[{:keys [id collection_id] :as document}]
(let [card-ids (prose-mirror/collect-ast document #(when (and (= prose-mirror/card-embed-type (:type %))
(pos? (-> % :attrs :id)))
(-> % :attrs :id)))
to-clone (when (seq card-ids)
(t2/select :model/Card {:where [:and [:in :id card-ids]
[:or [:<> :document_id id]
[:= :document_id nil]]]}))]
(reduce (fn [accum card]
(api/read-check card)
(assoc accum
(:id card)
(:id (create-card! (assoc card :document_id id :collection_id collection_id)
@api/*current-user*))))
{}
to-clone)))
(defn get-document
"Get document by id checking if the current user has permission to access and if the document exists."
[id]
(api/check-404
(api/read-check
(t2/hydrate (t2/select-one :model/Document :id id) :creator :can_write))))
(api.macros/defendpoint :get "/"
"Gets existing `Documents`."
[_route-params
_query-params]
(t2/hydrate (t2/select :model/Document {:where (collection/visible-collection-filter-clause)}) :creator :can_write))
(api.macros/defendpoint :post "/"
"Create a new `Document`."
[_route-params
_query-params
{:keys [name document collection_id cards]}
:- [:map
[:name :string]
[:document :any]
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:cards {:optional true} [:maybe [:map-of [:int {:max -1}] CardCreateSchema]]]]]
(collection/check-write-perms-for-collection collection_id)
(let [document-id (t2/with-transaction [_conn]
;; Validate existing cards first if provided
(let [document-id (t2/insert-returning-pk! :model/Document {:name name
:collection_id collection_id
:document document
:content_type prose-mirror/prose-mirror-content-type
:creator_id api/*current-user-id*})
cards-to-update-in-ast (merge (clone-cards-in-document! {:id document-id
:collection_id collection_id
:document document
:content_type prose-mirror/prose-mirror-content-type})
(when-not (empty? cards)
(create-cards-for-document! cards document-id collection_id @api/*current-user*)))]
;; Create new cards if provided
(when (seq cards-to-update-in-ast)
(t2/update! :model/Document :id document-id
(update-cards-in-ast
{:document document
:content_type prose-mirror/prose-mirror-content-type}
cards-to-update-in-ast)))
document-id))
created-document (get-document document-id)]
;; Publish event after successful creation
(events/publish-event! :event/document-create
{:object created-document
:user-id api/*current-user-id*})
created-document))
(api.macros/defendpoint :get "/:document-id"
"Returns an existing Document by ID."
[{:keys [document-id]} :- [:map [:document-id ms/PositiveInt]]]
(api/read-check (get-document document-id)))
(api.macros/defendpoint :put "/:document-id"
"Updates an existing `Document`."
[{:keys [document-id]} :- [:map
[:document-id ms/PositiveInt]]
_query-params
{:keys [name document collection_id cards] :as body} :- [:map
[:name {:optional true} :string]
[:document {:optional true} :any]
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:cards {:optional true} [:maybe [:map-of :int CardCreateSchema]]]]]
(let [existing-document (api/check-404 (get-document document-id))]
(api/check-403 (mi/can-write? existing-document))
(when (api/column-will-change? :collection_id existing-document body)
(m.document/validate-collection-move-permissions (:collection_id existing-document) collection_id))
(t2/with-transaction [_conn]
(t2/update! :model/Document document-id (cond-> {}
document (merge (update-cards-in-ast
{:document document
:content_type (:content_type existing-document)}
(merge
(clone-cards-in-document! (assoc existing-document :document document))
(when-not (empty? cards) (create-cards-for-document! cards document-id collection_id @api/*current-user*)))))
name (assoc :name name)
(contains? body :collection_id) (assoc :collection_id collection_id))))
(let [updated-document (get-document document-id)]
;; Publish event after successful update
(events/publish-event! :event/document-update
{:object updated-document
:user-id api/*current-user-id*})
updated-document)))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/document/` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,3 @@
(ns metabase-enterprise.documents.init
(:require
[metabase-enterprise.documents.revisions.impl]))

View File

@@ -0,0 +1,54 @@
(ns metabase-enterprise.documents.models.document
(:require
[metabase.api.common :as api]
[metabase.collections.models.collection :as collection]
[metabase.models.interface :as mi]
[metabase.permissions.core :as perms]
[metabase.users.models.user]
[metabase.util.log :as log]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/Document [_model] :document)
(t2/deftransforms :model/Document
{:document mi/transform-json})
(doto :model/Document
(derive :metabase/model)
(derive :perms/use-parent-collection-perms)
(derive :hook/timestamped?))
(defn validate-collection-move-permissions
"Validates that the current user has write permissions for both old and new collections
when moving a document. Uses the same permission pattern as check-allowed-to-change-collection.
Throws 403 exception if permissions are insufficient."
[old-collection-id new-collection-id]
(when old-collection-id
(collection/check-write-perms-for-collection old-collection-id))
(when new-collection-id
(collection/check-write-perms-for-collection new-collection-id))
(when new-collection-id
(api/check-400 (t2/exists? :model/Collection :id new-collection-id :archived false))))
(methodical/defmethod t2/batched-hydrate [:model/Document :creator]
"Hydrate the creator (user) of a document based on the creator_id."
[_model k documents]
(mi/instances-with-hydrated-data
documents k
#(-> (t2/select [:model/User :id :email :first_name :last_name] :id (keep :creator_id documents))
(map (juxt :id identity))
(into {}))
:creator_id {:default {}}))
(defn sync-document-cards-collection!
"Updates all cards associated with a document to match the document's collection.
Returns the number of cards updated."
[document-id collection-id]
(t2/update! :model/Card
:document_id document-id
{:collection_id collection-id}))
(t2/define-after-update :model/Document
[{:keys [id collection_id]}]
(sync-document-cards-collection! id collection_id))

View File

@@ -0,0 +1,53 @@
(ns metabase-enterprise.documents.prose-mirror
"Manipulate the prose mirror ast for documents"
(:require
[clojure.walk :as walk]))
(def card-embed-type
"Type of a card-embed node"
"cardEmbed")
(def prose-mirror-content-type
"The vendored 'mime-type' for documents saved using the prose-mirror ast."
"application/json+vnd.prose-mirror")
(defn- assert-prose-mirror
"Asserts the content-type is correct for the document or throw"
[{:keys [content_type]}]
(when-not (= content_type prose-mirror-content-type)
(throw (ex-info "Document does not have the prose mirror content-type"
{:content-type content_type
:status-code 400}))))
(defn update-ast
"Update a node that matches a predicate using a post-walk.
Args:
- document - a :model/Document, this will check that content type is valid for prose mirror
- predicate - a one-arg function returning true a given node should be updated
- updater - a one-arg function taking the node and return the new node
Returns:
- the updated prose-mirror ast"
[{:keys [document] :as doc} predicate updater]
(assert-prose-mirror doc)
(assoc doc :document
(walk/postwalk (fn ast-walker
[node]
(cond-> node
(predicate node) updater))
document)))
(defn collect-ast
"Collect values from the ast lazily removes nils
Args:
- document - a :model/Document, this will check that the content-type is valid for prose mirror
- collector - a function that extracts values from a given node
Returns:
- a lazy seq of results from collector"
[{:keys [document] :as doc} collector]
(assert-prose-mirror doc)
(->> (tree-seq :content :content document)
(keep collector)))

View File

@@ -0,0 +1,28 @@
(ns metabase-enterprise.documents.revisions.impl
"Document revision serialization implementation.
Implements Document revision serialization following the Card exclusion pattern,
excluding metadata columns while preserving content, name, and other Document-specific fields."
(:require
[metabase.revisions.models.revision :as revision]))
(def ^:private excluded-columns-for-document-revision
"Columns to exclude from Document revision serialization.
Excludes metadata columns (timestamps, IDs, collection_id, creator_id, etc.)
following the same pattern established for Card revisions.
The :document field contains the actual content and is preserved."
#{:id
:creator_id
:created_at
:updated_at
:collection_id})
(defmethod revision/serialize-instance :model/Document
[_model _id instance]
(apply dissoc instance excluded-columns-for-document-revision))
(defmethod revision/revert-to-revision! :model/Document
[model id user-id serialized-document]
((get-method revision/revert-to-revision! :default) model id user-id serialized-document))

View File

@@ -1,6 +1,7 @@
(ns metabase-enterprise.metabot-v3.dummy-tools
(:require
[medley.core :as m]
[metabase-enterprise.documents.api.document :as documents.api]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.legacy-mbql.normalize :as mbql.normalize]
@@ -289,6 +290,21 @@
{:structured-output details}
{:output (or details "report not found")}))))
(defn get-document-details
"Get information about the document with ID `document-id`."
[{:keys [document-id]}]
(if (int? document-id)
(try
(if-let [doc (documents.api/get-document document-id nil)]
(do
{:structured-output {:id (:id doc)
:name (:name doc)
:document (:document doc)}})
{:output "report not found"})
(catch Exception e
{:output (str "error fetching report: " (.getMessage e))}))
{:output "invalid document_id"}))
(defn- execute-query
[query-id legacy-query]
(let [legacy-query (mbql.normalize/normalize legacy-query)

View File

@@ -540,6 +540,24 @@
[:result_columns ::columns]]]]
[:map [:output :string]]])
(mr/def ::get-document-details-arguments
[:and
[:map
[:document_id :int]]
[:map {:encode/tool-api-request
#(set/rename-keys % {:document_id :document-id})}]])
(mr/def ::get-document-details-result
[:or
[:map
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:map
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:id :int]
[:name :string]
[:document :string]]]]
[:map [:output :string]]])
(mr/def ::get-table-details-arguments
[:and
[:map
@@ -777,6 +795,21 @@
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/get-document-details" :- [:merge ::get-document-details-result ::tool-request]
"Get information about a given report."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::get-document-details-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :get-document-details) :llm.log/llm->be)
(let [arguments (mc/encode ::get-document-details-arguments arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (mc/decode ::get-document-details-result
(metabot-v3.dummy-tools/get-document-details arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/get-table-details" :- [:merge ::get-table-details-result ::tool-request]
"Get information about a given table or model."
[_route-params

View File

@@ -0,0 +1,187 @@
(ns metabase-enterprise.transforms.api
(:require
[clojure.set :as set]
[metabase-enterprise.transforms.execute :as transforms.execute]
[metabase-enterprise.transforms.util :as transforms.util]
[metabase-enterprise.worker.core :as worker]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.driver.util :as driver.u]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.log :as log]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[ring.util.response :as response]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(mr/def ::transform-source
[:map
[:type [:= "query"]]
[:query [:map [:database :int]]]])
(mr/def ::transform-target
[:map
[:type [:enum "table" "view"]]
[:schema {:optional true} :string]
[:name :string]])
(mr/def ::execution-trigger
[:enum "none" "global-schedule"])
(comment
;; Examples
[{:id 1
:name "Gadget Products"
:source {:type "query"
:query {:database 1
:type "native",
:native {:query "SELECT * FROM PRODUCTS WHERE CATEGORY = 'Gadget'"
:template-tags {}}}}
:target {:type "table"
:schema "transforms"
:name "gadget_products"}}]
-)
(defn- source-database-id
[transform]
(-> transform :source :query :database))
(defn- check-database-feature
[transform]
(let [database (api/check-400 (t2/select-one :model/Database (source-database-id transform))
(deferred-tru "The source database cannot be found."))
feature (transforms.util/required-database-feature transform)]
(api/check-400 (driver.u/supports? (:engine database) feature database)
(deferred-tru "The database does not support the requested transform target type."))))
(api.macros/defendpoint :get "/"
"Get a list of transforms."
[_route-params
_query-params]
(api/check-superuser)
(t2/hydrate (t2/select :model/Transform) :last_execution))
(api.macros/defendpoint :post "/"
[_route-params
_query-params
body :- [:map
[:name :string]
[:description {:optional true} [:maybe :string]]
[:source ::transform-source]
[:target ::transform-target]
[:execution_trigger {:optional true} ::execution-trigger]]]
(api/check-superuser)
(check-database-feature body)
(when (transforms.util/target-table-exists? body)
(api/throw-403))
(t2/insert-returning-instance!
:model/Transform (select-keys body [:name :description :source :target :execution_trigger])))
(api.macros/defendpoint :get "/:id"
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(log/info "get transform" id)
(api/check-superuser)
(let [{:keys [target] :as transform} (api/check-404 (t2/select-one :model/Transform id))
database-id (source-database-id transform)
target-table (transforms.util/target-table database-id target :active true)]
(-> transform
(t2/hydrate :last_execution)
(assoc :table target-table))))
(api.macros/defendpoint :get "/execution"
[params :-
[:map
[:offset ms/IntGreaterThanOrEqualToZero]
[:limit ms/IntGreaterThanOrEqualToZero]
[:sort_column {:optional true} [:enum "started_at" "ended_at"]]
[:sort_direction {:optional true} [:enum "asc" "desc"]]
[:transform_id {:optional true} ms/IntGreaterThanOrEqualToZero]
[:status {:optional true} [:enum "started" "succeeded" "failed" "timeout"]]]]
(log/info "get executions")
(api/check-superuser)
(update (worker/paged-executions (-> params
(set/rename-keys {:transform_id :work_id})
(assoc :work_type "transform")))
:data #(mapv (fn [run]
(-> run
(set/rename-keys {:run_id :id
:work_id :transform_id
:run_method :trigger
:start_time :started_at
:end_time :ended_at})))
%)))
(api.macros/defendpoint :put "/:id"
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]
_query-params
body :- [:map
[:name {:optional true} :string]
[:description {:optional true} [:maybe :string]]
[:source {:optional true} ::transform-source]
[:target {:optional true} ::transform-target]
[:execution_trigger {:optional true} ::execution-trigger]]]
(log/info "put transform" id)
(api/check-superuser)
(let [old (t2/select-one :model/Transform id)
new (merge old body)
target-fields #(-> % :target (select-keys [:schema :name]))]
(check-database-feature new)
(when (and (not= (target-fields old) (target-fields new))
(transforms.util/target-table-exists? new))
(api/throw-403)))
(t2/update! :model/Transform id body)
(t2/select-one :model/Transform id))
(api.macros/defendpoint :delete "/:id"
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(log/info "delete transform" id)
(api/check-superuser)
(t2/delete! :model/Transform id)
nil)
(api.macros/defendpoint :delete "/:id/table"
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(log/info "delete transform target table" id)
(api/check-superuser)
(transforms.util/delete-target-table-by-id! id)
nil)
(api.macros/defendpoint :post "/cancel/:run-id"
[{:keys [run-id]} :- [:map
[:id :string]]]
(log/info "canceling run " run-id)
(api/check-superuser)
(worker/mark-cancel-started-run! run-id)
nil)
(api.macros/defendpoint :post "/:id/execute"
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(log/info "execute transform" id)
(api/check-superuser)
(let [transform (api/check-404 (t2/select-one :model/Transform id))
start-promise (promise)]
(future
(transforms.execute/execute-mbql-transform! transform {:start-promise start-promise
:run-method :manual}))
(when (instance? Throwable @start-promise)
(throw @start-promise))
(let [result @start-promise
run-id (when (and (vector? result) (= (first result) :started))
(second result))]
(-> (response/response {:message (deferred-tru "Transform execution started")
:run_id run-id})
(assoc :status 202)))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/transform` routes."
(handlers/routes
(api.macros/ns-handler *ns* +auth)))

View File

@@ -0,0 +1,2 @@
(ns metabase-enterprise.transforms.core
"API namespace for the `metabase-enterprise.transform` module.")

View File

@@ -0,0 +1,240 @@
(ns metabase-enterprise.transforms.execute
(:require
[clojure.core.async :as a]
[clojure.string :as str]
[medley.core :as m]
[metabase-enterprise.transforms.ordering :as transforms.ordering]
[metabase-enterprise.transforms.sync :as transforms.sync]
[metabase-enterprise.transforms.util :as transforms.util]
[metabase-enterprise.worker.core :as worker]
[metabase.driver :as driver]
[metabase.driver.util :as driver.u]
[metabase.lib.schema.common :as schema.common]
[metabase.query-processor.pipeline :as qp.pipeline]
[metabase.sync.core :as sync]
[metabase.task.core :as task]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli.registry :as mr]
[toucan2.core :as t2])
(:import
(java.util.concurrent Executors ExecutorService Future ScheduledExecutorService TimeUnit)))
(set! *warn-on-reflection* true)
(defonce ^:private ^ScheduledExecutorService scheduler (Executors/newScheduledThreadPool 1))
(defonce ^:private ^ExecutorService executor (Executors/newVirtualThreadPerTaskExecutor))
(mr/def ::transform-details
[:map
[:transform-type [:enum {:decode/normalize schema.common/normalize-keyword} :view :table]]
[:connection-details :any]
[:query :string]
[:output-table [:keyword {:decode/normalize schema.common/normalize-keyword}]]])
(mr/def ::transform-opts
[:map
[:overwrite? :boolean]])
(defn- sync-table!
[database target]
(let [table (or (transforms.util/target-table (:id database) target)
(sync/create-table! database (select-keys target [:schema :name])))]
(sync/sync-table! table)))
(defn- execute-mbl-transform-remote! [run-id driver transform-details opts]
(try
(worker/execute-transform! run-id driver transform-details opts)
(catch Throwable t
(log/error "Remote execution failed.")
(worker/fail-started-run! run-id {:message (.getMessage t)})
(throw t))))
(defn- sync-target!
([transform-id run-id]
(let [{:keys [source target]} (t2/select-one :model/Transform transform-id)
db (get-in source [:query :database])
database (t2/select-one :model/Database db)]
(sync-target! target database run-id)))
([target database _run-id]
;; sync the new table (note that even a failed sync status means that the execution succeeded)
(log/info "Syncing target" (pr-str target) "for transform")
(sync-table! database target)))
;; register that we need to run sync after a transform is finished remotely
(defmethod transforms.sync/post-success :transform
[{:keys [run_id work_id]}]
(sync-target! work_id run_id))
(defonce ^:private connections (atom {}))
(defn- cancel-run! [run-id]
(when-some [cancel-chan (a/promise-chan) #_(get @connections run-id)]
(swap! connections dissoc run-id)
(a/put! cancel-chan :cancel!)
(worker/cancel-run! run-id)))
(defn- execute-mbl-transform-local!
[run-id driver transform-details opts]
;; local run is responsible for status
;; start a timeout vthread
(.submit executor ^Runnable (fn []
(Thread/sleep (* 4 60 60 1000)) ;; 4 hours
(when-some [cancel-chan (get @connections run-id)]
(swap! connections dissoc run-id)
(a/put! cancel-chan :cancel!)
(worker/timeout-run! run-id))))
(try
(binding [qp.pipeline/*canceled-chan* (a/promise-chan)]
(swap! connections assoc run-id qp.pipeline/*canceled-chan*)
(driver/execute-transform! driver transform-details opts))
(when (get @connections run-id)
(swap! connections dissoc run-id)
(worker/succeed-started-run! run-id))
(catch Throwable t
(worker/fail-started-run! run-id {:message (.getMessage t)})
(throw t))))
(defn execute-mbql-transform!
"Execute `transform` and sync its target table.
This is executing synchronously, but supports being kicked off in the background
by delivering the `start-promise` just before the start when the beginning of the execution has been booked
in the database."
([transform] (execute-mbql-transform! transform nil))
([{:keys [id source target] :as transform} {:keys [run-method start-promise]}]
(try
(let [db (get-in source [:query :database])
{driver :engine :as database} (t2/select-one :model/Database db)
feature (transforms.util/required-database-feature transform)
run-id (str (u/generate-nano-id))
transform-details {:transform-type (keyword (:type target))
:connection-details (driver/connection-details driver database)
:query (transforms.util/compile-source source)
:output-table (transforms.util/qualified-table-name driver target)}
opts {:overwrite? true}]
(when-not (driver.u/supports? driver feature database)
(throw (ex-info "The database does not support the requested transform target type."
{:driver driver, :database database, :feature feature})))
;; mark the execution as started and notify any observers
(try
(worker/start-run! run-id :transform id
{:run_method run-method
:is_local (not (worker/run-remote?))})
(catch java.sql.SQLException e
(if (= (.getSQLState e) "23505")
(throw (ex-info "Transform is already running"
{:error :already-running
:transform-id id}
e))
(throw e))))
(when start-promise
(deliver start-promise [:started run-id]))
(log/info "Executing transform" id "with target" (pr-str target))
(if (worker/run-remote?)
(execute-mbl-transform-remote! run-id driver transform-details opts)
(do
(execute-mbl-transform-local! run-id driver transform-details opts)
(sync-target! target database run-id))))
(catch Throwable t
(log/error t "Error executing transform")
(when start-promise
;; if the start-promise has been delivered, this is a no-op,
;; but we assume nobody would catch the exception anyway
(deliver start-promise t))
(throw t)))))
(def ^:private ^Long scheduled-execution-poll-delay-millis 2000)
(defn- start-transforms!
[transforms opts]
(let [started-transforms
(mapv (fn [transform]
(let [start-promise (promise)]
(try
(let [thunk (^:once fn []
(execute-mbql-transform! transform (assoc opts :start-promise start-promise)))]
(.submit executor ^Runnable (bound-fn* thunk)))
(catch Throwable t
(deliver start-promise t)))
[transform start-promise]))
transforms)]
(reduce (fn [done [transform start-promise]]
(let [result @start-promise
run-id (when (and (vector? result) (= (first result) :started))
(second result))]
(if run-id
(update done :started conj (assoc transform ::run-id run-id))
(update done :failed conj transform))))
{:started []
:failed []}
started-transforms)))
(defn- poll-transforms
[transforms]
(when-let [run-ids (seq (keep ::run-id transforms))]
(loop []
(Thread/sleep scheduled-execution-poll-delay-millis)
(or (not-empty (into [] (map :work_id) (worker/inactive-runs run-ids)))
(recur)))))
(defn execute-transforms!
"Execute `transforms` and sync their target tables in dependency order.
The function returns as soon as all transforms have started."
[transforms opts]
(let [ordering (transforms.ordering/transform-ordering transforms)
_ (when-let [cycle (transforms.ordering/find-cycle ordering)]
(let [id->name (into {} (map (juxt :id :name)) transforms)]
(throw (ex-info (str "Cyclic transform definitions detected: "
(str/join " → " (map id->name cycle)))
{:cycle cycle}))))]
(loop [id->transform (m/index-by :id transforms)
to-start (into #{} (map :id) transforms)
running #{}
complete #{}]
(when (seq to-start)
(if-let [available-ids (not-empty (transforms.ordering/available-transforms ordering running complete))]
;; there are transforms to start
(let [{:keys [started failed]} (start-transforms! (map id->transform available-ids) opts)
id->transform (reduce (fn [m transform]
(assoc m (:id transform) transform))
id->transform
started)
running' (into running (map :id) started)
complete' (into complete (map :id) failed)
to-start' (reduce disj to-start available-ids)
completed (poll-transforms (map id->transform running'))]
(recur id->transform
to-start'
(reduce disj running' completed)
(reduce conj complete' completed)))
;; nothing to start yet, poll for transforms to finish
(if-let [completed (poll-transforms (map id->transform running))]
(recur id->transform
to-start
(reduce disj running completed)
(reduce conj complete completed))
(throw (ex-info "There are transforms to start, but nothing can be started and nothing is running!"
{:to-start to-start
:ordering ordering
:running running
:complete complete}))))))))
;; TODO (eric)
;;
;; Can I do this?
(defmethod task/init! ::CancelLostRuns [_]
(.scheduleAtFixedRate scheduler
#(try
(log/trace "Checking for canceling items.")
(let [runs (worker/reducible-canceled-local-runs)]
(reduce (fn [_ run]
(try
(cancel-run! (:run_id run))
(catch Throwable t
(log/error t (str "Error canceling " (:run_id run))))))
nil runs))
(catch Throwable t
(log/error t "Error while canceling on worker."))) 0 20 TimeUnit/SECONDS))

View File

@@ -0,0 +1,6 @@
(ns metabase-enterprise.transforms.init
(:require
[metabase-enterprise.transforms.execute]
[metabase-enterprise.transforms.models.transform]
[metabase-enterprise.transforms.schedule]
[metabase-enterprise.transforms.settings]))

View File

@@ -0,0 +1,39 @@
(ns metabase-enterprise.transforms.models.transform
(:require
[medley.core :as m]
[metabase-enterprise.worker.core :as worker]
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/Transform [_model] :transform)
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped?]]
(derive :model/Transform trait))
(t2/deftransforms :model/Transform
{:source mi/transform-json
:target mi/transform-json
:execution_trigger mi/transform-keyword})
(mi/define-batched-hydration-method with-transform
:transform
"Add transform to a WorkRun"
[runs]
(if-not (seq runs)
runs
(let [work-ids (into #{} (map :work_id) runs)
id->transform (t2/select-pk->fn identity [:model/Transform :id :name] :id [:in work-ids])]
(for [run runs]
(assoc run :transform (get id->transform (:work_id run)))))))
(mi/define-batched-hydration-method with-last-execution
:last_execution
"Add last_execution to a transform"
[transforms]
(if-not (seq transforms)
transforms
(let [transform-ids (into #{} (map :id) transforms)
last-executions (m/index-by :work_id (worker/latest-runs :transform transform-ids))]
(for [transform transforms]
(assoc transform :last_execution (get last-executions (:id transform)))))))

View File

@@ -0,0 +1,124 @@
(ns metabase-enterprise.transforms.ordering
(:require
[clojure.core.match]
[clojure.set :as set]
[clojure.string :as str]
[flatland.ordered.set :refer [ordered-set]]
[macaw.core :as macaw]
[metabase-enterprise.transforms.util :as transforms.util]
[metabase.driver :as driver]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.util :as lib.util]
[metabase.query-processor.preprocess :as qp.preprocess]
[metabase.query-processor.store :as qp.store]
[metabase.util :as u]))
(defn- clean-name [str]
(-> str
(str/replace #"['\"`]" "")
str/lower-case))
(defn- get-table [driver {:keys [table schema]}]
(let [normalized-table (driver/normalize-name driver table)
normalized-schema (when (seq schema)
(driver/normalize-name driver schema))]
(->> (qp.store/metadata-provider)
lib.metadata/tables
(some (fn [{db-table :name db-schema :schema id :id}]
(and (= normalized-table db-table)
(or (nil? normalized-schema)
(= normalized-schema db-schema))
id))))))
(defn- transform-deps [transform]
(let [query (-> (get-in transform [:source :query])
transforms.util/massage-sql-query
qp.preprocess/preprocess)
driver (-> (qp.store/metadata-provider)
lib.metadata/database
:engine)]
(case (:type query)
:native (->> (get-in query [:native :query])
macaw/parsed-query
macaw/query->components
:tables
(into #{} (keep #(->> % :component (get-table driver)))))
:query (into #{}
(keep #(clojure.core.match/match %
[:source-table source] (when (int? source)
source)
_ nil))
(tree-seq coll? seq query)))))
(defn- transform-deps-for-db [db-id transforms]
(qp.store/with-metadata-provider db-id
(into {}
(map (juxt :id transform-deps))
transforms)))
(defn- dependency-map [transforms]
(into {}
(map (juxt :id transform-deps))
transforms))
(defn- output-table-map [transforms]
(let [table-map (into {}
(map (fn [{:keys [schema name id]}]
[[schema name] id]))
(lib.metadata/tables (qp.store/metadata-provider)))]
(into {}
(keep (fn [transform]
(when-let [output-table (table-map [(get-in transform [:target :schema])
(get-in transform [:target :name])])]
[output-table (:id transform)])))
transforms)))
(defn transform-ordering
"Computes an 'ordering' of a given list of transforms.
The result is a map of transform id -> #{transform ids the transform depends on}. Dependencies are limited to just
the transforms in the original list -- if a transform depends on some transform not in the list, the 'extra'
dependency is ignored."
[transforms]
(let [transforms-by-db (->> transforms
(map (fn [transform]
{(get-in transform [:source :query :database]) [transform]}))
(apply merge-with into))
{:keys [output-tables dependencies]} (->> transforms-by-db
(map (fn [[db-id db-transforms]]
(qp.store/with-metadata-provider db-id
{:output-tables (output-table-map db-transforms)
:dependencies (dependency-map db-transforms)})))
(apply merge-with merge))]
(update-vals dependencies #(into #{} (keep output-tables) %))))
(defn find-cycle
"Finds a path containing a cycle in the directed graph `node->children`."
[node->children]
(loop [stack (into [] (map #(vector % (ordered-set))) (keys node->children))
visited #{}]
(when-let [[node path] (peek stack)]
(cond
(contains? path node)
(into [] (drop-while (complement #{node})) (conj path node))
(contains? visited node)
(recur (pop stack) visited)
:else
(let [path' (conj path node)
stack' (into (pop stack)
(map #(vector % path'))
(node->children node))]
(recur stack' (conj visited node)))))))
(defn available-transforms
"Given an ordering (see transform-ordering), a set of running transform ids, and a set of completed transform ids,
computes which transforms are currently able to be run."
[ordering running complete]
(for [[transform-id deps] ordering
:when (and (not (or (running transform-id)
(complete transform-id)))
(empty? (set/difference deps complete)))]
transform-id))

View File

@@ -0,0 +1,102 @@
(ns metabase-enterprise.transforms.schedule
(:require
[clojurewerkz.quartzite.jobs :as jobs]
[clojurewerkz.quartzite.schedule.cron :as cron]
[clojurewerkz.quartzite.triggers :as triggers]
[metabase-enterprise.transforms.execute :as transforms.execute]
[metabase-enterprise.transforms.settings :as transforms.settings]
[metabase.driver :as driver]
[metabase.events.core :as events]
[metabase.query-processor.timezone :as qp.timezone]
[metabase.task-history.core :as task-history]
[metabase.task.core :as task]
[metabase.util.log :as log]
[methodical.core :as methodical]
[toucan2.core :as t2])
(:import
(java.util TimeZone)
(org.quartz CronTrigger TriggerKey)))
(set! *warn-on-reflection* true)
(def ^:private global-job-key (jobs/key "metabase.task.transforms.schedule.global"))
(defn- timezone
[]
(or (driver/report-timezone)
(qp.timezone/system-timezone-id)
"UTC"))
(def ^:private ^TriggerKey global-trigger-key
(triggers/key "metabase.task.transforms.trigger.global"))
(defn- build-global-trigger
^CronTrigger [schedule]
(triggers/build
(triggers/with-description "Transform Global Schedule")
(triggers/with-identity global-trigger-key)
(triggers/for-job global-job-key)
(triggers/start-now)
(triggers/with-schedule
(cron/schedule
(cron/cron-schedule schedule)
(cron/in-time-zone (TimeZone/getTimeZone ^String (timezone)))
;; We want to fire the trigger once even if the previous triggers missed
;; (potentially several times)
(cron/with-misfire-handling-instruction-fire-and-proceed)))
;; higher than sync
(triggers/with-priority 6)))
(defn- create-global-trigger!
"Creates the global trigger for a transform."
[schedule]
(when schedule
(log/info "Creating global trigger for transforms with schedule" schedule)
(task/add-trigger! (build-global-trigger schedule))))
(defn- delete-global-trigger!
"Delete the global trigger for a transform."
([]
(if-let [trigger (first (task/existing-triggers global-job-key global-trigger-key))]
(delete-global-trigger! trigger)
(log/info "No global trigger for transforms exists")))
([trigger]
(log/info "Deleting global trigger for transforms with schedule" (:schedule trigger))
(task/delete-trigger! (-> trigger :key triggers/key))))
(defn- update-global-trigger!
"Update the global trigger."
[schedule]
(let [existing-trigger (first (task/existing-triggers global-job-key global-trigger-key))]
(if (not= schedule (:schedule existing-trigger))
(do
(when existing-trigger
(delete-global-trigger! existing-trigger))
(when schedule
(create-global-trigger! schedule)))
(log/info "No changes to the global trigger for transforms with schedule" schedule))))
(task/defjob ^{:doc "Execute transforms."
org.quartz.DisallowConcurrentExecution true}
ExecuteTransforms
[_context]
(when-let [transforms (t2/select :model/Transform :execution_trigger :global-schedule)]
(task-history/with-task-history {:task "execute-transforms"}
(transforms.execute/execute-transforms! transforms {:run-method :cron}))))
(defmethod task/init! ::ExecuteTransform [_]
(log/info "Initializing global transform execution job")
(let [job (jobs/build
(jobs/with-identity global-job-key)
(jobs/with-description "Execute Transforms")
(jobs/of-type ExecuteTransforms)
(jobs/store-durably))
trigger (build-global-trigger (transforms.settings/transform-schedule))]
(task/schedule-task! job trigger)))
(derive ::gloabal-transform-schedule-update :metabase/event)
(derive :event/gloabal-transform-schedule-update ::gloabal-transform-schedule-update)
(methodical/defmethod events/publish-event! ::gloabal-transform-schedule-update
[_topic {:keys [new-schedule]}]
(update-global-trigger! new-schedule))

View File

@@ -0,0 +1,21 @@
(ns metabase-enterprise.transforms.settings
(:require
[metabase.events.core :as events]
[metabase.settings.core :as setting]
[metabase.util.i18n :refer [deferred-tru]]))
(derive :event/gloabal-transform-schedule-update :metabase/event)
(setting/defsetting transform-schedule
(deferred-tru "The schedule for automatic execution of scheduled transforms")
:type :string
:visibility :settings-manager
:default "0 0 0 * * ? *"
:feature :transforms
:doc false
:export? true
:encryption :no
:on-change (fn [old new]
(events/publish-event! :event/gloabal-transform-schedule-update {:old-schedule old
:new-schedule new}))
:audit :getter)

View File

@@ -0,0 +1,96 @@
(ns metabase-enterprise.transforms.sync
(:require
[clojurewerkz.quartzite.conversion :as conversion]
[clojurewerkz.quartzite.jobs :as jobs]
[clojurewerkz.quartzite.schedule.calendar-interval :as calendar-interval]
[clojurewerkz.quartzite.schedule.simple :as simple]
[clojurewerkz.quartzite.triggers :as triggers]
[java-time.api :as t]
[metabase-enterprise.worker.api :as api]
[metabase-enterprise.worker.core :as worker]
[metabase-enterprise.worker.models.worker-run]
[metabase.app-db.core :as mdb]
[metabase.driver :as driver]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.task.core :as task]
[metabase.util.log :as log]
[toucan2.core :as t2])
(:import
(java.util.concurrent
Executors
ExecutorService
Future
ScheduledExecutorService
TimeUnit)))
(set! *warn-on-reflection* true)
(def ^:private job-key "metabase-enterprise.transforms.sync")
(defmulti post-success
"What to run after successful remote run is synced locally. Dispatches on work_type."
{:arglists '([run])}
:work_type)
(defmethod post-success :default
[_]
#_do-nothing)
(defn- sync-worker-runs! [_ctx]
(log/trace "Syncing worker runs.")
(let [runs (worker/reducible-active-remote-runs)]
(run! (fn [run]
(try
(let [resp (api/get-status (:run_id run))]
(case (:status resp)
"started"
:do-nothing
"canceling"
(worker/mark-cancel-started-run! (:run_id run) {:end_time (t/instant (:end-time resp))
:message (:note resp)})
"canceled"
(worker/cancel-run! (:run_id run) {:end_time (t/instant (:end-time resp))
:message (:note resp)})
"success"
(do
(worker/succeed-started-run! (:run_id run) {:end_time (t/instant (:end-time resp))})
(post-success run))
"error"
(worker/fail-started-run! (:run_id run) {:end_time (t/instant (:end-time resp))
:message (:note resp)})
"timeout"
(worker/timeout-run! (:run_id run) {:end_time (t/instant (:end-time resp))
:message (:note resp)})))
(catch Throwable t
(log/error t (str "Error syncing " (:run_id run))))))
runs))
(log/trace "Timing out old runs.")
(worker/timeout-old-runs! 4 :hour)
(log/trace "Canceling items that haven't been marked canceled.")
(worker/cancel-old-canceling-runs! 1 :minute))
(task/defjob ^{:doc "Syncs remote execution information with local table."
org.quartz.DisallowConcurrentExecution true}
SyncWorkerStatus [ctx]
(sync-worker-runs! ctx))
(defn- start-job! []
(when (not (task/job-exists? job-key))
(let [job (jobs/build
(jobs/of-type SyncWorkerStatus)
(jobs/with-identity (jobs/key job-key)))
trigger (triggers/build
(triggers/with-identity (triggers/key job-key))
(triggers/start-now)
(triggers/with-schedule
(calendar-interval/schedule
(calendar-interval/with-interval-in-seconds 2)
(calendar-interval/with-misfire-handling-instruction-do-nothing))))]
(task/schedule-task! job trigger))))
(defmethod task/init! ::SyncWorkerStatus [_]
(when (api/run-remote?)
(log/info "Scheduling worker sync.")
(start-job!)))

View File

@@ -0,0 +1,94 @@
(ns metabase-enterprise.transforms.util
(:require
[metabase.driver :as driver]
[metabase.query-processor.compile :as qp.compile]
[metabase.sync.core :as sync]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(defn qualified-table-name
"Return the name of the target table of a transform as a possibly qualified symbol."
[_driver {:keys [schema name]}]
(if schema
(keyword schema name)
(keyword name)))
(defn target-table-exists?
"Test if the target table of a transform already exists."
[{:keys [source target] :as _transform}]
(let [db-id (-> source :query :database)
database (t2/select-one :model/Database db-id)
driver (:engine database)]
(try
(-> (driver/describe-table driver database target)
:fields
seq
boolean)
(catch Exception e
(not (driver/table-known-to-not-exist? driver e))))))
(defn target-table
"Load the `target` table of a transform from the database specified by `database-id`."
[database-id target & kv-args]
(some-> (apply t2/select-one :model/Table
:db_id database-id
:schema (:schema target)
:name (:name target)
kv-args)
(t2/hydrate :db)))
(defn- sync-table!
([database target] (sync-table! database target nil))
([database target {:keys [create?]}]
(when-let [table (or (target-table (:id database) target)
(when create?
(sync/create-table! database (select-keys target [:schema :name]))))]
(sync/sync-table! table)
table)))
(defn activate-table!
"Activate table for `target` in `database` in the app db."
[database target]
(when-let [table (sync-table! database target {:create? true})]
;; TODO this should probably be a function in the sync module
(t2/update! :model/Table (:id table) {:active true})))
(defn deactivate-table!
"Deactivate table for `target` in `database` in the app db."
[database target]
(when-let [table (sync-table! database target)]
;; TODO this should probably be a function in the sync module
(t2/update! :model/Table (:id table) {:active false})))
(defn delete-target-table!
"Delete the target table of a transform and sync it from the app db."
[{:keys [id target source], :as _transform}]
(when target
(let [target (update target :type keyword)
database-id (-> source :query :database)
{driver :engine :as database} (t2/select-one :model/Database database-id)]
(driver/drop-transform-target! driver database target)
(log/info "Deactivating target " (pr-str target) "for transform" id)
(deactivate-table! database target))))
(defn delete-target-table-by-id!
"Delete the target table of the transform specified by `transform-id`."
[transform-id]
(delete-target-table! (t2/select-one :model/Transform transform-id)))
(defn massage-sql-query [query]
(assoc-in query [:middleware :disable-remaps?] true))
(defn compile-source
"Compile the source query of a transform."
[{query-type :type :as source}]
(case query-type
"query" (:query (qp.compile/compile-with-inline-parameters (massage-sql-query (:query source))))))
(defn required-database-feature
"Returns the database feature necessary to execute `transform`."
[transform]
(case (-> transform :target :type)
"table" :transforms/table
"view" :transforms/view
"materialized-view" :transforms/materialized-view))

View File

@@ -0,0 +1,50 @@
(ns metabase-enterprise.worker.api
(:require
[clj-http.client :as http]
[metabase.config.core :as config]
[metabase.system.core :as system]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(defn- mb-id []
(system/site-uuid))
(defn- json-body [{:keys [body]}]
(json/decode+kw body))
(defn- worker-uri []
(config/config-str :mb-transform-worker-uri))
(defn- worker-route [^String path]
(when-let [base-uri (worker-uri)]
(-> base-uri
java.net.URI.
(.resolve path)
str)))
(defn run-remote?
"Should this metabase instance run work remotely?"
[]
(boolean (worker-uri)))
(defn execute-transform!
"Execute a transform on the remote worker."
[run-id driver transform-details opts]
(json-body (http/put (worker-route (str "/transform/" run-id))
{:form-params {:driver driver
:transform-details transform-details
:opts opts
:mb-source (mb-id)}
:content-type :json})))
(defn get-status [run-id]
(json-body (http/get (worker-route (str "/status/" run-id "?mb-source=" (mb-id)))
{:content-type :json})))
(defn health-check []
(:body (http/get (worker-route "/api/health"))))
(comment
(health-check))

View File

@@ -0,0 +1,30 @@
(ns metabase-enterprise.worker.core
(:require
[metabase-enterprise.worker.api]
[metabase-enterprise.worker.models.worker-run]
[metabase-enterprise.worker.server]
[metabase-enterprise.worker.tracking]
[potemkin :as p]))
(p/import-vars
[metabase-enterprise.worker.api
execute-transform!
get-status
run-remote?]
[metabase-enterprise.worker.models.worker-run
cancel-old-canceling-runs!
cancel-run!
fail-started-run!
inactive-runs
latest-runs
mark-cancel-started-run!
paged-executions
reducible-canceled-local-runs
reducible-active-remote-runs
start-run!
succeed-started-run!
timeout-old-runs!
timeout-run!]
[metabase-enterprise.worker.server
start!
stop!])

View File

@@ -0,0 +1,215 @@
(ns metabase-enterprise.worker.models.worker-run
(:require
[metabase.app-db.core :as mdb]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]
[toucan2.realize :as t2.realize]))
(methodical/defmethod t2/table-name :model/WorkerRun [_model] :worker_run)
(derive :model/WorkerRun :metabase/model)
(t2/deftransforms :model/WorkerRun
{:work_type mi/transform-keyword
:status mi/transform-keyword
:run_method mi/transform-keyword})
(def ^:private model->work-type
{:model/Transform :transform})
(mi/define-simple-hydration-method add-worker-runs
:worker-runs
"Add worker-runs for a transform or other work. Must have :id field."
[work]
(t2/select :model/WorkerRun
:work_id (:id work)
:work_type (model->work-type (t2/model work))
{:order-by [[:start_time :desc] [:end_time :desc]]}))
(defn- latest-runs-query [work-type work-ids]
{:with [[:ranked_runs
{:select [:*
[[:over [[:row_number] {:partition-by :work_id, :order-by [[:start_time :desc]]}]] :rn]]
:from [:worker_run]
:where [:and
[:= :work_type work-type]
[:in :work_id work-ids]]}]]
:select [:*]
:from [:ranked_runs]
:where [:= :rn [:inline 1]]})
(defn latest-runs
"Return the latest runs for `work-type` and `work-ids`."
[work-type work-ids]
(when (seq work-ids)
(into [] (map (comp t2.realize/realize #(dissoc % :rn)))
(t2/reducible-select :model/WorkerRun (latest-runs-query (name work-type) work-ids)))))
(defn inactive-runs
"Return the runs with run IDs in `run-ids` that are not active."
[run-ids]
(when (seq run-ids)
(t2/select :model/WorkerRun
:run_id [:in run-ids]
:is_active nil)))
(defn start-run!
"Start a run"
([run-id work-type work-id]
(start-run! run-id work-type work-id {}))
([run-id work-type work-id properties]
(t2/insert! :model/WorkerRun
(assoc properties
:run_id run-id
:work_type work-type
:work_id work-id
:status :started
:is_active true))))
(defn succeed-started-run!
"Mark a started run as successfully completed."
([run-id]
(succeed-started-run! run-id {}))
([run-id properties]
(t2/update! :model/WorkerRun
:run_id run-id
:is_active true
:status "started"
(merge {:end_time :%now}
properties
{:status :succeeded
:is_active nil}))))
(defn fail-started-run!
"Mark the started active run as failed and inactive."
[run-id properties]
(t2/update! :model/WorkerRun
:run_id run-id
:is_active true
:status :started
(merge {:end_time :%now}
properties
{:status :failed
:is_active nil})))
(defn mark-cancel-started-run!
"Mark a started run for cancellation."
([run-id]
(mark-cancel-started-run! run-id
{:message "Canceled by user"}))
([run-id properties]
(t2/update! :model/WorkerRun
:run_id run-id
:is_active true
:status :started
(merge {:end_time :%now}
properties
{:status :canceling}))))
(defn reducible-canceled-local-runs
"Return a reducible sequence of local canceled runs."
[]
(t2/reducible-select :model/WorkerRun
:is_local true
:status "canceling"))
(defn reducible-active-remote-runs
"Return a reducible sequence of active remote runs."
[]
(t2/reducible-select :model/WorkerRun :is_local false :is_active true))
(defn cancel-run!
"Cancel a started or canceling run."
([run-id]
(cancel-run! run-id {:message "Canceled by user"}))
([run-id properties]
(t2/update! :model/WorkerRun
:run_id run-id
:status [:in ["started" "canceling"]]
(merge {:end_time :%now}
properties
{:status :canceled
:is_active nil}))))
(defn timeout-run!
"Mark a started run as timed out."
([run-id]
(timeout-run! run-id {}))
([run-id properties]
(t2/update! :model/WorkerRun
:run_id run-id
:status "started"
(merge {:end_time :%now
:message "Timed out"}
properties
{:status :timeout
:is_active nil}))))
(defn timeout-old-runs!
"Time out all active runs older than the specified age."
[age unit]
(t2/update! :model/WorkerRun
:is_active true
:start_time [:< (sql.qp/add-interval-honeysql-form (mdb/db-type) :%now (- age) unit)]
{:status :timeout
:end_time :%now
:is_active nil
:message "Timed out by metabase"}))
(defn cancel-old-canceling-runs!
"Cancel all canceling runs older than the specified age."
[age unit]
(t2/update! :model/WorkerRun
:status "canceling"
:end_time [:< (sql.qp/add-interval-honeysql-form (mdb/db-type) :%now (- age) unit)]
{:status :canceled
:end_time :%now
:is_active nil
:message "Canceled by user but could not guarantee run stopped."}))
(defn paged-executions
"Return a page of the list of the executions.
Follows the conventions used by the FE."
[{:keys [offset
limit
sort_column
sort_direction
work_type
work_id
status]}]
(let [offset (or offset 0)
limit (or limit 20)
sort-direction (or (keyword sort_direction) :desc)
nulls-sort (if (= sort-direction :asc)
:nulls-last
:nulls-first)
sort-column (keyword sort_column)
order-by (case sort_column
:started_at [[sort-column sort-direction]]
:ended_at [[sort-column sort-direction nulls-sort]]
[[:start_time sort-direction]
[:end_time sort-direction nulls-sort]])
conditions (concat (when work_type
[:work_type work_type])
(when work_id
[:work_id work_id])
(when status
[:= :status status])
(when (= status "started")
[:is_active true]))
conditions-with-sort-and-pagination (concat conditions [{:order-by order-by
:offset offset
:limit limit}])
runs (apply t2/select :model/WorkerRun conditions-with-sort-and-pagination)]
{:data (t2/hydrate runs :transform)
:limit limit
:offset offset
:total (apply t2/count :model/WorkerRun conditions)}))
(comment
(t2/select :model/WorkerRun)
(cancel-run! "slo7dhL7zoclb0uI0Zchj")
-)

View File

@@ -0,0 +1,130 @@
(ns metabase-enterprise.worker.server
(:require
[clojure.edn :as edn]
[clojure.string :as str]
[compojure.core :as compojure]
[compojure.route :as route]
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.transforms.core :as transforms]
[metabase-enterprise.worker.tracking :as tracking]
[metabase.api.util.handlers :as handlers]
[metabase.config.core :as config]
[metabase.driver :as driver]
[metabase.lib.schema.common :as schema.common]
[metabase.server.core :as server]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli.registry :as mr]
[metabase.util.yaml :as yaml]
[ring.adapter.jetty :as ring-jetty]
[ring.middleware.keyword-params :refer [wrap-keyword-params]]
[ring.middleware.params :refer [wrap-params]]
[ring.util.response :as response])
(:import (java.util.concurrent
Executors
ExecutorService
Future
ScheduledExecutorService
Semaphore
TimeUnit)
(org.eclipse.jetty.util.thread QueuedThreadPool)))
(set! *warn-on-reflection* true)
;; allow no more than 1000 running workers at a time
(defonce ^:private ^Semaphore semaphore (Semaphore. 1000 true))
(defonce ^:private ^ExecutorService executor (Executors/newVirtualThreadPerTaskExecutor))
(mr/def ::transform-api-request
[:map
[:driver [:keyword {:decode/normalize schema.common/normalize-keyword}]]
[:transform-details :metabase-enterprise.transforms.execute/transform-details]
[:opts :metabase-enterprise.transforms.execute/transform-opts]
[:mb-source :string]])
(defn- handle-transform-put
[{:keys [params body]}]
(log/info "Handling transform PUT request")
(if (.tryAcquire semaphore)
(let [{:keys [run-id]} params
{:keys [driver transform-details opts mb-source]} (mc/coerce
::transform-api-request
body
(mtx/transformer {:name :normalize}))]
(when (tracking/track-start! run-id mb-source)
(.submit executor
^Runnable #(try
(driver/execute-transform! driver
transform-details
opts)
(tracking/track-finish! run-id)
(catch Throwable t
(log/error t "Error executing transform")
(tracking/track-error! run-id (.getMessage t)))
(finally
(.release semaphore)))))
(-> (response/response (tracking/get-status run-id mb-source))
(response/content-type "application/json")))
(-> (response/response "Too many requests")
(response/status 429))))
(defn- handle-status-get
[run-id mb-source]
(log/info "Handling status GET request")
(if-let [resp (tracking/get-status run-id mb-source)]
(response/response resp)
(-> (response/response "Not found")
(response/status 404))))
(def ^:private routes
(compojure/routes
(compojure/GET "/api/health" [] "healthy")
(compojure/PUT "/transform/:run-id" request (handle-transform-put request))
(compojure/GET "/status/:run-id" [run-id mb-source] (handle-status-get run-id mb-source))
(route/not-found "Page not found")))
(def ^:private handler
(reduce (fn [handler middleware-fn]
(middleware-fn handler))
routes
[server/wrap-json-body
server/wrap-streamed-json-response
wrap-keyword-params
wrap-params]))
(defn ^:private port []
(if-let [port (config/config-str :mb-worker-jetty-port)]
(Integer/parseInt port)
3030))
(defonce ^:private instance (atom nil))
(defn stop! []
(when @instance
(log/info "Stopping worker server")
(.stop ^QueuedThreadPool @instance)
(reset! instance nil))
nil)
(defn start!
([] (start! {}))
([opts]
(stop!)
(let [thread-pool (doto (new QueuedThreadPool)
(.setVirtualThreadsExecutor (Executors/newVirtualThreadPerTaskExecutor)))
jetty-port (port)]
(log/info "Starting worker server on port" jetty-port)
(reset! instance (ring-jetty/run-jetty #'handler
{:port jetty-port
:thread-pool thread-pool
:join? (not (:dev opts))})))
nil))
(comment
(start! {:dev true})
(stop!))

View File

@@ -0,0 +1,142 @@
(ns metabase-enterprise.worker.tracking
(:require
[java-time.api :as t]
[metabase.config.core :as config]
[metabase.driver.sql-jdbc.execute :as sql-jdbc.execute])
(:import
(java.sql
PreparedStatement)))
(set! *warn-on-reflection* true)
(defn- run-query! [driver conn-str sql params]
(with-open [conn (java.sql.DriverManager/getConnection conn-str)
stmt (sql-jdbc.execute/statement-or-prepared-statement driver
conn
sql
params
nil)]
(let [rs (if (instance? PreparedStatement stmt)
(.executeQuery ^PreparedStatement stmt)
(.executeQuery stmt sql))
rsmeta (.getMetaData rs)]
(into [] (sql-jdbc.execute/reducible-rows driver rs rsmeta)))))
(defn- run-update! [driver conn-str sql params]
(with-open [conn (java.sql.DriverManager/getConnection conn-str)
stmt (sql-jdbc.execute/statement-or-prepared-statement driver
conn
sql
params
nil)]
(if (instance? PreparedStatement stmt)
(.executeUpdate ^PreparedStatement stmt)
(.executeUpdate stmt sql))))
(defn track-start!
[run-id mb-source]
(-> (run-update! :postgres (config/config-str :mb-worker-db)
"
INSERT INTO worker_runs (run_id, source, status)
SELECT ?, ?, 'running'
WHERE NOT EXISTS (
SELECT 1 FROM worker_runs WHERE run_id = ?
);"
[run-id mb-source run-id])
(= 1))) ;; new?
(defn set-status!
([run-id status]
(set-status! run-id status ""))
([run-id status note]
(run-update! :postgres (config/config-str :mb-worker-db)
"UPDATE worker_runs
SET status = ?, end_time = now(), note = ?
WHERE run_id = ?"
[status note run-id])
:ok))
(defn track-finish!
[run-id]
(set-status! run-id "success"))
(defn track-error!
[run-id msg]
(set-status! run-id "error" msg))
(def ^:private timeout-sec (* 4 60 60) #_sec)
(defn- handle-timeout
[run]
(if (and (= "running" (:status run))
(> (:run-time run) timeout-sec))
(-> run
(assoc :status "timeout")
(assoc :end-time (t/plus (:start-time run) (t/duration 4 :hours)))
(assoc :note "Timed out.")
(dissoc :run-time))
(dissoc run :run-time)))
(defn get-status
[run-id mb-source]
(->> (run-query! :postgres (config/config-str :mb-worker-db)
"SELECT run_id, status, start_time, end_time, note, EXTRACT(EPOCH FROM now() - start_time) as run_time
FROM worker_runs
WHERE run_id = ? AND source = ?"
[run-id mb-source])
first ;; row
(zipmap [:run-id :status :start-time :end-time :note :run-time])
handle-timeout
not-empty))
(defn timeout-old-tasks
[]
(run-update! :postgres (config/config-str :mb-worker-db)
"UPDATE worker_runs
SET status = ?, end_time = NOW(), note = ?
WHERE status = 'running' AND start_time < NOW() - INTERVAL '4 hours'"
["timeout" "Timed out by worker"]))
(comment
(let [driver :postgres
sql "SELECT * from worker_runs"]
(with-open [conn (java.sql.DriverManager/getConnection "jdbc:postgresql://localhost:5432/worker")]
(with-open [stmt (sql-jdbc.execute/statement-or-prepared-statement driver
conn
sql
[]
nil)]
(let [rs (if (instance? PreparedStatement stmt)
(.executeQuery ^PreparedStatement stmt)
(.executeQuery stmt sql))
rsmeta (.getMetaData rs)]
(into [] (sql-jdbc.execute/reducible-rows driver rs rsmeta))))))
(let [driver :postgres
sql "insert into worker_runs (work_id, type, source, status) values (?, ?, ?, ?) RETURNING run_id"]
(with-open [conn (java.sql.DriverManager/getConnection "jdbc:postgresql://localhost:5432/worker")]
(with-open [stmt (sql-jdbc.execute/statement-or-prepared-statement driver
conn
sql
[1, "sql-transform", "mb-1", "running"]
nil)]
(let [rs (if (instance? PreparedStatement stmt)
(.executeQuery ^PreparedStatement stmt)
(.executeQuery stmt sql))
rsmeta (.getMetaData rs)]
(into [] (sql-jdbc.execute/reducible-rows driver rs rsmeta)))))))
(comment
(alter-var-root #'environ.core/env assoc :mb-worker-db "jdbc:postgresql://localhost:5432/worker")
(def id (str (random-uuid)))
(track-start! id "mb-1")
(track-finish! id)
(track-error! id "oops")
(get-status id "mb-1"))

View File

@@ -0,0 +1,100 @@
(ns metabase-enterprise.worker-db.migrations
"Database migration utilities for worker databases."
(:require
[clojure.core :exclude [run!]]
[clojure.java.io :as io]
[metabase.classloader.core :as classloader]
[metabase.config.core :as config]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms])
(:import
(javax.sql DataSource)
(liquibase Contexts LabelExpression Liquibase)
(liquibase.database Database DatabaseFactory)
(liquibase.database.jvm JdbcConnection)
(liquibase.resource ClassLoaderResourceAccessor ResourceAccessor)))
(set! *warn-on-reflection* true)
(mu/defn- run-migrations-from-resource!
"Run Liquibase migrations from a resource file in the classpath.
Parameters:
- data-source: A javax.sql.DataSource for the target database
- resource-path: Path to the Liquibase changelog file in resources (e.g. \"migrations/my-db-changelog.yaml\")
Example:
(run-migrations-from-resource! my-datasource \"migrations/external-db-changelog.yaml\")"
[data-source :- (ms/InstanceOfClass DataSource)
resource-path :- :string]
(log/info "Running migrations from resource:" resource-path)
;; Verify the resource exists
(when-not (io/resource resource-path)
(throw (ex-info (str "Migration resource not found: " resource-path)
{:resource-path resource-path})))
;; Create Liquibase instance manually with custom changelog
(with-open [^java.sql.Connection conn (.getConnection ^DataSource data-source)]
(let [^JdbcConnection liquibase-conn (JdbcConnection. conn)
^Database database (.findCorrectDatabaseImplementation (DatabaseFactory/getInstance) liquibase-conn)
^ResourceAccessor resource-accessor (ClassLoaderResourceAccessor. (classloader/the-classloader))
^Liquibase lb (Liquibase. ^String resource-path resource-accessor database)]
;; Run the migrations
(try
(log/info "Starting migration execution for" resource-path)
(.update lb (Contexts.) (LabelExpression.))
(log/info "Successfully completed migrations from" resource-path)
:success
(catch Exception e
(log/error e "Failed to run migrations from" resource-path)
(throw e))))))
(mu/defn- run-migrations-from-resource-with-connection-string!
"Run Liquibase migrations from a resource file using a JDBC connection string.
Parameters:
- connection-string: JDBC connection string (e.g. \"jdbc:postgresql://localhost/mydb?user=myuser&password=mypass\")
- resource-path: Path to the Liquibase changelog file in resources (e.g. \"migrations/my-db-changelog.yaml\")
Example:
(run-migrations-from-resource-with-connection-string!
\"jdbc:postgresql://localhost/mydb?user=myuser&password=mypass\"
\"migrations/external-db-changelog.yaml\")"
[connection-string :- :string
resource-path :- :string]
(log/info "Running migrations from resource:" resource-path "with connection:" connection-string)
;; Verify the resource exists
(when-not (io/resource resource-path)
(throw (ex-info (str "Migration resource not found: " resource-path)
{:resource-path resource-path})))
;; Create Liquibase instance manually with custom changelog and connection string
(with-open [^java.sql.Connection conn (java.sql.DriverManager/getConnection connection-string)]
(let [^JdbcConnection liquibase-conn (JdbcConnection. conn)
^Database database (.findCorrectDatabaseImplementation (DatabaseFactory/getInstance) liquibase-conn)
^ResourceAccessor resource-accessor (ClassLoaderResourceAccessor. (classloader/the-classloader))
^Liquibase lb (Liquibase. ^String resource-path resource-accessor database)]
;; Run the migrations
(try
(log/info "Starting migration execution for" resource-path)
(.update lb (Contexts.) (LabelExpression.))
(log/info "Successfully completed migrations from" resource-path)
:success
(catch Exception e
(log/error e "Failed to run migrations from" resource-path)
(throw e))))))
(defn run!
"Run migrations for the worker."
[]
(let [worker-db-connection-string (config/config-str :mb-worker-db)]
(run-migrations-from-resource-with-connection-string! worker-db-connection-string
"migrations/workers/worker_runs.yaml")))
(comment
(run-migrations-from-resource-with-connection-string! "jdbc:postgresql://localhost:5432/worker"
"migrations/workers/worker_runs.yaml"))

View File

@@ -0,0 +1,11 @@
(ns metabase-enterprise.workspaces.api
"`/api/ee/workspace/` routes"
(:require
[metabase-enterprise.workspaces.api.workspace]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/workspace` routes."
metabase-enterprise.workspaces.api.workspace/routes)

View File

@@ -0,0 +1,533 @@
(ns metabase-enterprise.workspaces.api.workspace
#_{:clj-kondo/ignore [:metabase/modules]}
(:require
[metabase-enterprise.workspaces.models.workspace :as m.workspace]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(comment
m.workspace/keep-me)
(set! *warn-on-reflection* true)
;;; Helper functions
(defn update-workspace-entity-at-index
"Updates an entity at a specific index in the workspace's entity collection.
Args:
- workspace-id: The workspace ID
- entity-key: The key in the workspace map (:plans, :transforms, etc.)
- index: The 0-based index of the entity to update
- update-fn: Function that takes the current entity and returns the updated entity"
[workspace-id entity-key index update-fn]
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id workspace-id))
current-items (vec (get workspace entity-key []))
current-item (api/check-404 (nth current-items index nil))
new-item (update-fn current-item)
updated-items (assoc current-items index new-item)]
(t2/update! :model/Workspace workspace-id {entity-key updated-items})
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id workspace-id))))
(defn- delete-workspace-entity-at-index
"Deletes an entity at a specific index in the workspace's entity collection.
Args:
- workspace-id: The workspace ID
- entity-key: The key in the workspace map (:plans, :transforms, etc.)
- index: The 0-based index of the entity to delete"
[workspace-id entity-key index]
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id workspace-id))
current-items (vec (get workspace entity-key []))
_ (api/check-404 (nth current-items index nil)) ; Verify index exists
updated-items (vec (concat (subvec current-items 0 index)
(subvec current-items (inc index))))]
(t2/update! :model/Workspace workspace-id {entity-key updated-items})
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id workspace-id))))
;; GET /api/ee/workspace
(api.macros/defendpoint :get "/"
"List all workspaces for the current user"
[]
(mapv m.workspace/sort-workspace
(t2/select :model/Workspace {:order-by [[:created_at :desc]]})))
;; GET /api/ee/workspace/:workspace-id
(api.macros/defendpoint :get "/:workspace-id"
"Get a workspace by ID"
[{:keys [workspace-id]} :- [:map [:workspace-id ms/PositiveInt]]]
(let [workspace (t2/select-one :model/Workspace :id workspace-id)]
(api/check-404 workspace)
(m.workspace/sort-workspace workspace)))
;; POST /api/ee/workspace
(api.macros/defendpoint :post "/"
"Create a new workspace.
Request body:
- name (required): Workspace name
- description (optional): Workspace description"
[_route-params
_query-params
{:keys [name description collection_id]}
:- [:map
[:name ms/NonBlankString]
[:collection_id ms/PositiveInt]
[:description {:optional true} [:maybe :string]]]]
(let [workspace-data {:name name
:description description
:collection_id collection_id
:users []
:plans []
:transforms []
:activity_logs []
:permissions []
:documents []
:data_warehouses []}]
(m.workspace/sort-workspace
(t2/insert-returning-instance! :model/Workspace workspace-data))))
(comment
(doseq [del-id (map :id (rest (t2/select [:model/Collection :id] :name "Workspace Collection")))]
(t2/delete! :model/Collection :id del-id))
(require '[metabase.test :as mt]
'[toucan2.core :as t2])
(mt/user-http-request :crowberto :post 200 "collection/" {:name "Workspace Collection"
:slug "workspace_collection"})
(t2/select [:model/Collection :id :name] :name "Workspace Collection")
(mt/user-http-request
:crowberto :post 200 (format "ee/workspace/%s/plan" (:id (t2/select [:model/Collection :id :name] :name "Workspace Collection")))
{:name "New Plan"
:collection_id (:id (t2/select-one [:model/Collection :id] :name "Workspace Collection"))
:description "This is a new plan"
:content {:steps ["Step 1" "Step 2"]}})
(t2/select-one [:model/Workspace :id :name :description] :name "New Plan")
;; => (toucan2.instance/instance :model/Workspace {:id 1, :name "New Plan", :description "This is a new plan"})
)
;; PUT /api/ee/workspace/:id
(api.macros/defendpoint :put "/:id"
"Update an existing workspace's name or description.
Request body:
- name (required): Updated workspace name
- description (optional): Updated workspace description
Returns the updated workspace."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params
{:keys [name description]} :- [:map
[:name ms/NonBlankString]
[:description {:optional true} [:maybe :string]]]]
(api/check-404 (t2/select-one :model/Workspace :id id))
(t2/update! :model/Workspace id {:name name :description description})
(t2/select-one :model/Workspace :id id))
;; DELETE /api/ee/workspace/:id
(api.macros/defendpoint :delete "/:id"
"Delete a workspace."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-404 (t2/select-one :model/Workspace :id id))
(t2/delete! :model/Workspace :id id)
api/generic-204-no-content)
;; PUT /api/ee/workspace/:id/plan
(api.macros/defendpoint :put "/:id/plan"
"Add a plan to the workspace.
Request body:
- title (required): Plan title
- description (required): Plan description
- content (required): Plan content object"
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]
_query-params
{:keys [title description content]}
:- [:map
[:title ms/NonBlankString]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:content :map]]]
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
current-plans (or (:plans workspace) [])
new-plan {:title title
:description description
:content content
:created_at (str (java.time.Instant/now))}
updated-plans (conj current-plans new-plan)]
(t2/update! :model/Workspace id {:plans updated-plans})
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
(api.macros/defendpoint :put "/:id/plan/:index"
"Replace a plan in the workspace, by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the plan to replace (0-based)
Request body:
- title (required): Plan title
- description (required): Plan description
- content (required): Plan content object"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params
{:keys [title description content]}
:- [:map
[:title ms/NonBlankString]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:content :map]]]
(update-workspace-entity-at-index id :plans index
(fn [current-plan]
(merge current-plan {:title title
:description description
:content content
:created_at (str (java.time.Instant/now))}))))
(api.macros/defendpoint :delete "/:id/plan/:index"
"Delete a plan from the workspace by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the plan to delete (0-based)"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params]
(delete-workspace-entity-at-index id :plans index)
api/generic-204-no-content)
;; PUT /api/ee/workspace/:id/transform
(api.macros/defendpoint :put "/:id/transform"
"Add a transform to the workspace.
Request body:
- name (required): Transform name
- description (required): Transform description
- source (required): Source configuration
- target (required): Target configuration
- config (optional): Transform configuration"
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]
_query-params
{:keys [name description source target config]}
:- [:map
[:name ms/NonBlankString]
[:description ms/NonBlankString]
[:source :map]
[:target :map]
[:config {:optional true} [:maybe :map]]]]
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
current-transforms (or (:transforms workspace) [])
new-transform {:name name
:description description
:source source
:target target
:config config
:created_at (str (java.time.Instant/now))}
updated-transforms (conj current-transforms new-transform)]
(t2/update! :model/Workspace id
{:transforms updated-transforms})
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
(api.macros/defendpoint :put "/:id/transform/:index"
"Replace a transform in the workspace, by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the transform to replace (0-based)
Request body:
- name (required): Transform name
- description (required): Transform description
- source (required): Source configuration
- target (required): Target configuration
- config (optional): Transform configuration"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params
{:keys [name description source target config]}
:- [:map
[:name ms/NonBlankString]
[:description ms/NonBlankString]
[:source :map]
[:target :map]
[:config {:optional true} [:maybe :map]]]]
(update-workspace-entity-at-index id :transforms index
(fn [current-transform]
(merge current-transform {:name name
:description description
:source source
:target target
:config config
:created_at (str (java.time.Instant/now))}))))
(api.macros/defendpoint :delete "/:id/transform/:index"
"Delete a transform from the workspace by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the transform to delete (0-based)"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params]
(delete-workspace-entity-at-index id :transforms index)
api/generic-204-no-content)
;; POST /api/ee/workspace/:id/transform/link
(api.macros/defendpoint :post "/:id/transform/link"
"Link an existing transform to the workspace.
Request body:
- transform_id (required): ID of the transform to link"
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params
{:keys [transform_id]} :- [:map [:transform_id ms/PositiveInt]]]
(api/check-superuser)
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
transform (api/check-404 (t2/select-one :model/Transform :id transform_id))
current-transforms (or (:transforms workspace) [])
;; Convert the existing transform to workspace format
linked-transform {:id (:id transform)
:name (:name transform)
:description (:description transform)
:source (:source transform)
:target (:target transform)
:config (:config transform)
:created_at (str (java.time.Instant/now))}
updated-transforms (conj current-transforms linked-transform)]
(t2/update! :model/Workspace id {:transforms updated-transforms})
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
;; PUT /api/ee/workspace/:id/document
(api.macros/defendpoint :put "/:id/document"
"Add a document to the workspace.
Request body:
- document_id (required): ID of the document to link"
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params
{:keys [document_id]} :- [:map [:document_id ms/PositiveInt]]]
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
current-docs (or (:documents workspace) [])
updated-docs (distinct (conj current-docs document_id))]
(t2/update! :model/Workspace id {:documents updated-docs})
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
;; PUT /api/ee/workspace/:id/data_warehouse
(api.macros/defendpoint :put "/:id/data_warehouse"
"Add a data warehouse to the workspace.
Request body:
- dwh_id (required): ID of the data warehouse
- name (required): Name of the data warehouse
- type (required): Access type (read-only or read-write)
- credentials (required): Credentials object"
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params
{:keys [data_warehouses_id name
type credentials]} :- [:map
[:data_warehouses_id ms/PositiveInt]
[:name ms/NonBlankString]
[:type [:enum "read-only" "read-write"]]
[:credentials :map]]]
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
current-dwh (or (:data_warehouses workspace) [])
new-dwh {:id data_warehouses_id
:name name
:type (keyword type)
:credentials credentials
:created_at (str (java.time.Instant/now))}
updated-dwh (conj current-dwh new-dwh)]
(t2/update! :model/Workspace id
{:data_warehouses updated-dwh})
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
(api.macros/defendpoint :put "/:id/data_warehouse/:index"
"Replace a data warehouse in the workspace, by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the data warehouse to replace (0-based)
Request body:
- data_warehouses_id (required): ID of the data warehouse
- name (required): Name of the data warehouse
- type (required): Access type (read-only or read-write)
- credentials (required): Credentials object"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params
{:keys [data_warehouses_id name type credentials]}
:- [:map
[:data_warehouses_id ms/PositiveInt]
[:name ms/NonBlankString]
[:type [:enum "read-only" "read-write"]]
[:credentials :map]]]
(update-workspace-entity-at-index id :data_warehouses index
(fn [current-dwh]
(merge current-dwh {:id data_warehouses_id
:name name
:type (keyword type)
:credentials credentials
:created_at (str (java.time.Instant/now))}))))
(api.macros/defendpoint :delete "/:id/data_warehouse/:index"
"Delete a data warehouse from the workspace by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the data warehouse to delete (0-based)"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params]
(delete-workspace-entity-at-index id :data_warehouses index)
api/generic-204-no-content)
(defn- add-user! [workspace user]
(let [current-users (or (:users workspace) [])
new-user (assoc user :created_at (str (java.time.Instant/now)))
updated-users (conj current-users new-user)]
(t2/update! :model/Workspace (:id workspace) {:users updated-users})))
;; PUT /api/ee/workspace/:id/user
(api.macros/defendpoint :put "/:workspace-id/user"
"Add a user to the workspace.
Request body:
- id (required): User ID
- name (required): User name
- email (required): User email
- type (required): User type (e.g., 'workspace-user')"
[{:keys [workspace-id]} :- [:map [:workspace-id ms/PositiveInt]]
_query-params
user :- [:map
[:id ms/PositiveInt]
[:name ms/NonBlankString]
[:email ms/NonBlankString]
[:type ms/NonBlankString]]]
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id workspace-id))]
(add-user! workspace user)
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id workspace-id))))
(api.macros/defendpoint :put "/:id/user/:index"
"Replace a user in the workspace, by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the user to replace (0-based)
Request body:
- id (required): User ID
- name (required): User name
- email (required): User email
- type (required): User type (e.g., 'workspace-user')"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params
{:keys [user_id name email type]}
:- [:map
[:user_id ms/PositiveInt]
[:name ms/NonBlankString]
[:email ms/NonBlankString]
[:type ms/NonBlankString]]]
(update-workspace-entity-at-index id :users index
(fn [current-user]
(merge current-user {:id user_id
:name name
:email email
:type type
:created_at (str (java.time.Instant/now))}))))
(api.macros/defendpoint :delete "/:id/user/:index"
"Delete a user from the workspace by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the user to delete (0-based)"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params]
(delete-workspace-entity-at-index id :users index)
api/generic-204-no-content)
;; PUT /api/ee/workspace/:id/permission
(api.macros/defendpoint :put "/:id/permission"
"Add a permission to the workspace.
Request body:
- table (required): Table name
- permission (required): Permission type (read or write)"
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params
{:keys [table permission]}
:- [:map
[:table ms/NonBlankString]
[:permission [:enum "read" "write"]]]]
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
current-permissions (or (:permissions workspace) [])
new-permission {:table table
:permission (keyword permission)
:created_at (str (java.time.Instant/now))}
updated-permissions (conj current-permissions new-permission)]
(t2/update! :model/Workspace id
{:permissions updated-permissions})
(t2/select-one :model/Workspace :id id)))
(api.macros/defendpoint :put "/:id/permission/:index"
"Replace a permission in the workspace, by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the permission to replace (0-based)
Request body:
- table (required): Table name
- permission (required): Permission type (read or write)"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params
{:keys [table permission]}
:- [:map
[:table ms/NonBlankString]
[:permission [:enum "read" "write"]]]]
(update-workspace-entity-at-index id :permissions index
(fn [current-permission]
(merge current-permission {:table table
:permission (keyword permission)
:created_at (str (java.time.Instant/now))}))))
(api.macros/defendpoint :delete "/:id/permission/:index"
"Delete a permission from the workspace by index.
Route Params:
- id (required): Workspace ID
- index (required): Index of the permission to delete (0-based)"
[{:keys [id index]} :- [:map
[:id ms/PositiveInt]
[:index :int]]
_query-params]
(delete-workspace-entity-at-index id :permissions index)
api/generic-204-no-content)
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/workspace/` routes for workspace management"
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,540 @@
(ns metabase-enterprise.workspaces.isolation-manager
"A namespace that will manage database isolation. Will operate on database connection details compatible with
clojure.java.jdbc. The goal is that satisfy a high level goal of creating an isolated workspace in a database that
an LLM agent can have read/write access to. This space will be adjacent to data in the rest of the database but
isolated to it. We must create three things:
1. a schema (for postgres, snowflake, redshift) or a database (for clickhouse) where data can be ETL'd into. The
schema should have some pattern to it that includes some identifying information of the workspace, and also a prefix
so that Metabase knows now to index or sync this schema. It must be very bespoke and at extremely low risk of being
confused with real customer data. The schema/database will have the following naming converion:
mb__isolation_7748c_test_workspace_123
|-----------| |---| |----------------|
† †† †††
† mb__isolation: common prefix to all workspace instances. Indicates that metabase owns this and we can sync or not
as we like
†† 7748c: derived from instance-uuid. On my instance `7f43c806-7ef1-4308-8fab-cb41b177db91`
7 7 4 8 c
Let's an instance know if this isolation unit was created by this instance or not.
††† test_workspace_123: a mapping that should tie back to a workspace in some manner. This is largely opaque but
should indicate uniquely back to a workspace on this instance.
2. a user with read privileges to the original data and write access to the new schema
The public api is
(create-isolation driver, connection-details, workspace-id)
(delete-isolation driver, connection-details, workspace-id, [isolation-info])
Isolation info is optional but nice to have. The isolation mechanism should be deterministic from the connection
details and worksapce-id.
It is expected that a workspace can use databases it knows about to create isolations for it's work."
(:require
[clojure.java.jdbc :as jdbc]
[clojure.string :as str]
[metabase.driver.ddl.interface :as ddl.i]
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.driver.sql.util :as sql.u]
[metabase.system.core :as system]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(defn- instance-uuid-slug
"Create a slug from the site UUID, taking the first character of each section."
[site-uuid-string]
(apply str (map first (str/split site-uuid-string #"-"))))
(defn- isolation-schema-name
"Generate schema/database name for workspace isolation following mb__isolation_<slug>_<workspace-id> pattern."
[workspace-id]
(let [instance-slug (instance-uuid-slug (str (system/site-uuid)))
clean-workspace-id (str/replace (str workspace-id) #"[^a-zA-Z0-9]" "_")]
(format "mb__isolation_%s_%s" instance-slug clean-workspace-id)))
(defn- isolation-user-name
"Generate username for workspace isolation."
[workspace-id user-type]
(let [instance-slug (instance-uuid-slug (str (system/site-uuid)))
clean-workspace-id (str/replace (str workspace-id) #"[^a-zA-Z0-9]" "_")]
(format "mb_iso_%s_%s_%s" instance-slug clean-workspace-id (name user-type))))
(mr/def ::rule any?)
(mr/def ::options :map)
(mr/def ::steps
[:cat :keyword ::options [:* [:alt [:schema [:ref ::steps]] ::rule]]])
(mu/defn- evaluate-steps
"Evaluate steps. Steps is a tree of steps matching the ::steps schema. F should be a function that takes in the
opaque value of a step. `f` must return a tuple `[:success _]` or `[:error ]`.
Error handling: by default an error will stop all evaluation. It will report which _steps_ are skipped, and which
_trees_ are also skipped. It will not descend into trees to enumerate steps once skipped. You can put
`{:error-strategy ::continue-on-error}` as options anywhere in the tree. This option affects subtrees. You can
shadow this back to the regular error strategy with any other value to prevent finding the continue on error
strategy."
[steps :- ::steps f]
(letfn [(subtree? [rule] (and (vector? rule) (keyword? (first rule))))
(error-strategy [stackframes]
(when (seq stackframes)
(if-some [strategy (-> stackframes peek :options :error-strategy)]
strategy
(recur (pop stackframes)))))]
(loop [stackframes [{:q (into clojure.lang.PersistentQueue/EMPTY
(nthrest steps 2))
:options (second steps)
:tree (first steps)}]
acc [[:tree (first steps)]]
state :running
gas 50]
(when (zero? gas) (throw (ex-info "ran out of gas" {:acc acc})))
(if-not (seq stackframes)
[acc state]
(let [{:keys [q] :as frame} (peek stackframes)
base-stack (pop stackframes)
rule (peek q)]
;; done with this stackframe
(cond (not (seq q))
(let [state' (if (= state :error)
(let [strategy (error-strategy base-stack)]
(if (not= strategy ::continue-on-error)
state
:running))
state)]
(recur base-stack acc state' (dec gas)))
;; scoot through
(= state :error)
(let [info (if (subtree? rule)
[:skipping-tree (first rule)]
[:skipping-step rule])
frame' (update frame :q pop)]
(recur (conj base-stack frame') (conj acc info) state (dec gas)))
;; hit a tree node
(subtree? rule)
(let [[tree-name options & sub-rules] rule
new-stackframe {:q (into clojure.lang.PersistentQueue/EMPTY
sub-rules)
:options options
:tree tree-name}
stackframes (conj base-stack
(update frame :q pop)
new-stackframe)]
(recur stackframes
(conj acc [:tree tree-name])
state
(dec gas)))
:else
(let [[status _response :as result] (f rule)]
(when-not (#{:error :success} status)
(throw (ex-info "Bad status from rule" {:rule rule :result result})))
(let [switch-to-error? (and (= :error status)
(not= (error-strategy stackframes)
::continue-on-error))]
(recur (conj base-stack (update frame :q pop))
(conj acc result)
(if switch-to-error? :error state)
(dec gas))))))))))
(defmulti ^:private create-isolation*
"Create database isolation for a workspace."
(fn [driver workspace] driver))
;; todo: use (jdbc/metadata-result (.getSchemas metadata))
(mu/defn- postgres-steps :- ::steps
"Return postgres steps. Needs the populator and read users (maps with user and password) and the schema name to be
populated. Will create the schema, uses, and setup privileges. Currently hardcoded to give the populator access to
the `public` schema. This will need to be updated to include the schemas from the source database."
[{:keys [populator schema-name]}]
[:schema
{}
(format "CREATE SCHEMA %s" schema-name)
[:users
{}
;; Create populator user with random password
[:populator
{}
(format "CREATE USER %s WITH PASSWORD '%s'" (:user populator) (:password populator))
[:populator-privileges
{}
;; Grant privileges to populator user
;; source data hardcorded to public: FIX
(format "GRANT USAGE ON SCHEMA %s TO %s" "public" (:user populator))
;; todo: don't hardcode this to public
(format "GRANT USAGE ON SCHEMA public TO %s" (:user populator))
(format "GRANT USAGE ON SCHEMA %s TO %s" schema-name (:user populator))
(format "GRANT SELECT ON ALL TABLES IN SCHEMA %s TO %s" "public" (:user populator))
(format "GRANT CREATE ON SCHEMA %s TO %s" schema-name (:user populator))
(format "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %s TO %s"
schema-name (:user populator))]]]])
(mu/defn snowflake-steps :- ::steps
[{:keys [populator schema-name source-database-name]}]
(letfn [(q [d]
(try
(sql.u/quote-name :clickhouse :database (ddl.i/format-name :clickhouse d))
(catch Exception e
(log/errorf e "Error quoting %s" (pr-str d))
(throw e))))]
[:use-database
{}
(format "USE DATABASE \"%s\" " source-database-name)
[:schema
{}
(format "CREATE SCHEMA IF NOT EXISTS %s" schema-name)
;; todo: snowflake extra users. They are difficult to get right with roles and quoting
#_[:usage-on-new-schema
{}
;; hardcoded developer. ownership of new schema to developer conflicts with next bit
#_(format "GRANT OWNERSHIP ON SCHEMA %s to ROLE DEVELOPER" schema-name)
#_(format "GRANT USAGE ON SCHEMA %s to ROLE DEVELOPER" schema-name)]
#_[:users
{}
;; Create populator user with random password
[:populator-role
{}
(format "CREATE ROLE \"%s\" " (:role populator))
[:populator
{}
(format "CREATE OR REPLACE USER %s PASSWORD = '%s' DEFAULT_ROLE='%s'"
(:user populator)
(:password populator)
(:role populator))
[:populator-privileges
{}
;; Grant privileges to populator user
;; source data hardcorded to public: FIX
(format "GRANT OWNERSHIP ON SCHEMA %s TO \"%s\" " schema-name #_(:user populator) (:role populator))
;; todo: don't hardcode this to public
(format "GRANT USAGE ON DATABASE \"%s\" TO \"%s\" " source-database-name #_(:user populator) (:role populator))]
[:assign-role
{}
(format "GRANT ROLE \"%s\" to USER %s" (:role populator) (:user populator))]]]]]]))
(comment
(let [spec (metabase.driver.sql-jdbc.connection/db->pooled-connection-spec (toucan2.core/select-one-fn :id :model/Database :engine :snowflake))]
(jdbc/query spec ["select current_role()"]))
)
(defmethod create-isolation* :snowflake
[_driver {:keys [id connection-details]}]
;; schema name is automatically uppercased by snowflake, but then if you quote it it doesn't find the uppercased
;; version.
(let [schema-name (str/upper-case (isolation-schema-name id))
jdbc-spec (sql-jdbc.conn/connection-details->spec :snowflake connection-details)
populator {:user (isolation-user-name id :populator)
:password (str (random-uuid))
:role (str (random-uuid))}
steps (snowflake-steps {:populator populator
:schema-name schema-name
:source-database-name ((some-fn :db :dbname) connection-details)})]
(jdbc/with-db-transaction [tx jdbc-spec]
(let [results (evaluate-steps steps (fn [sql]
(try [:success sql (jdbc/execute! tx
(if (string? sql)
[sql]
sql))]
(catch Exception e
(log/error e)
[:error sql (ex-message e)]))))]
{:isolation-type :schema
:schema-name schema-name
;;:populator populator
:results results}))))
(comment
(malli.core/validate ::steps
(snowflake-steps {:populator {:user "populator_user123" :password (str (random-uuid))}
:schema-name (isolation-schema-name "workspace_1234")
:source-database-name "sales"}))
(postgres-steps {:populator {:user "populator" :password "populator-pw"}
:schema-name "scratchpad"})
(malli.core/validate ::steps
(postgres-steps {:populator {:user "populator" :password "populator-pw"}
:schema-name "scratchpad"}))
(evaluate-steps (postgres-steps {:populator {:user "populator" :password "populator-pw"}
:schema-name "scratchpad"})
(fn [step] (if (= 0 (rand-int 10))
[:error (format "error during: %s" step)]
[:success (format "performed: %s" step)])))
(evaluate-steps [:schema
{:error-strategy ::continue-on-error}
"CREATE SCHEMA scratchpad"
[:users
{}
[:populator
{}
"CREATE USER populator WITH PASSWORD 'populator-pw'"
[:populator-privileges
{}
"GRANT USAGE ON SCHEMA public TO populator"
"GRANT USAGE ON SCHEMA public TO populator"
"GRANT USAGE ON SCHEMA scratchpad TO populator"
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO populator"
"GRANT CREATE ON SCHEMA scratchpad TO populator"
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO populator"]]]]
(fn [step] (if (= 0 (rand-int 10))
[:error (format "error during: %s" step)]
[:success (format "performed: %s" step)]))))
(defmethod create-isolation* :postgres
[_driver {:keys [id connection-details]}]
(let [schema-name (isolation-schema-name id)
populator {:user (isolation-user-name id :populator)
:password (str (random-uuid))}
jdbc-spec (sql-jdbc.conn/connection-details->spec :postgres connection-details)
steps (postgres-steps {:populator populator
:schema-name schema-name})]
(jdbc/with-db-transaction [tx jdbc-spec]
;; Create schema
(let [results (evaluate-steps steps (fn [sql]
(try [:success
sql
(jdbc/execute! tx [sql])]
(catch Exception e
[:error sql (ex-message e)]))))]
;; Return isolation info
{:isolation-type :schema
:schema-name schema-name
:populator populator
:results results}))))
;; todo: create an evaluator of steps. steps will perhaps for a dag, preconditions, checks, failures. ideally a test
;; would have a list of all steps, randomly execute one, and then execute all steps and ensure that we can proceed to
;; the end. ie, any particular step tha tis already satisfied (schema already exists, user already exists) is ok and
;; we can carry on.
(mu/defn- clickhouse-steps :- ::steps
[{:keys [populator database-name source-database-name]}]
(letfn [(q [d]
(try
(sql.u/quote-name :clickhouse :database (ddl.i/format-name :clickhouse d))
(catch Exception e
(log/errorf e "Error quoting %s" (pr-str d))
(throw e))))]
[:database
{}
(format "CREATE DATABASE %s" (q database-name))
[:users
{}
[:populator
{}
(format "CREATE USER %s IDENTIFIED WITH sha256_password BY '%s'"
(:user populator) (:password populator))
[:populator-privileges
{}
(format "GRANT SELECT ON %s.* TO %s" (q source-database-name) (:user populator))
(format "GRANT CREATE ON %s.* TO %s" (q database-name) (:user populator))
(format "GRANT INSERT ON %s.* TO %s" (q database-name) (:user populator))
(format "GRANT SELECT ON %s.* TO %s" (q database-name) (:user populator))
(format "GRANT DROP ON %s.* TO %s" (q database-name) (:user populator))
(format "GRANT SHOW DATABASES ON *.* TO %s" (:user populator))]]]]))
(comment
(malli.core/validate ::steps
(clickhouse-steps {:populator {:user "populator" :password "populator-pw"}
:database-name "scratchpad"
:source-database-name "foo"}))
)
(defmethod create-isolation* :clickhouse
[_driver {:keys [id connection-details]}]
(let [database-name (isolation-schema-name id)
populator {:user (isolation-user-name id :populator) :password (str (random-uuid))}
jdbc-spec (sql-jdbc.conn/connection-details->spec :clickhouse connection-details)
steps (clickhouse-steps {:populator populator :database-name database-name
:source-database-name ((some-fn :db :dbname) connection-details)})]
(jdbc/with-db-transaction [tx jdbc-spec]
(let [results (evaluate-steps steps (fn [sql]
(try [:success sql (jdbc/execute! tx [sql])]
(catch Exception e
[:error sql (ex-message e)]))))]
{:isolation-type :database
:database-name database-name
:populator populator
:results results}))))
(comment
;; future work to check for existing schemas
(let [metadata (.getMetaData (:connection tx))]
(let [schemas (into [] (map :table_schem) (jdbc/result-set-seq (.getSchemas metadata)))]
{:schemas schemas
:isolations (filter #(String/.startsWith % "mb__isolation_") schemas)
:our-isolations (filter #(String/.startsWith % "mb__isolation_7748c") schemas)
:already-exists? (some #{schema-name} schemas)
:driver _driver}))
;; we're a bit sloppy with db vs dbname?
(let [workspace-id "workspace_manual_01"
connection-details {:scan-all-databases false, :ssl false, :password "password", :destination-database false,
:port 8123, :advanced-options false, :dbname "sales_data", :host "localhost",
:tunnel-enabled false, :user "default"}]
(create-isolation :clickhouse (:connection-details workspace) workspace-id))
{:isolation-type :database,
:database-name "mb__isolation_7748c_workspace_manual_01",
:populator {:user "mb_iso_7748c_workspace_manual_01_populator",
:password "9d8285d5-94c3-4e87-a98a-1c47c9fb727b"},}
(delete-isolation :clickhouse (:connection-details workspace) (:id workspace))
{:deleted-database "mb__isolation_7748c_workspace_manual_01",
:deleted-users ["mb_iso_7748c_workspace_manual_01_populator"]}
)
(defn create-isolation
"Create database isolation for a single database.
Creates an isolated environment in the specified database where LLM agents can operate safely.
The isolation includes:
- A schema (PostgreSQL) or database (ClickHouse) with workspace-specific naming
- A populator user with read access to original data + write access to isolated area
Args:
engine - Database engine keyword (:postgres, :clickhouse, etc.)
connection-details - Database connection details (compatible with Metabase format)
workspace-id - Unique workspace identifier for naming isolation resources
Returns:
Map containing isolation details:
:schema-name/:database-name - Name of created isolation area
:populator - map with user and password for populator (read original +
write isolated)
Example:
(create-isolation :postgres db-connection-details \"workspace-123\")
;; postgres isolations by schema
{:isolation-type :schema
:schema-name \"mb__isolation_7748c_test_workspace_123\"
:populator {:user \"mb_iso_7748c_test_workspace_123_populator\"
:password \"d632f61c-398a-467d-856c-d8411665bd0f\"}}
;; clickhouse isolates by database
{:isolation-type :database
:database-name \"mb__isolation_7748c_testing_workspace216479\"
:populator {:user \"mb_iso_7748c_testing_workspace216479_populator\"
:password \"a9445a75-621f-4211-82e1-9f96ee797dd6\"}}"
[engine connection-details workspace-id]
(create-isolation* engine {:id workspace-id :connection-details connection-details}))
(defmulti ^:private delete-isolation*
"Delete database isolation for a workspace. This is deliberately not public. Use `delete-isolation` as an entrypoint."
(fn [driver workspace] driver))
(defmethod delete-isolation* :postgres
[_driver {:keys [id connection-details isolation-info]}]
(let [schema-name (or (:schema-name isolation-info)
(str/upper-case (isolation-schema-name id)))
populator-user (or (-> isolation-info :populator :user)
(isolation-user-name id :populator))
jdbc-spec (sql-jdbc.conn/connection-details->spec :postgres connection-details)
teardown-steps [:cleanup
{:error-strategy ::continue-on-error}
[:remove-privileges
{}
(format "REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA %s FROM %s" "public" populator-user)
(format "REVOKE ALL PRIVILEGES ON SCHEMA %s FROM %s" "public" populator-user)]
[:remove-new-schema
{}
;; Drop schema (CASCADE to drop all objects in it).
(format "DROP SCHEMA IF EXISTS %s CASCADE" schema-name)]
[:drop-user
{}
;; Drop users
(format "DROP USER IF EXISTS %s" populator-user)]]]
(jdbc/with-db-transaction [tx jdbc-spec]
(let [results (evaluate-steps teardown-steps (fn [sql]
(try [:success sql (jdbc/execute! tx [sql])]
(catch Exception e
[:error sql (ex-message e)]))))]
{:deleted-schema schema-name
:deleted-users [populator-user]
:results results}))))
(defmethod delete-isolation* :clickhouse
[_driver {:keys [id connection-details isolation-info]}]
(let [database-name (or (:database-name isolation-info)
(isolation-schema-name id))
populator-user (or (-> isolation-info :populator :user)
(isolation-user-name id :populator))
jdbc-spec (sql-jdbc.conn/connection-details->spec :clickhouse connection-details)
steps [:cleanup
{:error-strategy ::continue-on-error}
[:drop-database {}
(format "DROP DATABASE IF EXISTS %s" database-name)]
[:remove-users {}
(format "DROP USER IF EXISTS %s" populator-user)]]]
(jdbc/with-db-transaction [tx jdbc-spec]
(let [results (evaluate-steps steps (fn [sql]
(try [:success sql (jdbc/execute! tx [sql])]
(catch Exception e
[:error sql (ex-message e)]))))]
{:deleted-database database-name
:deleted-users [populator-user]
:results results}))))
(defmethod delete-isolation* :snowflake
[_driver {:keys [id connection-details isolation-info] :as x}]
(let [schema-name (or (:schema-name isolation-info)
(isolation-schema-name id))
populator-user (or (-> isolation-info :populator :user)
(isolation-user-name id :populator))
jdbc-spec (sql-jdbc.conn/connection-details->spec :snowflake connection-details)
steps [:cleanup {:error-strategy ::continue-on-error}
[:drop-schema {} (format "DROP SCHEMA %s" schema-name)]
[:drop-user {} (format "DROP USER %s" populator-user)]
[:drop-role {} (format "DROP ROLE %s" (-> isolation-info :populator :role))]]]
(jdbc/with-db-transaction [tx jdbc-spec]
(let [results (evaluate-steps steps (fn [sql]
(try [:success sql (jdbc/execute! tx [sql])]
(catch Exception e
[:error sql (ex-message e)]))))]
{:deleted-schema schema-name :deleted-users [populator-user] :results results}))))
(defn delete-isolation
"Delete database isolation for a single database.
Cleans up all isolation resources created for the specified database:
- Drops the isolated schema/database
- Drops the created user (populator)
- Revokes any granted privileges
Args:
engine - Database engine keyword (:postgres, :clickhouse, etc.)
connection-details - Database connection details (compatible with Metabase format)
workspace-id - Unique workspace identifier used when creating isolation
isolation-info - (Optional) Result from create-isolation for more efficient cleanup
Returns:
Map containing cleanup details:
:deleted-schema/:deleted-database - Name of dropped isolation area
:deleted-users - List of dropped usernames
Example:
(delete-isolation :postgres db-connection-details \"workspace-123\" isolation-result)"
([engine connection-details workspace-id]
(delete-isolation* engine {:id workspace-id :connection-details connection-details}))
([engine connection-details workspace-id isolation-info]
(delete-isolation* engine {:id workspace-id
:connection-details connection-details
:isolation-info isolation-info})))

View File

@@ -0,0 +1,280 @@
(ns metabase-enterprise.workspaces.models.workspace
"Workspaces are a container that an LLM can use to store and manage state. They are the answer to 'How do we allow LLMs to do stuff, in a safe way?'.
It has locked down permissions and
is used to store information that the LLM can use to answer questions or perform tasks. It is not a database, but
rather a way to manage state in a way that is secure and controlled.
There's also a concept of a workspace having a user and a workspace having a database. The user has limited access to the database so that they can only
access the data that is relevant to them. The workspace is a way to manage this access and control what the user can do with the data.
Workspace users are not the same as Metabase users. They do not have a permission group, They are a separate concept
that is used to manage access to the workspace and the data within it. Creating a workspace should also create a user.
Workspaces are independent of one another by design. A workspace can be deleted without affecting other workspaces.
And they cannot be used together. This is to ensure that the data in one workspace cannot be accessed by another
workspace user.
"
#_{:clj-kondo/ignore [:metabase/modules]}
(:require
[babashka.fs :as fs]
[clj-yaml.core :as yaml]
[clojure.string :as str]
[java-time.api :as t]
[malli.generator :as mg]
[malli.util :as mut]
[metabase-enterprise.transforms.ordering :as transforms.ordering]
[metabase.models.interface :as mi]
[metabase.query-processor.store :as qp.store]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(mr/def ::created_at
[:string {:gen/fmap #(str (t/instant (hash %)))}])
(mr/def ::plan [:map
[:title :string]
[:description :string]
[:content :map]
[:created_at ::created_at]])
;; maybe another table?
(mr/def ::activity-log [:map
[:name [:string {:min 1}]]
;; needs a plan?
[:steps [:sequential
[:map
[:step-id [:int {:min 0}]]
[:description :string]
[:status [:enum :pending :running :completed :failed]]
[:outcome {:optional true} [:enum :success :error :warning]]
[:error-message {:optional true} :string]
[:start-time [:string {:description "When the step started"}]]
[:end-time {:optional true} [:maybe {:description "When the step ended"} :string]]
[:created_at ::created_at]
[:updated-at [:string {:description "When the step was last updated"}]]]]]])
;; look at transforms, make it match
(mr/def ::transform
[:map
[:name :string]
[:description :string]
[:source [:map
[:type [:or :string]] #_[:enum "query" "table"]
[:query {:optional true} [:map
[:database {:description "ID of the source database"} :int]
[:type :string] ;; native or query
[:native {:optional true} [:map
[:query :string]
[:template-tags {:optional true} [:map]]]]]]]]
[:target [:map [:name :string] [:type :string]]]
[:created_at ::created_at]
[:config {:optional true} [:map]]])
;; wip
(mr/def ::document [:int {:description "ID of a document in app-db" :min 1}])
(mr/def ::user
[:map
[:id :int]
[:type :string] ;; workspace-user
[:name :string]
[:email :string]
[:created_at ::created_at]
;; api-key?
;; db creds?
])
(mr/def ::data-warehouse
[:map
[:id [:int {:min 1}]]
[:created_at ::created_at]
[:type [:enum "read-only" "read-write"]]
[:credentials :map]
[:name :string]])
(mr/def ::permission
[:map
[:table :string]
[:created_at ::created_at]
[:permission [:enum "read" "write"]]])
(mr/def ::workspace
[:map
[:id {:optional true} [:maybe [:int {:min 1}]]]
[:collection_id {:optional true} [:maybe [:int {:min 1}]]]
[:name [:string {:min 1}]]
[:description {:optional true} [:maybe :string]]
;; each plan, transofrm, and document(<-unsure) is basically a file abstraction
[:plans [:sequential ::plan]]
;; This should maybe be another table:
[:activity_logs [:sequential ::activity-log]]
[:transforms [:sequential ::transform]]
[:documents [:sequential ::document]]
[:users [:sequential ::user]]
[:data_warehouses [:sequential ::data-warehouse]]
[:permissions [:sequential ::permission]]
[:created_at [:any {:description "The date and time the workspace was created"}]]
[:updated_at [:any {:description "The date and time the workspace was last updated"}]]])
(methodical/defmethod t2/table-name :model/Workspace [_model] :workspace)
(t2/deftransforms :model/Workspace
{:plans mi/transform-json
:activity_logs mi/transform-json
:transforms mi/transform-json
:documents mi/transform-json
:users mi/transform-json
:data_warehouses mi/transform-json
:permissions mi/transform-json})
(doto :model/Workspace
(derive :metabase/model)
(derive :hook/timestamped?))
(defn- sort-toplevel-keys
"Sorts top-level keys in the order they're defined in the schema."
[k1 k2]
(let [order (zipmap (mut/keys (mr/resolve-schema ::workspace)) (range))]
(compare (get order k1 Integer/MAX_VALUE)
(get order k2 Integer/MAX_VALUE))))
(defn- created-at-sort
"Sort a collection of workspaces by their `created_at` timestamp, newest first.
Handles nil or invalid created_at values by treating them as oldest."
[xs]
(vec (sort-by (fn [item]
(try
(when-let [created_at (get item :created_at)]
(t/instant created_at))
(catch Exception _
;; Return a very old instant for invalid dates
(t/instant "1970-01-01T00:00:00Z"))))
#(compare %2 %1) ; Reverse comparison for newest first
xs)))
(defn sort-workspace
"Required for a stable diff in a yaml view"
[workspace]
(-> (into (sorted-map-by sort-toplevel-keys) workspace)
(update :documents (fnil (comp vec sort) []))
(update :plans (fnil created-at-sort []))
(update :transforms (fnil created-at-sort []))
(update :users (fnil created-at-sort []))
(update :data_warehouses (fnil created-at-sort []))
(update :permissions (fnil created-at-sort []))))
(defn transform-table-dependencies
"Identifies the database tables that a transform depends on.
For workspace transforms, this function takes a transform object and returns
a set of table IDs that the transform's source query depends on.
Args:
- transform: A transform object with :source containing query information
Returns:
- A set of table IDs (integers) that the transform depends on"
[transform]
(when-let [db-id (get-in transform [:source :query :database])]
(qp.store/with-metadata-provider db-id
(#'transforms.ordering/transform-deps transform))))
(defn workspace-transform-dependencies
"Analyzes all transforms in a workspace and returns their table dependencies.
Args:
- workspace: A workspace object containing transforms
Returns:
- A map of transform name -> set of table IDs that transform depends on"
[workspace]
(into {}
(keep (fn [transform]
(when-let [deps (transform-table-dependencies transform)]
[(:name transform) deps])))
(:transforms workspace [])))
(defn transform-table-dependencies-detailed
"Returns detailed information about the tables a transform depends on.
Args:
- transform: A transform object with :source containing query information
Returns:
- A seq of maps containing table details: {:id, :name, :schema, :display_name, etc.}"
[transform]
(when-let [table-ids (transform-table-dependencies transform)]
(when (seq table-ids)
(t2/select [:model/Table :id :name :schema :display_name :description :db_id]
:id [:in table-ids]))))
(defn workspace-transform-dependencies-detailed
"Returns detailed table dependency information for all transforms in a workspace.
Args:
- workspace: A workspace object containing transforms
Returns:
- A map of transform name -> seq of detailed table information"
[workspace]
(into {}
(keep (fn [transform]
(when-let [deps (transform-table-dependencies-detailed transform)]
[(:name transform) deps])))
(:transforms workspace [])))
(comment
(require '[clojure.walk :as walk])
(require '[malli.core :as mc] '[malli.error :as me] '[malli.util :as mut] '[metabase.util.malli :as mu]
'[metabase.util.malli.describe :as umd] '[malli.provider :as mp] '[malli.generator :as mg]
'[malli.transform :as mtx] '[metabase.util.malli.registry :as mr] '[malli.json-schema :as mjs])
(defn super-get [m k]
(let [found (volatile! [])]
(walk/postwalk
(fn [x]
(when (and (coll? x)
(= 2 (count x))
(= k (first x)))
(vswap! found conj (second x)))
x)
m)
@found))
(transform-table-dependencies (:transforms (sort-workspace (t2/select-one :model/Workspace 254))))
;; correct order:
;; => ("2025-08-08T21:16:50.420896Z"
;; "2025-08-08T21:13:40.567837Z"
;; "2025-08-08T21:07:45.052178Z"
;; "2025-08-08T21:03:43.194484Z"
;; "2025-08-08T20:07:45.709135Z")
;; => ("2025-08-08T20:07:45.709135Z"
;; "2025-08-08T21:16:50.420896Z"
;; "2025-08-08T21:13:40.567837Z"
;; "2025-08-08T21:07:45.052178Z"
;; "2025-08-08T21:03:43.194484Z")
)
(mu/defn- ->yaml [workspace :- ::workspace]
(yaml/generate-string (sort-workspace workspace) :dumper-options {:flow-style :block}))
;; temp for testing
(mu/defn- write-yaml
([workspace] (write-yaml workspace {}))
([workspace :- ::workspace
options :- [:map [:prefix :string]]]
(let [file-name (str (:prefix options "")
(str/replace (:name workspace) " " "_") ".yaml")]
(fs/create-dirs (fs/parent file-name))
(println "Writing workspace to" file-name)
(spit file-name (->yaml workspace)))))
(comment
(let [w (mg/generate ::workspace {:size 1})]
(write-yaml w {:prefix "yaml_samples/"})))

View File

@@ -0,0 +1,82 @@
(ns metabase-enterprise.documents.api.collection-test
(:require
[clojure.test :refer :all]
[metabase.test :as mt]))
(deftest document-cards-do-not-appear-in-collection-items
(testing "GET /api/collection/:id/items excludes cards with document_id"
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Document {doc-id :id} {}
:model/Card {normal-card-id :id} {:collection_id coll-id}
:model/Card _ {:collection_id coll-id
:document_id doc-id}
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
(testing "Normal cards and dashboards appear, but in_document cards do not"
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model)
(:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items"))))))))
(testing "Even with show_dashboard_questions=true, in_document cards do not appear"
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model)
(:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items?show_dashboard_questions=true")))))))))))
(deftest document-cards-do-not-appear-in-root-items
(testing "GET /api/collection/root/items excludes cards with document_id"
(mt/with-temp [:model/Document {doc-id :id} {}
:model/Card {normal-card-id :id} {:collection_id nil
:name "Normal Root Card"}
:model/Card {in-document-card-id :id} {:collection_id nil
:document_id doc-id
:name "In Document Root Card"}
:model/Dashboard {dash-id :id} {:collection_id nil
:name "Root Dashboard"}]
(testing "Normal cards and dashboards in root appear, but in_document cards do not"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
root-test-items (filter #(#{normal-card-id dash-id in-document-card-id} (:id %))
(:data items))]
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items))))))
(testing "Even with show_dashboard_questions=true, in_document cards do not appear"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items?show_dashboard_questions=true")
root-test-items (filter #(#{normal-card-id dash-id in-document-card-id} (:id %))
(:data items))]
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items)))))))))
(deftest documents-appear-in-collection-items
(testing "GET /api/collection/:id/items includes documents"
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Document {doc-id :id} {:collection_id coll-id
:name "Test Document"}
:model/Card {card-id :id} {:collection_id coll-id}
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
(testing "Documents appear alongside cards and dashboards"
(is (= #{[doc-id "document"]
[card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model)
(:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items")))))))))))
(deftest documents-appear-in-root-items
(testing "GET /api/collection/root/items includes documents"
(mt/with-temp [:model/Document {doc-id :id} {:collection_id nil
:name "Root Document"}
:model/Card {card-id :id} {:collection_id nil
:name "Root Card"}
:model/Dashboard {dash-id :id} {:collection_id nil
:name "Root Dashboard"}]
(testing "Documents appear alongside cards and dashboards in root"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
root-test-items (filter #(#{doc-id card-id dash-id} (:id %))
(:data items))]
(is (= #{[doc-id "document"]
[card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items)))))))))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
(ns backend.test.metabase-enterprise.documents.models.dashboard-card-test
(:require
[clojure.test :refer :all]
[metabase.dashboards.models.dashboard-card :as dashboard-card]
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest prevent-in-report-cards-from-being-added-to-dashboards-test
(testing "Cannot add a card with document_id to dashboard"
(mt/with-temp [:model/Dashboard dashboard {}
:model/Document document {}
:model/Card normal-card {:name "Normal Card" :type :question}
:model/Card report-card {:name "Report Card" :type :question :document_id (:id document)}]
(testing "Adding a normal card works fine"
(let [result (dashboard-card/create-dashboard-cards!
[{:dashboard_id (:id dashboard)
:card_id (:id normal-card)
:size_x 4
:size_y 3
:row 0
:col 0}])]
(is (= 1 (count result)))
(is (= (:id normal-card) (:card_id (first result))))))
(testing "Adding an in_document card throws an exception"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Cards with 'document_id' cannot be added to dashboards"
(dashboard-card/create-dashboard-cards!
[{:dashboard_id (:id dashboard)
:card_id (:id report-card)
:size_x 4
:size_y 3
:row 1
:col 0}]))))))
(testing "Cannot add multiple cards if any of them has a document_id"
(mt/with-temp [:model/Dashboard dashboard {}
:model/Document document {}
:model/Card normal-card1 {:name "Normal Card 1" :type :question}
:model/Card normal-card2 {:name "Normal Card 2" :type :model}
:model/Card report-card1 {:name "Report Card" :type :question :document_id (:id document)}]
(testing "Batch creation fails if any card is in_document type"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Cards with 'document_id' cannot be added to dashboards"
(dashboard-card/create-dashboard-cards!
[{:dashboard_id (:id dashboard)
:card_id (:id normal-card1)
:size_x 4
:size_y 3
:row 0
:col 0}
{:dashboard_id (:id dashboard)
:card_id (:id report-card1)
:size_x 4
:size_y 3
:row 0
:col 4}
{:dashboard_id (:id dashboard)
:card_id (:id normal-card2)
:size_x 4
:size_y 3
:row 0
:col 8}])))
;; Verify that no cards were added due to transaction rollback
(is (= 0 (t2/count :model/DashboardCard :dashboard_id (:id dashboard))))))))

View File

@@ -0,0 +1,361 @@
(ns metabase-enterprise.documents.models.document-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.documents.models.document :as document]
[metabase.collections.models.collection :as collection]
[metabase.permissions.core :as perms]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db :test-users))
(deftest sync-document-cards-collection-matching-cards-test
(testing "should only update cards with matching document_id"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
:model/Document {document-id :id} {:name "Test Document"}
:model/Card {card1-id :id} {:name "Document Card 1"
:document_id document-id
:collection_id nil
:dataset_query (mt/mbql-query venues)}
:model/Card {card2-id :id} {:name "Document Card 2"
:document_id document-id
:collection_id nil
:dataset_query (mt/mbql-query venues)}
:model/Card {other-card-id :id} {:name "Other Card"
:type :question
:collection_id nil
:dataset_query (mt/mbql-query venues)}]
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
;; Should update exactly 2 cards
(is (= 2 updated-count))
;; Verify the document cards were updated
(let [updated-card1 (t2/select-one :model/Card :id card1-id)
updated-card2 (t2/select-one :model/Card :id card2-id)
other-card (t2/select-one :model/Card :id other-card-id)]
(is (= collection-id (:collection_id updated-card1)))
(is (= collection-id (:collection_id updated-card2)))
;; Other card should not be affected
(is (nil? (:collection_id other-card))))))))
(deftest sync-document-cards-collection-nil-document-document-id-test
(testing "should not affect cards with nil document_id"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
:model/Document {document-id :id} {:name "Test Document"}
:model/Card {nil-document-card-id :id} {:name "Nil Document Card"
:document_id nil
:collection_id nil
:dataset_query (mt/mbql-query venues)}]
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
;; Should update 0 cards since no cards have matching document_id
(is (= 0 updated-count))
;; Verify the card with nil document_id was not affected
(let [unchanged-card (t2/select-one :model/Card :id nil-document-card-id)]
(is (nil? (:collection_id unchanged-card))))))))
(deftest sync-document-cards-collection-large-numbers-test
(testing "should handle large numbers of cards efficiently"
(mt/with-model-cleanup [:model/Card]
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
:model/Document {document-id :id} {:name "Test Document with Many Cards"}]
;; Create 50 cards associated with the document
(let [card-ids (doall
(for [i (range 50)]
(:id (t2/insert-returning-pks! :model/Card
(merge (mt/with-temp-defaults :model/Card)
{:document_id document-id
:collection_id nil
:dataset_query (mt/mbql-query venues)})))))]
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
;; Should update all 50 cards
(is (= 50 updated-count))
;; Verify all cards were updated (check a few samples)
(let [sample-cards (t2/select :model/Card :id [:in card-ids] {:limit 5})]
(is (every? #(= collection-id (:collection_id %)) sample-cards)))))))))
(deftest sync-document-cards-collection-empty-result-sets-test
(testing "should handle empty result sets gracefully"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
:model/Document {document-id :id} {:name "Empty Document"}]
;; No cards associated with this document
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
;; Should return 0 for no updates
(is (= 0 updated-count))))))
(deftest document-collection-sync-hook-triggers-on-collection-change-test
(testing "Hook is called when document collection_id changes"
(mt/with-temp
[:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/Document {document-id :id} {:collection_id old-collection-id
:name "Test Document"}
:model/Card {card1-id :id} {:name "Card 1"
:document_id document-id
:collection_id old-collection-id}
:model/Card {card2-id :id} {:name "Card 2"
:document_id document-id
:collection_id old-collection-id}]
;; Update the document's collection_id
(t2/update! :model/Document document-id {:collection_id new-collection-id})
;; Verify that associated cards were updated to match the new collection
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card1-id))))
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card2-id)))))))
(deftest document-collection-sync-hook-handles-nil-collections-test
(testing "Hook correctly handles nil collection values"
(mt/with-temp
[:model/Collection {collection-id :id} {:name "Test Collection"}
:model/Document {document-id :id} {:collection_id collection-id
:name "Test Document"}
:model/Card {card-id :id} {:name "Card"
:document_id document-id
:collection_id collection-id}]
;; Move document to no collection (nil)
(t2/update! :model/Document document-id {:collection_id nil})
;; Verify that the card's collection_id was updated to nil
(is (nil? (:collection_id (t2/select-one :model/Card :id card-id)))))))
(deftest document-collection-sync-hook-only-affects-cards-test
(testing "Hook only updates cards with matching document_id"
(mt/with-temp
[:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/Document {document-id :id} {:collection_id old-collection-id
:name "Test Document"}
;; Card that should be updated (correct type and document_id)
:model/Card {in-document-card-id :id} {:name "In-Document Card"
:type :question
:document_id document-id
:collection_id old-collection-id}
;; Card that should be updated (wrong type)
:model/Card {question-card-id :id} {:name "Question Card"
:type :model
:collection_id old-collection-id}
;; Card that should NOT be updated (no document_id)
:model/Card {regular-card-id :id} {:name "Regular Card"
:collection_id old-collection-id}]
;; Update the document's collection_id
(t2/update! :model/Document document-id {:collection_id new-collection-id})
;; Verify only the correct card was updated
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id in-document-card-id))))
;; Verify other cards were NOT updated
(is (= old-collection-id (:collection_id (t2/select-one :model/Card :id question-card-id))))
(is (= old-collection-id (:collection_id (t2/select-one :model/Card :id regular-card-id)))))))
(deftest personal-collection-edge-cases-test
(testing "Personal collection handling"
(binding [collection/*allow-deleting-personal-collections* true]
(mt/with-temp [:model/User {user-id :id} {:first_name "Test" :last_name "User" :email "test@example.com"}
:model/Collection {personal-collection-id :id} {:name "Personal Collection"
:personal_owner_id user-id}
:model/Collection {regular-collection-id :id} {:name "Regular Collection"}
:model/Document {document-id :id} {:collection_id regular-collection-id
:name "Personal Test Document"}
:model/Card {card-id :id} {:name "Personal Card"
:document_id document-id
:collection_id regular-collection-id
:dataset_query (mt/mbql-query venues)}]
(testing "moving document to personal collection moves associated cards"
(mt/with-current-user user-id
;; As the personal collection owner, update should succeed
(t2/update! :model/Document document-id {:collection_id personal-collection-id})
;; Verify both document and card moved to personal collection
(is (= personal-collection-id (:collection_id (t2/select-one :model/Document :id document-id))))
(is (= personal-collection-id (:collection_id (t2/select-one :model/Card :id card-id))))))
(testing "moving document from personal collection works"
(mt/with-current-user user-id
;; Move back to regular collection
(t2/update! :model/Document document-id {:collection_id regular-collection-id})
;; Verify both document and card moved back
(is (= regular-collection-id (:collection_id (t2/select-one :model/Document :id document-id))))
(is (= regular-collection-id (:collection_id (t2/select-one :model/Card :id card-id))))))))))
(deftest validate-collection-move-permissions-allows-move-with-both-permissions-test
(testing "allows move when user has write permissions for both collections"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permissions to both collections
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
;; Should not throw any exception
(is (true? (document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
(deftest validate-collection-move-permissions-throws-403-missing-old-permission-test
(testing "throws 403 when user lacks write permission for old collection"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission only to new collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
;; Should throw 403 exception
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
(deftest validate-collection-move-permissions-throws-403-missing-new-permission-test
(testing "throws 403 when user lacks write permission for new collection"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission only to old collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
;; Should throw 403 exception
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
(deftest validate-collection-move-permissions-throws-403-no-permissions-test
(testing "throws 403 when user lacks write permissions for both collections"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; No permissions granted
;; Should throw 403 exception
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
(deftest validate-collection-move-permissions-allows-move-from-root-test
(testing "allows move when old collection is nil (moving from root)"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission to new collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
;; Should not throw any exception when old collection is nil
(is (true? (document/validate-collection-move-permissions nil new-collection-id))))))))
(deftest validate-collection-move-permissions-allows-move-to-root-test
(testing "allows move when new collection is nil (moving to root)"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission to old collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
;; Should not throw any exception when new collection is nil
(is (nil? (document/validate-collection-move-permissions old-collection-id nil))))))))
(deftest validate-collection-move-permissions-allows-move-both-nil-test
(testing "allows move when both collections are nil"
(mt/with-temp [:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Should not throw any exception when both collections are nil
(is (nil? (document/validate-collection-move-permissions nil nil)))))))
(deftest validate-collection-move-permissions-throws-400-nonexistent-collection-test
(testing "throws 400 when new collection does not exist"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission to old collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
;; Use a non-existent collection ID
(let [non-existent-id 999999]
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
(document/validate-collection-move-permissions old-collection-id non-existent-id)))))))))
(deftest validate-collection-move-permissions-throws-400-archived-collection-test
(testing "throws 400 when new collection is archived"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {archived-collection-id :id} {:name "Archived Collection" :archived true}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permissions to both collections
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) archived-collection-id)
;; Should throw 400 exception for archived collection
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid"
(document/validate-collection-move-permissions old-collection-id archived-collection-id))))))))
(deftest validate-collection-move-permissions-superuser-test
(testing "superuser can move between any collections"
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}]
(mt/with-current-user (mt/user->id :crowberto)
;; Superuser should be able to move without explicit permissions
(is (true? (document/validate-collection-move-permissions old-collection-id new-collection-id)))))))
(deftest hydrate-document-creator-test
(testing "hydrates document creator correctly"
(mt/with-temp [:model/User {user-id :id} {:first_name "John"
:last_name "Doe"
:email "john.doe@example.com"}
:model/Document {document-id :id} {:name "Test Document"
:creator_id user-id}]
(let [hydrated-doc (t2/hydrate (t2/select-one :model/Document :id document-id) :creator)]
(testing "creator is hydrated with correct user data"
(is (some? (:creator hydrated-doc)))
(is (= user-id (get-in hydrated-doc [:creator :id])))
(is (= "John" (get-in hydrated-doc [:creator :first_name])))
(is (= "Doe" (get-in hydrated-doc [:creator :last_name])))
(is (= "john.doe@example.com" (get-in hydrated-doc [:creator :email]))))))))
(deftest hydrate-multiple-documents-creator-test
(testing "hydrates creators for multiple documents efficiently"
(mt/with-temp [:model/User {user1-id :id} {:first_name "Alice"
:last_name "Smith"
:email "alice@example.com"}
:model/User {user2-id :id} {:first_name "Bob"
:last_name "Jones"
:email "bob@example.com"}
:model/Document {doc1-id :id} {:name "Document 1"
:creator_id user1-id}
:model/Document {doc2-id :id} {:name "Document 2"
:creator_id user2-id}
:model/Document {doc3-id :id} {:name "Document 3"
:creator_id user1-id}] ; Same creator as doc1
(let [documents (t2/select :model/Document :id [:in [doc1-id doc2-id doc3-id]])
hydrated-docs (t2/hydrate documents :creator)]
(testing "all documents have their creators hydrated"
(is (= 3 (count hydrated-docs)))
(doseq [doc hydrated-docs]
(is (some? (:creator doc)))
(is (some? (get-in doc [:creator :id])))
(is (some? (get-in doc [:creator :first_name])))
(is (some? (get-in doc [:creator :last_name])))
(is (some? (get-in doc [:creator :email])))))
(testing "creators are correctly matched to documents"
(let [doc1 (first (filter #(= doc1-id (:id %)) hydrated-docs))
doc2 (first (filter #(= doc2-id (:id %)) hydrated-docs))
doc3 (first (filter #(= doc3-id (:id %)) hydrated-docs))]
(is (= user1-id (get-in doc1 [:creator :id])))
(is (= "Alice" (get-in doc1 [:creator :first_name])))
(is (= user2-id (get-in doc2 [:creator :id])))
(is (= "Bob" (get-in doc2 [:creator :first_name])))
(is (= user1-id (get-in doc3 [:creator :id])))
(is (= "Alice" (get-in doc3 [:creator :first_name])))))))))

View File

@@ -0,0 +1,319 @@
(ns metabase-enterprise.documents.prose-mirror-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.documents.prose-mirror :as prose-mirror]))
(deftest ^:parallel update-ast-basic-test
(testing "updates nodes that match predicate"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}
{:type "paragraph"
:content [{:type "text"
:text "World"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (= (:type %) "text"))
updater #(assoc % :text (str (:text %) "!"))
result (prose-mirror/update-ast doc predicate updater)]
(is (= "Hello!" (get-in result [:document :content 0 :content 0 :text])))
(is (= "World!" (get-in result [:document :content 1 :content 0 :text]))))))
(deftest ^:parallel update-ast-non-matching-test
(testing "leaves non-matching nodes unchanged"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}
{:type "heading"
:content [{:type "text"
:text "Title"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (= (:type %) "paragraph"))
updater #(assoc % :modified true)
result (prose-mirror/update-ast doc predicate updater)]
(is (true? (get-in result [:document :content 0 :modified])))
(is (nil? (get-in result [:document :content 1 :modified]))))))
(deftest ^:parallel update-ast-empty-document-test
(testing "handles empty document"
(let [doc {:document {}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(= (:type %) "text")
updater #(assoc % :updated true)
result (prose-mirror/update-ast doc predicate updater)]
(is (= {} (:document result))))))
(deftest ^:parallel update-ast-nested-structures-test
(testing "handles nested structures"
(let [doc {:document {:type "doc"
:content [{:type "table"
:content [{:type "table_row"
:content [{:type "table_cell"
:content [{:type "paragraph"
:content [{:type "text"
:text "Cell"}]}]}]}]}]}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (= (:type %) "text"))
updater #(update % :text str "-updated")
result (prose-mirror/update-ast doc predicate updater)]
(is (= "Cell-updated"
(get-in result [:document :content 0 :content 0 :content 0 :content 0 :content 0 :text]))))))
(deftest ^:parallel update-ast-all-node-types-test
(testing "predicate receives all node types including non-maps"
(let [doc {:document {:type "doc"
:content ["string-node"
42
{:type "paragraph"}]}
:content_type prose-mirror/prose-mirror-content-type}
visited-nodes (atom [])
predicate (fn [node]
(swap! visited-nodes conj node)
false)
updater identity]
(prose-mirror/update-ast doc predicate updater)
(is (some string? @visited-nodes))
(is (some number? @visited-nodes))
(is (some map? @visited-nodes)))))
(deftest ^:parallel update-ast-updater-function-test
(testing "updater function is applied correctly"
(let [doc {:document {:type "doc"
:attrs {:level 1}}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (contains? % :attrs))
updater #(update-in % [:attrs :level] inc)
result (prose-mirror/update-ast doc predicate updater)]
(is (= 2 (get-in result [:document :attrs :level]))))))
(deftest ^:parallel update-ast-invalid-content-type-test
(testing "throws exception for invalid content-type"
(let [doc {:document {:type "doc"}
:content_type "text/plain"}
predicate identity
updater identity]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Document does not have the prose mirror content-type"
(prose-mirror/update-ast doc predicate updater))))))
(deftest ^:parallel update-ast-exception-data-test
(testing "exception contains correct data"
(let [doc {:document {:type "doc"}
:content_type "application/json"}
predicate identity
updater identity]
(try
(prose-mirror/update-ast doc predicate updater)
(is false "Should have thrown exception")
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)]
(is (= "application/json" (:content-type data)))
(is (= 400 (:status-code data)))))))))
(deftest ^:parallel update-ast-preserve-fields-test
(testing "preserves other document fields"
(let [doc {:document {:type "doc"}
:content_type prose-mirror/prose-mirror-content-type
:id 123
:name "Test Doc"
:other-field "value"}
predicate (constantly false)
updater identity
result (prose-mirror/update-ast doc predicate updater)]
(is (= 123 (:id result)))
(is (= "Test Doc" (:name result)))
(is (= "value" (:other-field result))))))
(deftest ^:parallel update-ast-nil-document-test
(testing "handles nil document"
(let [doc {:document nil
:content_type prose-mirror/prose-mirror-content-type}
predicate identity
updater identity
result (prose-mirror/update-ast doc predicate updater)]
(is (nil? (:document result))))))
(deftest ^:parallel update-ast-multiple-updates-test
(testing "multiple updates on same node"
(let [doc {:document {:type "text" :text "hello"}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (= (:type %) "text"))
updater #(-> %
(update :text str "!")
(assoc :modified true))
result (prose-mirror/update-ast doc predicate updater)]
(is (= "hello!" (get-in result [:document :text])))
(is (true? (get-in result [:document :modified]))))))
(deftest ^:parallel update-ast-content-type-constant-test
(testing "works with valid prose-mirror content-type constant"
(let [doc {:document {:type "doc"}
:content_type prose-mirror/prose-mirror-content-type}
predicate (constantly false)
updater identity]
(is (some? (prose-mirror/update-ast doc predicate updater))))))
(deftest ^:parallel collect-ast-basic-test
(testing "collects values from matching nodes"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}
{:type "paragraph"
:content [{:type "text"
:text "World"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "text"))
(:text %))
result (prose-mirror/collect-ast doc collector)]
(is (= ["Hello" "World"] (vec result))))))
(deftest ^:parallel collect-ast-empty-results-test
(testing "returns empty sequence when no matches"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (= (:type %) "nonexistent") %)
result (prose-mirror/collect-ast doc collector)]
(is (empty? result)))))
(deftest ^:parallel collect-ast-nil-filtering-test
(testing "removes nils from results"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}
{:type "heading"
:content [{:type "text"
:text "Title"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "text") (= (:text %) "Hello"))
(:text %))
result (prose-mirror/collect-ast doc collector)]
(is (= ["Hello"] (vec result))))))
(deftest ^:parallel collect-ast-nested-structures-test
(testing "works with nested structures"
(let [doc {:document {:type "doc"
:content [{:type "table"
:content [{:type "table_row"
:content [{:type "table_cell"
:content [{:type "paragraph"
:content [{:type "text"
:text "Cell1"}]}]}
{:type "table_cell"
:content [{:type "paragraph"
:content [{:type "text"
:text "Cell2"}]}]}]}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "text"))
(:text %))
result (prose-mirror/collect-ast doc collector)]
(is (= ["Cell1" "Cell2"] (vec result))))))
(deftest ^:parallel collect-ast-traversal-order-test
(testing "collector receives all nodes in tree traversal order"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "First"}]}
{:type "paragraph"
:content [{:type "text"
:text "Second"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
visited-types (atom [])
collector (fn [node]
(when (map? node)
(swap! visited-types conj (:type node)))
nil)]
(doall (prose-mirror/collect-ast doc collector))
(is (= ["doc" "paragraph" "text" "paragraph" "text"] @visited-types)))))
(deftest ^:parallel collect-ast-lazy-sequence-test
(testing "returns lazy sequence"
(let [doc {:document {:type "doc"
:content [{:type "text" :text "test"}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (map? %) (:type %))
result (prose-mirror/collect-ast doc collector)]
(is (instance? clojure.lang.LazySeq result)))))
(deftest ^:parallel collect-ast-empty-document-test
(testing "handles empty document"
(let [doc {:document {}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (map? %) (:type %))
result (prose-mirror/collect-ast doc collector)]
(is (empty? result)))))
(deftest ^:parallel collect-ast-nil-document-test
(testing "handles nil document"
(let [doc {:document nil
:content_type prose-mirror/prose-mirror-content-type}
collector identity
result (prose-mirror/collect-ast doc collector)]
(is (empty? result)))))
(deftest ^:parallel collect-ast-complex-objects-test
(testing "collector can return complex objects"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:attrs {:id "p1"}
:content [{:type "text"
:text "Hello"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "paragraph"))
{:id (get-in % [:attrs :id])
:type (:type %)})
result (prose-mirror/collect-ast doc collector)]
(is (= [{:id "p1" :type "paragraph"}] (vec result))))))
(deftest ^:parallel collect-ast-invalid-content-type-test
(testing "throws exception for invalid content-type"
(let [doc {:document {:type "doc"}
:content_type "text/plain"}
collector identity]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Document does not have the prose mirror content-type"
(prose-mirror/collect-ast doc collector))))))
(deftest ^:parallel collect-ast-exception-data-test
(testing "exception contains correct data for collect-ast"
(let [doc {:document {:type "doc"}
:content_type "application/xml"}
collector identity]
(try
(doall (prose-mirror/collect-ast doc collector))
(is false "Should have thrown exception")
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)]
(is (= "application/xml" (:content-type data)))
(is (= 400 (:status-code data)))))))))
(deftest ^:parallel collect-ast-card-embed-test
(testing "works with card embed nodes"
(let [doc {:document {:type "doc"
:content [{:type prose-mirror/card-embed-type
:attrs {:cardId 123}}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) prose-mirror/card-embed-type))
(get-in % [:attrs :cardId]))
result (prose-mirror/collect-ast doc collector)]
(is (= [123] (vec result))))))
(deftest ^:parallel collect-ast-order-preservation-test
(testing "preserves order of collection"
(let [doc {:document {:type "doc"
:content [{:type "text" :text "first"}
{:type "text" :text "second"}
{:type "text" :text "third"}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "text"))
(:text %))
result (prose-mirror/collect-ast doc collector)]
(is (= ["first" "second" "third"] (vec result))))))

View File

@@ -0,0 +1,36 @@
(ns metabase-enterprise.documents.recent-views-test
(:require
[clojure.test :refer :all]
[metabase.events.core :as events]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- most-recent-view
[user-id model-id model-type]
(t2/select-one [:model/RecentViews :user_id :model :model_id]
:user_id user-id
:model_id model-id
:model model-type
{:order-by [[:id :desc]]}))
(deftest card-query-test
(mt/with-test-user :rasta
(testing "document cards should not be counted"
(mt/with-temp [:model/Document {doc-id :id} {}
:model/Card card-3 {:creator_id (mt/user->id :rasta)
:document_id doc-id}]
(events/publish-event! :event/card-query {:card-id (:id card-3)
:user-id (mt/user->id :rasta)
:context :question})
(is (nil? (most-recent-view (mt/user->id :rasta) (:id card-3) "card")))))))
(deftest legacy-card-read-test
(testing "in_document cards should not be counted even with context :question"
(mt/with-temp [:model/Document {doc-id :id} {}
:model/Card card {:creator_id (mt/user->id :rasta)
:document_id doc-id}]
(mt/with-test-user :rasta
(events/publish-event! :event/card-read {:object-id (:id card)
:user-id (mt/user->id :rasta)
:context :question})
(is (nil? (most-recent-view (mt/user->id :rasta) (:id card) "card")))))))

View File

@@ -0,0 +1,102 @@
(ns metabase-enterprise.documents.revisions.impl-test
"Unit tests for Document revision serialization and reversion functionality."
(:require
[clojure.test :refer :all]
[metabase-enterprise.documents.revisions.impl]
[metabase.revisions.models.revision :as revision]
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest serialize-instance-document-test
(testing "Document revision serialization excludes metadata fields"
(let [document {:id 123
:name "Test Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
:creator_id 456
:created_at #t "2023-01-01T00:00:00Z"
:updated_at #t "2023-01-02T00:00:00Z"
:collection_id 789
:archived false}
serialized (revision/serialize-instance :model/Document 123 document)]
(testing "includes Document-specific fields"
(is (= "Test Document" (:name serialized)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
(:document serialized)))
(is (= false (:archived serialized))))
(testing "excludes metadata fields"
(is (not (contains? serialized :id)))
(is (not (contains? serialized :creator_id)))
(is (not (contains? serialized :created_at)))
(is (not (contains? serialized :updated_at)))
(is (not (contains? serialized :collection_id)))))))
(deftest serialize-instance-document-with-id-test
(testing "Document revision serialization works with different IDs"
(let [document {:id 123
:name "Test Document"
:document {:type "doc" :content []}
:creator_id 456
:created_at #t "2023-01-01T00:00:00Z"
:updated_at #t "2023-01-02T00:00:00Z"
:collection_id 789}]
(testing "works with matching ID"
(let [serialized (revision/serialize-instance :model/Document 123 document)]
(is (= "Test Document" (:name serialized)))
(is (not (contains? serialized :id)))))
(testing "works with different ID"
(let [serialized (revision/serialize-instance :model/Document 999 document)]
(is (= "Test Document" (:name serialized)))
(is (not (contains? serialized :id))))))))
(deftest serialize-instance-document-minimal-test
(testing "Document revision serialization works with minimal document"
(let [document {:id 1
:name "Minimal Doc"
:document {:type "doc"}
:creator_id 2
:created_at #t "2023-01-01T00:00:00Z"
:updated_at #t "2023-01-01T00:00:00Z"
:collection_id nil}
serialized (revision/serialize-instance :model/Document 1 document)]
(is (= "Minimal Doc" (:name serialized)))
(is (= {:type "doc"} (:document serialized)))
(is (nil? (:collection_id serialized)) "collection_id should be excluded even when nil")
(is (not (contains? serialized :id)))
(is (not (contains? serialized :creator_id))))))
(deftest revert-to-revision-document-test
(testing "Document reversion uses default implementation"
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Original Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
:creator_id (mt/user->id :crowberto)}]
(let [original-serialized (revision/serialize-instance :model/Document doc-id document)
_ (t2/update! :model/Document doc-id {:name "Updated Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}})
updated-document (t2/select-one :model/Document :id doc-id)]
(testing "document was updated"
(is (= "Updated Document" (:name updated-document)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}
(:document updated-document))))
(testing "reversion restores original state"
(revision/revert-to-revision! :model/Document doc-id (mt/user->id :crowberto) original-serialized)
(let [reverted-document (t2/select-one :model/Document :id doc-id)]
(is (= "Original Document" (:name reverted-document)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
(:document reverted-document)))
(testing "preserves metadata fields not in serialization"
(is (= doc-id (:id reverted-document)))
(is (= (mt/user->id :crowberto) (:creator_id reverted-document)))
(is (:created_at reverted-document))
(is (:updated_at reverted-document)))))))))
(deftest excluded-columns-test
(testing "excluded columns constant contains expected fields"
(let [excluded-columns @#'metabase-enterprise.documents.revisions.impl/excluded-columns-for-document-revision]
(is (contains? excluded-columns :id))
(is (contains? excluded-columns :creator_id))
(is (contains? excluded-columns :created_at))
(is (contains? excluded-columns :updated_at))
(is (contains? excluded-columns :collection_id))
(is (= 5 (count excluded-columns)) "Should exclude exactly 5 metadata fields"))))

View File

@@ -0,0 +1,270 @@
(ns metabase-enterprise.documents.revisions.integration-test
"Integration tests for Document revision history including API endpoints, events, and permissions."
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.documents.revisions.impl]
[metabase.config.core :as config]
[metabase.events.core :as events]
[metabase.revisions.models.revision :as revision]
[metabase.test :as mt]
[metabase.util :as u]
[toucan2.core :as t2]))
(def ^:private rasta-revision-info
(delay {:id (mt/user->id :rasta)
:first_name "Rasta"
:last_name "Toucan"
:common_name "Rasta Toucan"}))
(defn- create-document-revision!
"Helper function to create a Document revision manually for testing."
[document-id is-creation? user]
(revision/push-revision!
{:object (t2/select-one :model/Document :id document-id)
:entity :model/Document
:id document-id
:user-id (mt/user->id user)
:is-creation? is-creation?}))
(defn- get-document-revisions
"Helper function to get revisions for a Document via API."
[document-id]
(mt/user-http-request :rasta :get 200 (format "revision/document/%d" document-id)))
(deftest document-create-event-triggers-revision-test
(testing "Document creation triggers revision creation via events"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Test Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
:creator_id (mt/user->id :crowberto)}]
(events/publish-event! :event/document-create {:object document :user-id (mt/user->id :crowberto)})
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id)]
(is revision "Revision should be created")
(is (= "Document" (:model revision)))
(is (= doc-id (:model_id revision)))
(is (= (mt/user->id :crowberto) (:user_id revision)))
(is (:is_creation revision))
(is (not (:is_reversion revision)))
(testing "serialized object excludes metadata fields"
(let [object (:object revision)]
(is (= "Test Document" (:name object)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]} (:document object)))
(is (not (contains? object :id)))
(is (not (contains? object :creator_id)))
(is (not (contains? object :created_at)))
(is (not (contains? object :updated_at)))
(is (not (contains? object :collection_id))))))))))
(deftest document-update-event-triggers-revision-test
(testing "Document update triggers revision creation via events"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Original Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
:creator_id (mt/user->id :crowberto)}]
(let [updated-document (assoc document
:name "Updated Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]})]
(events/publish-event! :event/document-update {:object updated-document :user-id (mt/user->id :rasta)})
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id :user_id (mt/user->id :rasta))]
(is revision "Revision should be created for update")
(is (= "Document" (:model revision)))
(is (= doc-id (:model_id revision)))
(is (= (mt/user->id :rasta) (:user_id revision)))
(is (not (:is_creation revision)))
(is (not (:is_reversion revision)))
(testing "serialized object contains updated data"
(let [object (:object revision)]
(is (= "Updated Document" (:name object)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]} (:document object)))))))))))
(deftest document-revision-api-endpoints-test
(testing "Document revision API endpoints work correctly"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Test Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Initial content"}]}]}
:creator_id (mt/user->id :rasta)}]
(create-document-revision! doc-id true :rasta)
(testing "GET /api/revision/document/:id returns Document revisions"
(let [revisions (get-document-revisions doc-id)]
(is (= 1 (count revisions)))
(let [revision (first revisions)]
(is (:is_creation revision))
(is (not (:is_reversion revision)))
(is (= "created this." (:description revision)))
(is (=? @rasta-revision-info (:user revision)))
(is (= config/mb-version-string (:metabase_version revision))))))
(testing "Multiple revisions are returned in correct order"
(t2/update! :model/Document doc-id {:name "Updated Document"})
(create-document-revision! doc-id false :rasta)
(let [revisions (get-document-revisions doc-id)]
(is (= 2 (count revisions)))
(let [[latest-revision creation-revision] revisions]
(is (not (:is_creation latest-revision)))
(is (:is_creation creation-revision))
(is (= "created this." (:description creation-revision)))
(is (not= "created this." (:description latest-revision))))))))))
(deftest document-reversion-api-test
(testing "Document reversion via API works correctly"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Original Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
:creator_id (mt/user->id :rasta)}]
(create-document-revision! doc-id true :rasta)
(testing "Document can be updated and reverted"
(t2/update! :model/Document doc-id {:name "Updated Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}})
(create-document-revision! doc-id false :rasta)
(let [revisions (revision/revisions :model/Document doc-id)
[_ {original-revision-id :id}] revisions]
(testing "Revert via API endpoint"
(let [revert-response (mt/user-http-request :rasta :post "revision/revert"
{:entity :document
:id doc-id
:revision_id original-revision-id})]
(is (:is_reversion revert-response))
(is (= "reverted to an earlier version." (:description revert-response)))
(is (=? @rasta-revision-info (:user revert-response)))))
(testing "Document state is restored"
(let [reverted-document (t2/select-one :model/Document :id doc-id)]
(is (= "Original Document" (:name reverted-document)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
(:document reverted-document)))))
(testing "Reversion creates new revision entry"
(let [final-revisions (get-document-revisions doc-id)]
(is (= 3 (count final-revisions)))
(let [latest-revision (first final-revisions)]
(is (:is_reversion latest-revision))
(is (= "reverted to an earlier version." (:description latest-revision))))))))))))
(deftest document-revision-permission-checks-test
(testing "Document revision API respects permissions"
(mt/with-premium-features #{:documents}
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection collection {:name "Private collection"}
:model/Document document {:collection_id (u/the-id collection)
:name "Private document"
:document {:type "doc" :content []}
:creator_id (mt/user->id :crowberto)}]
(create-document-revision! (:id document) true :crowberto)
(t2/update! :model/Document (:id document) {:name "Updated Private Document"})
(create-document-revision! (:id document) false :crowberto)
(let [document-id (u/the-id document)
[_ {prev-rev-id :id}] (revision/revisions :model/Document document-id)]
(testing "User without permissions cannot access revisions"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :get 403 (format "revision/document/%d" document-id)))))
(testing "User without permissions cannot revert"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :post 403 "revision/revert"
{:entity :document
:id document-id
:revision_id prev-rev-id}))))))))))
(deftest document-revision-cross-boundary-integration-test
(testing "Document revisions integrate properly with OSS revision system"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Cross-boundary Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Enterprise content"}]}]}
:creator_id (mt/user->id :rasta)}]
(testing "Document revisions work with OSS revision/revisions function"
(create-document-revision! doc-id true :rasta)
(let [revisions (revision/revisions :model/Document doc-id)]
(is (seq revisions))
(is (= 1 (count revisions)))
(let [revision (first revisions)]
(is (= doc-id (:model_id revision)))
(is (= "Document" (:model revision))))))
(testing "Document revisions work with OSS revision API routes"
(let [revisions-response (mt/user-http-request :rasta :get (format "revision/document/%d" doc-id))]
(is (seq revisions-response))
(is (every? #(contains? % :is_creation) revisions-response))
(is (every? #(contains? % :description) revisions-response))))
(testing "Document reversion integrates with OSS revert endpoint"
(t2/update! :model/Document doc-id {:name "Modified Document"})
(create-document-revision! doc-id false :rasta)
(let [[_ {original-rev-id :id}] (revision/revisions :model/Document doc-id)
revert-response (mt/user-http-request :rasta :post "revision/revert"
{:entity :document
:id doc-id
:revision_id original-rev-id})]
(is (:is_reversion revert-response))
(let [reverted-doc (t2/select-one :model/Document :id doc-id)]
(is (= "Cross-boundary Document" (:name reverted-doc))))))))))
(deftest document-revision-event-handler-integration-test
(testing "Document revision events are published and handled correctly"
(mt/with-premium-features #{:documents}
(let [original-event events/publish-event!
events-received (atom [])]
(with-redefs [events/publish-event! (fn [topic event]
(swap! events-received conj [topic event])
(original-event topic event))]
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Event Test Document"
:document {:type "doc" :content []}
:creator_id (mt/user->id :rasta)}]
(testing "Create event triggers revision creation"
(events/publish-event! :event/document-create {:object document :user-id (mt/user->id :rasta)})
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id)]
(is revision)
(is (:is_creation revision))))
(testing "Update event triggers revision creation"
(let [updated-document (assoc document :name "Updated Event Document")]
(events/publish-event! :event/document-update {:object updated-document :user-id (mt/user->id :rasta)})
(let [revisions (t2/select :model/Revision :model "Document" :model_id doc-id {:order-by [[:id :desc]]})]
(is (= 2 (count revisions)))
(let [latest-revision (first revisions)]
(is (not (:is_creation latest-revision)))
(is (= "Updated Event Document" (get-in latest-revision [:object :name])))))))
(testing "Events were published correctly"
(is (>= (count @events-received) 2))
(is (some #(= :event/document-create (first %)) @events-received))
(is (some #(= :event/document-update (first %)) @events-received)))))))))
(deftest document-revision-diff-and-descriptions-test
(testing "Document revision diffs and descriptions work correctly"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Original Title"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
:creator_id (mt/user->id :rasta)}]
(create-document-revision! doc-id true :rasta)
(testing "Name change produces correct diff and description"
(t2/update! :model/Document doc-id {:name "Updated Title"})
(create-document-revision! doc-id false :rasta)
(let [revisions (get-document-revisions doc-id)
update-revision (first revisions)]
(is (= "Original Title" (get-in update-revision [:diff :before :name])))
(is (= "Updated Title" (get-in update-revision [:diff :after :name])))
(is (str/includes? (:description update-revision) "renamed"))))
(testing "Content change produces diff"
(t2/update! :model/Document doc-id {:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "New content"}]}]}})
(create-document-revision! doc-id false :rasta)
(let [revisions (get-document-revisions doc-id)
content-revision #p (first revisions)]
(is (= {:content [{:content [{:text "Original content"}]}]}
(get-in content-revision [:diff :before :document])))
(is (= {:content [{:content [{:text "New content"}]}]}
(get-in content-revision [:diff :after :document])))))))))

View File

@@ -0,0 +1,47 @@
(ns metabase-enterprise.documents.search-test
(:require
[clojure.test :refer :all]
[metabase.search.test-util :as search.tu]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util :as u]))
(defn- text->prose-mirror-ast
"Convert plain text to a ProseMirror AST structure."
[text]
(if (empty? text)
{:type "doc" :content []}
{:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text text}]}]}))
(use-fixtures :once (fixtures/initialize :db :test-users))
(defn card-with-name-and-query
([]
(card-with-name-and-query (mt/random-name)))
([card-name]
{:name card-name
:display "scalar"
:dataset_query (mt/mbql-query venues)
:visualization_settings {:global {:title nil}}}))
(deftest card-search-exclusion-test
(testing "Cards with document_id are excluded from search indexing"
(let [card-name (mt/random-name)
regular-card-name (str card-name "-regular")]
(search.tu/with-temp-index-table
(mt/with-temp [:model/Document document {:name "Test Document"
:document (text->prose-mirror-ast "")}
:model/Card document-card (-> (card-with-name-and-query card-name)
(assoc :document_id (u/the-id document)))
:model/Card regular-card (card-with-name-and-query regular-card-name)]
(testing "Search API excludes document cards"
(let [results (mt/user-http-request :crowberto :get 200 "search" {:q card-name :models "question"})]
(is (not (some #(= (:id %) (u/the-id document-card)) (:data results)))
"Document card should not appear in search results"))
(let [regular-results (mt/user-http-request :crowberto :get 200 "search" {:q regular-card-name :models "question"})]
(is (some #(= (:id %) (u/the-id regular-card)) (:data regular-results))
"Regular card should appear in search results"))))))))

View File

@@ -0,0 +1,259 @@
(ns ^:mb/driver-tests metabase-enterprise.transforms.api-test
"Tests for /api/transform endpoints."
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.transforms.test-util :refer [with-transform-cleanup!]]
[metabase-enterprise.transforms.util :as transforms.util]
[metabase.driver :as driver]
[metabase.query-processor.compile :as qp.compile]
[metabase.test :as mt]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn- make-query [category]
(let [q (if (= :clickhouse driver/*driver*)
(mt/mbql-query products {:filter [:= $category category]
:expressions {"clickhouse_merge_table_id" $id}})
(mt/mbql-query products {:filter [:= $category category]}))
sql (:query (qp.compile/compile q))]
;; inline the parameter
(str/replace-first sql "?" (str \' category \'))))
(comment
(binding [driver/*driver* :clickhouse]
(make-query "Gadget"))
-)
(deftest create-transform-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(with-transform-cleanup! [table-name "gadget_products"]
(mt/user-http-request :crowberto :post 200 "ee/transform"
{:name "Gadget Products"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query (make-query "Gadget")
:template-tags {}}}}
;; for clickhouse (and other dbs where we do merge into), we will
;; want a primary key
;;:primary-key-column "id"
:target {:type "table"
;; leave out schema for now
;;:schema (str (rand-int 10000))
:name table-name}})))))
(deftest list-transforms-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(testing "Can list without query parameters"
(mt/with-premium-features #{:transforms}
(mt/user-http-request :crowberto :get 200 "ee/transform")))
(testing "Can list with query parameters"
(mt/with-premium-features #{:transforms}
(with-transform-cleanup! [table-name "gadget_products"]
(let [body {:name "Gadget Products"
:description "Desc"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query (make-query "Gadget")
:template-tags {}}}}
:target {:type "table"
;;:schema "transforms"
:name table-name}}
_ (mt/user-http-request :crowberto :post 200 "ee/transform" body)
list-resp (mt/user-http-request :crowberto :get 200 "ee/transform")]
(is (seq list-resp))))))))
(deftest get-transforms-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(with-transform-cleanup! [table-name "gadget_products"]
(let [body {:name "Gadget Products"
:description "Desc"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query (make-query "Gadget")
:template-tags {}}}}
:target {:type "table"
;;:schema "transforms"
:name table-name}}
resp (mt/user-http-request :crowberto :post 200 "ee/transform" body)]
(is (=? (assoc body
:execution_trigger "none"
:last_execution nil)
(mt/user-http-request :crowberto :get 200 (format "ee/transform/%s" (:id resp))))))))))
(deftest put-transforms-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(with-transform-cleanup! [table-name "gadget_products"]
(let [query2 (make-query "None")
resp (mt/user-http-request :crowberto :post 200 "ee/transform"
{:name "Gadget Products"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query (make-query "Gadget")
:template-tags {}}}}
:target {:type "table"
;;:schema "transforms"
:name table-name}})]
(is (=? {:name "Gadget Products 2"
:description "Desc"
:source {:type "query"
:query {:database (mt/id)
:type "native",
:native {:query query2
:template-tags {}}}}
:target {:type "table"
:name table-name}
:execution_trigger "global-schedule"}
(mt/user-http-request :crowberto :put 200 (format "ee/transform/%s" (:id resp))
{:name "Gadget Products 2"
:description "Desc"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query query2
:template-tags {}}}}
:execution_trigger "global-schedule"}))))))))
(deftest change-target-table-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(with-transform-cleanup! [table1-name "dookey_products"
table2-name "doohickey_products"]
(let [query2 (make-query "Doohickey")
original {:name "Gadget Products"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query (make-query "Gadget")
:template-tags {}}}}
:target {:type "table"
;;:schema "transforms"
:name table1-name}}
resp (mt/user-http-request :crowberto :post 200 "ee/transform"
original)
updated {:name "Doohickey Products"
:description "Desc"
:source {:type "query"
:query {:database (mt/id)
:type "native",
:native {:query query2
:template-tags {}}}}
:target {:type "table"
:name table2-name}}]
(is (=? updated
(mt/user-http-request :crowberto :put 200 (format "ee/transform/%s" (:id resp)) updated)))
(is (false? (transforms.util/target-table-exists? original))))))))
(deftest delete-transforms-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(with-transform-cleanup! [table-name "gadget_products"]
(let [resp (mt/user-http-request :crowberto :post 200 "ee/transform"
{:name "Gadget Products"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query (make-query "Gadget")
:template-tags {}}}}
:target {:type "table"
;;:schema "transforms"
:name table-name}})]
(mt/user-http-request :crowberto :delete 204 (format "ee/transform/%s" (:id resp)))
(mt/user-http-request :crowberto :get 404 (format "ee/transform/%s" (:id resp))))))))
(deftest delete-table-transforms-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(with-transform-cleanup! [table-name "gadget_products"]
(let [resp (mt/user-http-request :crowberto :post 200 "ee/transform"
{:name "Gadget Products"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query (make-query "Gadget")
:template-tags {}}}}
:target {:type "table"
;;:schema "transforms"
:name table-name}})]
(mt/user-http-request :crowberto :delete 204 (format "ee/transform/%s/table" (:id resp))))))))
(defn- test-execution
[transform-id]
(let [resp (mt/user-http-request :crowberto :post 202 (format "ee/transform/%s/execute" transform-id))
timeout-s 10 ; 10 seconds is our timeout to finish execution and sync
limit (+ (System/currentTimeMillis) (* timeout-s 1000))]
(is (=? {:message "Transform execution started"}
resp))
(loop []
(when (> (System/currentTimeMillis) limit)
(throw (ex-info (str "Transfer execution timed out after " timeout-s " seconds") {})))
(let [resp (mt/user-http-request :crowberto :get 200 (format "ee/transform/%s" transform-id))
status (some-> resp :last_execution :status keyword)]
(when-not (contains? #{:started :succeeded} status)
(throw (ex-info (str "Transfer execution failed with status " status) {:resp resp})))
(when-not (some? (:table resp))
(Thread/sleep 100)
(recur))))))
(defn- check-query-results
[table-name ids category]
(let [table-id (t2/select-one-fn :id :model/Table :db_id (mt/id) :name table-name)
id-field-id (t2/select-one-fn :id :model/Field :table_id table-id :position 0)
category-field-id (t2/select-one-fn :id :model/Field :table_id table-id :position 3)]
(is (= (mapv vector ids (repeat category))
(mt/rows
(mt/run-mbql-query nil
{:source-table table-id
:fields [[:field id-field-id nil] [:field category-field-id nil]]
:order-by [[[:field id-field-id nil] :asc]]
:limit 3}))))))
(deftest execute-test
(doseq [target-type ["table" "view"]
:let [feature (keyword "transforms" target-type)]]
(testing (str "transform execution with " target-type " target")
(mt/test-drivers (mt/normal-drivers-with-feature feature)
(mt/with-premium-features #{:transforms}
(let [schema (t2/select-one-fn :schema :model/Table (mt/id :products))]
(with-transform-cleanup! [{table1-name :name :as target1} {:type target-type
:schema schema
:name "gadget_products"}
{table2-name :name :as target2} {:type target-type
:schema schema
:name "doohickey_products"}]
(let [query2 (make-query "Doohickey")
original {:name "Gadget Products"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query (make-query "Gadget")
:template-tags {}}}}
:target target1}
{transform-id :id} (mt/user-http-request :crowberto :post 200 "ee/transform"
original)
_ (test-execution transform-id)
_ (is (true? (transforms.util/target-table-exists? original)))
_ (check-query-results table1-name [5 11 16] "Gadget")
updated {:name "Doohickey Products"
:description "Desc"
:source {:type "query"
:query {:database (mt/id)
:type "native",
:native {:query query2
:template-tags {}}}}
:target target2}]
(is (=? (assoc updated
:execution_trigger "none")
(mt/user-http-request :crowberto :put 200 (format "ee/transform/%s" transform-id) updated)))
(test-execution transform-id)
(is (true? (transforms.util/target-table-exists? original)))
(is (true? (transforms.util/target-table-exists? updated)))
(check-query-results table2-name [2 3 4] "Doohickey")))))))))

View File

@@ -0,0 +1,72 @@
(ns metabase-enterprise.transforms.execute-test
(:require
[clojure.test :refer :all]
[medley.core :as m]
[metabase-enterprise.transforms.execute :as transforms.execute]
[metabase-enterprise.transforms.test-util :refer [with-transform-cleanup!]]
[metabase.lib-be.metadata.jvm :as lib.metadata.jvm]
[metabase.lib.convert :as lib.convert]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.query-processor :as qp]
[metabase.test :as mt]
[toucan2.core :as t2]
[toucan2.util :as u]))
(set! *warn-on-reflection* true)
(defn- make-query
([source-table]
(make-query source-table nil nil))
([source-table source-column constraint-fn & constraint-params]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
table (if (string? source-table)
(m/find-first (comp #{source-table} u/lower-case-en :name) (lib.metadata/tables mp))
source-table)
query (lib/query mp table)
column (when source-column
(m/find-first (comp #{source-column} u/lower-case-en :name)
(lib/visible-columns query)))]
(cond-> query
(and source-column constraint-fn)
(lib/filter (apply constraint-fn column constraint-params))))))
(defn- wait-for-table
[table-name timeout-ms]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
limit (+ (System/currentTimeMillis) timeout-ms)]
(loop []
(Thread/sleep 200)
(when (> (System/currentTimeMillis) limit)
(throw (ex-info "table has not been created" {:table-name table-name, :timeout-ms timeout-ms})))
(or (m/find-first (comp #{table-name} :name) (lib.metadata/tables mp))
(recur)))))
(deftest execute-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(let [target-type "table"
schema (t2/select-one-fn :schema :model/Table (mt/id :products))]
(with-transform-cleanup! [{table1-name :name :as target1} {:type target-type
:schema schema
:name "g_products"}
{table2-name :name :as target2} {:type target-type
:schema schema
:name "gizmo_products"}]
(let [t1-query (make-query "products" "category" lib/starts-with "G")]
(mt/with-temp [:model/Transform t1 {:name "transform1"
:source {:type :query
:query (lib.convert/->legacy-MBQL t1-query)}
:target target1}]
(transforms.execute/execute-mbql-transform! t1 {:run-method :manual})
(let [table1 (wait-for-table table1-name 10000)
t2-query (make-query table1 "category" lib/= "Gizmo")]
(mt/with-temp [:model/Transform t2 {:name "transform2"
:source {:type :query
:query (lib.convert/->legacy-MBQL t2-query)}
:target target2}]
(transforms.execute/execute-transforms! [t1 t2] {:run-method :cron})
(let [table2 (wait-for-table table2-name 10000)
check-query (lib/aggregate (make-query table2) (lib/count))]
(is (=? {:data {:cols [{:name "count"}]
:rows [[51]]}}
(qp/process-query check-query))))))))))))

View File

@@ -0,0 +1,107 @@
(ns metabase-enterprise.transforms.ordering-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.transforms.ordering :as ordering]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- make-transform [{:keys [query]}]
{:source {:type "query",
:query query}
:name "transform1"
:target {:schema "public"
:name "orders_2"
:type "table"}})
(deftest basic-ordering-test
(mt/with-temp [:model/Transform {t1 :id} (make-transform
{:query {:database (mt/id),
:type "query",
:query {:source-table (mt/id :orders)}}})]
(is (= {t1 #{}}
(ordering/transform-ordering [(t2/select-one :model/Transform :id t1)])))))
(deftest basic-dependencies-test
(mt/with-temp [:model/Transform {t1 :id} (make-transform
{:query {:database (mt/id),
:type "query",
:query {:source-table (mt/id :orders)}}})]
(is (= #{(mt/id :orders)}
(#'ordering/transform-deps (t2/select-one :model/Transform :id t1))))))
(deftest joined-dependencies-test
(mt/with-temp [:model/Transform {t1 :id} (make-transform
{:query {:database (mt/id),
:type "query",
:query {:source-table (mt/id :orders),
:joins
[{:fields "all",
:strategy "left-join",
:alias "Products",
:condition
[:=
[:field
(mt/id :orders :product_id)
{:base-type "type/Integer"}]
[:field
(mt/id :products :id)
{:base-type "type/Integer",
:join-alias "Products"}]],
:source-table (mt/id :products)}]},
:parameters []}})]
(is (= #{(mt/id :orders)
(mt/id :products)}
(into #{}
(#'ordering/transform-deps (t2/select-one :model/Transform :id t1)))))))
(deftest card-dependencies-test
(mt/with-temp [:model/Card {card :id} {:dataset_query {:database (mt/id),
:type "query",
:query {:source-table (mt/id :orders),
:joins
[{:fields "all",
:strategy "left-join",
:alias "Products",
:condition
[:=
[:field
(mt/id :orders :product_id)
{:base-type "type/Integer"}]
[:field
(mt/id :products :id)
{:base-type "type/Integer",
:join-alias "Products"}]],
:source-table (mt/id :products)}]},
:parameters []}}
:model/Transform {t1 :id} (make-transform
{:query {:database (mt/id),
:type "query",
:query
{:aggregation [["count"]],
:breakout [["field" "Products__category" {:base-type "type/Text"}]],
:source-table (str "card__" card)},
:parameters []}})]
(is (= #{(mt/id :orders)
(mt/id :products)}
(into #{}
(#'ordering/transform-deps (t2/select-one :model/Transform :id t1)))))))
(defn- rotations
[v]
(let [n (count v)
elements (cycle v)]
(into #{} (map #(take n (drop % elements)))
(range n))))
(deftest find-cycle-test
(let [test-graph
{1 #{2}
2 #{3 5}
3 #{4}
4 #{6}
5 #{6}
6 #{7}
7 #{3}}
cycles (rotations [3 4 6 7])]
(is (contains? cycles
(ordering/find-cycle test-graph)))))

View File

@@ -0,0 +1,44 @@
(ns metabase-enterprise.transforms.test-util
(:require
[clojure.string :as str]
[metabase.api.common :as api]
[metabase.driver :as driver]
[metabase.test :as mt]
[metabase.util :as u]))
(defn drop-target!
"Drop transform target `target`. `target` can be a string or a map. If `target` is a string,
type :table is assumed."
[target]
(let [target (if (map? target)
target
;; assume this is just a plain table name
{:type :table, :name target})
driver driver/*driver*]
(binding [api/*is-superuser?* true
api/*current-user-id* (mt/user->id :crowberto)]
(-> (driver/drop-transform-target! driver (mt/db) target)
u/ignore-exceptions))))
(defn gen-table-name
"Generate a random table name with prefix `table-name-prefix`."
[table-name-prefix]
(if (map? table-name-prefix)
;; table-name-prefix is a whole target, randomize the name
(update table-name-prefix :name gen-table-name)
(str table-name-prefix \_ (str/replace (str (random-uuid)) \- \_))))
(defmacro with-transform-cleanup!
"Execute `body`, then delete any new :model/Transform instances and drop tables generated from `table-gens`."
[table-gens & body]
(assert (seqable? table-gens) "need a seqable? as table-gens")
(assert (even? (count table-gens)) "need an even number of forms in table-gens")
(if-let [[sym prefix & more-gens] (seq table-gens)]
`(let [target# (gen-table-name ~prefix)
~sym target#]
(try
(with-transform-cleanup! ~more-gens ~@body)
(finally
(drop-target! target#))))
`(mt/with-model-cleanup [:model/Transform :model/Table]
~@body)))

View File

@@ -0,0 +1,547 @@
(ns metabase-enterprise.workspaces.api.workspace-test
"Tests for workspace API endpoints"
(:require
[clojure.test :refer :all]
[malli.core :as mc]
[malli.util :as mut]
[metabase-enterprise.workspaces.models.workspace :as m.workspace]
[metabase.test :as mt]
[metabase.util.malli.registry :as mr]
[toucan2.core :as t2]))
(deftest workspace-validation-test
(testing "Workspace validation with updated Malli schema structure"
(let [valid-workspace {:name "Test"
:description "Test workspace"
:created_at "2023-08-06T15:00:00Z"
:updated_at "2023-08-06T15:00:00Z"
:plans []
:transforms []
:activity_logs []
:permissions []
:users []
:data_warehouses []
:documents []}]
(is (nil? (mc/explain ::m.workspace/workspace valid-workspace))))))
(deftest create-workspace-test
(testing "Creating a workspace with valid data using mt/with-temp"
(mt/with-temp [:model/Collection {collection-id :id} {:name "API Workspace Collection"}
:model/Workspace {workspace-id :id :as wksp} {:name "Test Workspace"
:description "A test workspace"
:collection_id collection-id}]
(is (pos? workspace-id))
(is (= "Test Workspace"
(:name (t2/select-one :model/Workspace :id workspace-id))))
(is (= (mut/keys (mr/resolve-schema ::m.workspace/workspace))
(keys (m.workspace/sort-workspace wksp)))))))
;;; API Endpoint Tests
(defn- test-workspace-data
"Generate test workspace data with a valid collection."
([]
(test-workspace-data {}))
([overrides]
(merge {:name "Test Workspace"
:description "A test workspace"
:collection_id 1 ; Will be replaced with actual collection ID in tests
:users []
:plans []
:transforms []
:documents []
:data_warehouses []
:permissions []
:activity_logs []}
overrides)))
(defn- create-test-collection
"Create a test collection for workspace tests."
[]
(mt/user-http-request :crowberto :post 200 "collection/"
{:name "Test Workspace Collection"
:slug "test_workspace_collection"}))
(deftest api-create-workspace-validation-test
(testing "POST /api/ee/workspace/ - validation tests"
(mt/with-model-cleanup [:model/Workspace]
(let [collection (create-test-collection)]
(testing "should require name"
(mt/user-http-request :crowberto :post 400 "ee/workspace/"
{:description "Missing name"
:collection_id (:id collection)}))
(testing "should require collection_id"
(mt/user-http-request :crowberto :post 400 "ee/workspace/"
{:name "Missing collection"
:description "Test"}))
(testing "should reject blank name"
(mt/user-http-request :crowberto :post 400 "ee/workspace/"
{:name ""
:collection_id (:id collection)
:description "Blank name"}))))))
(deftest api-get-workspaces-test
(testing "GET /api/ee/workspace/ - list workspaces"
(mt/with-temp [:model/Collection {collection-id :id} {:name "API Workspace Collection"}
:model/Workspace _ {:name "API Workspace 1"
:collection_id collection-id}
:model/Workspace _ {:name "API Workspace 2"
:collection_id collection-id}]
(let [result (mt/user-http-request :crowberto :get 200 "ee/workspace/")]
(is (>= (count result) 2))
(is (some #(= "API Workspace 1" (:name %)) result))
(is (some #(= "API Workspace 2" (:name %)) result))))))
(deftest api-get-workspace-by-id-test
(testing "GET /api/ee/workspace/:id - get specific workspace"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Test Workspace Collection"}
:model/Workspace {workspace-id :id :as test-workspace} {:name "Test Workspace"
:collection_id collection-id}]
(def tw test-workspace)
(testing "should get existing workspace"
(let [result (mt/user-http-request :crowberto :get 200 (format "ee/workspace/%s" workspace-id))]
(is (= workspace-id (:id result)))))
(testing "should return 404 for non-existent workspace"
(mt/user-http-request :crowberto :get 404 "ee/workspace/99999")))))
(deftest api-update-workspace-test
(testing "PUT /api/ee/workspace/:id - update workspace"
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
(testing "should update workspace successfully"
(let [updated-data {:name "Updated Workspace"
:description "Updated description"}
result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s" workspace-id)
updated-data)]
(is (partial= updated-data result))
(is (= workspace-id (:id result)))
;; Verify in database
(let [db-workspace (t2/select-one :model/Workspace :id workspace-id)]
(is (partial= updated-data db-workspace)))))
(testing "should return 404 for non-existent workspace"
(mt/user-http-request :crowberto :put 404 "ee/workspace/99999"
{:name "Non-existent"})))))
(deftest api-delete-workspace-test
(testing "DELETE /api/ee/workspace/:id - delete workspace"
(mt/with-model-cleanup [:model/Workspace]
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
(testing "should delete workspace successfully"
(mt/user-http-request :crowberto :delete 204
(format "ee/workspace/%s" workspace-id))
;; Verify workspace is deleted
(is (nil? (t2/select-one :model/Workspace :id workspace-id))))
(testing "should return 404 for non-existent workspace"
(mt/user-http-request :crowberto :delete 404 "ee/workspace/99999"))))))
;;; Workspace Plans API Tests
(deftest api-add-plan-to-workspace-test
(testing "PUT /api/ee/workspace/:id/plan - add plan to workspace"
(mt/with-model-cleanup [:model/Workspace]
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
(testing "should add plan successfully"
(let [plan-data {:title "Test Plan"
:description "Test plan description"
:content {:steps ["Step 1" "Step 2" "Step 3"]
:timeline "Q1 2025"}}
result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/plan" workspace-id)
plan-data)]
(is (= workspace-id (:id result)))
(is (= 1 (count (:plans result))))
(let [added-plan (first (:plans result))]
(is (partial= plan-data added-plan))
(is (contains? added-plan :created_at)))))
(testing "should add multiple plans"
(let [second-plan {:title "Second Plan"
:description "Second plan description"
:content {:steps ["Another step"]}}]
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/plan" workspace-id)
second-plan)
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)]
(is (= 2 (count (:plans updated-workspace)))))))
(testing "should validate required fields"
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/plan" workspace-id)
{:description "Missing title"
:content {}}))
(testing "should return 404 for non-existent workspace"
(mt/user-http-request :crowberto :put 404 "ee/workspace/99999/plan"
{:title "Plan" :description "Desc" :content {}}))))))
;;; Workspace Transforms API Tests
(deftest api-add-transform-to-workspace-test
(testing "PUT /api/ee/workspace/:id/transform - add transform to workspace"
(mt/with-model-cleanup [:model/Workspace]
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
(testing "should add transform successfully"
(let [transform-data {:name "ETL Transform"
:description "Transform raw data"
:source {:type "database" :connection "source-db"}
:target {:type "warehouse" :connection "target-db"}
:config {:schedule "daily"}}
result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/transform" workspace-id)
transform-data)]
(is (= workspace-id (:id result)))
(is (= 1 (count (:transforms result))))
(let [added-transform (first (:transforms result))]
(is (partial= transform-data added-transform))
(is (contains? added-transform :created_at)))))
(testing "should validate required fields"
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/transform" workspace-id)
{:description "Missing name and source/target"})
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/transform" workspace-id)
{:name "Transform"
:description "Missing source"
:target {}}))))))
(deftest api-add-user-to-workspace-test
(testing "PUT /api/ee/workspace/:id/user - add user to workspace"
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
(testing "should add user successfully"
(let [user-data {:id 123
:name "John Doe"
:email "john@example.com"
:type "workspace-user"}
result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/user" workspace-id)
user-data)]
result
(def result result)
(is (= workspace-id (:id result)))
(is (= 1 (count (:users result))))
(let [added-user (first (:users result))]
(is (partial= user-data added-user))
(is (contains? added-user :created_at)))))
(testing "should validate required fields"
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/user" workspace-id)
{:name "Missing user_id and email"
:type "user"})))))
(deftest api-add-document-to-workspace-test
(testing "PUT /api/ee/workspace/:id/document - add document to workspace"
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
(testing "should add document successfully"
(let [document-data {:document_id 456}
result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/document" workspace-id)
document-data)]
(is (= workspace-id (:id result)))
(is (= [456] (:documents result)))))
(testing "should add multiple documents"
(let [second-document {:document_id 789}]
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/document" workspace-id)
second-document)
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)]
(is (= #{456 789} (set (:documents updated-workspace)))))))
(testing "should not duplicate document IDs"
(let [duplicate-document {:document_id 456}]
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/document" workspace-id)
duplicate-document)
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)]
(is (= [456 789] (:documents updated-workspace))))))
(testing "should validate required fields"
(is (= {:specific-errors {:document_id ["missing required key, received: nil"]},
:errors {:document_id "value must be an integer greater than zero."}}
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/document" workspace-id)
{:missing "document_id"})))))))
;;; Workspace Data Warehouses API Tests
(deftest api-add-data-warehouse-to-workspace-test
(testing "PUT /api/ee/workspace/:id/data_warehouse - add data warehouse to workspace"
(mt/with-model-cleanup [:model/Workspace]
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
(testing "should add data warehouse successfully"
(let [dwh-data {:data_warehouses_id 101
:name "Production DWH"
:type "read-only"
:credentials {:host "dwh.company.com"
:port 5432
:username "analyst"}}
result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/data_warehouse" workspace-id)
dwh-data)]
(is (= workspace-id (:id result)))
(is (= 1 (count (:data_warehouses result))))
(let [added-dwh (first (:data_warehouses result))]
(is (= 101 (:id added-dwh)))
(is (= "Production DWH" (:name added-dwh)))
(is (= "read-only" (:type added-dwh)))
(is (= (:credentials dwh-data) (:credentials added-dwh)))
(is (contains? added-dwh :created_at)))))
(testing "should add multiple data warehouses"
(let [second-dwh {:data_warehouses_id 102
:name "Analytics DWH"
:type "read-write"
:credentials {:host "analytics.company.com"}}]
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/data_warehouse" workspace-id)
second-dwh)
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)]
(is (= 2 (count (:data_warehouses updated-workspace)))))))
(testing "should validate required fields"
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/data_warehouse" workspace-id)
{:name "Missing required fields"})
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/data_warehouse" workspace-id)
{:data_warehouses_id 103
:name "Invalid type"
:type "invalid-type"
:credentials {}}))
(testing "should validate type enum"
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/data_warehouse" workspace-id)
{:data_warehouses_id 104
:name "Bad Type"
:type "write-only"
:credentials {}}))))))
;;; Workspace Permissions API Tests
(deftest api-add-permission-to-workspace-test
(testing "PUT /api/ee/workspace/:id/permission - add permission to workspace"
(mt/with-model-cleanup [:model/Workspace]
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
(testing "should add permission successfully"
(let [permission-data {:table "customers"
:permission "read"}
result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/permission" workspace-id)
permission-data)]
(is (= workspace-id (:id result)))
(is (= 1 (count (:permissions result))))
(let [added-permission (first (:permissions result))]
(is (= "customers" (:table added-permission)))
(is (= "read" (:permission added-permission)))
(is (contains? added-permission :created_at)))))
(testing "should add multiple permissions"
(let [write-permission {:table "orders" :permission "write"}]
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/permission" workspace-id)
write-permission)
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)
perm-set (set (mapv #(dissoc % :created_at) (:permissions updated-workspace)))]
(def perm-set perm-set)
(is (= 2 (count perm-set)))
(is (contains? perm-set
{:table "orders" :permission "write"})))))
(testing "should validate required fields"
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/permission" workspace-id)
{:table "products"}))
(testing "should validate permission enum"
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/permission" workspace-id)
{:table "products"
:permission "execute"}))))))
;;; Authentication Tests
(deftest api-workspace-authentication-test
(testing "Workspace API endpoints require authentication"
(let [endpoints [["GET" "ee/workspace/"]
["GET" "ee/workspace/1"]
["POST" "ee/workspace/"]
["PUT" "ee/workspace/1"]
["DELETE" "ee/workspace/1"]
["PUT" "ee/workspace/1/plan"]
["PUT" "ee/workspace/1/transform"]
["PUT" "ee/workspace/1/user"]
["PUT" "ee/workspace/1/document"]
["PUT" "ee/workspace/1/dwh"]
["PUT" "ee/workspace/1/permission"]]]
(doseq [[method endpoint] endpoints]
(testing (format "%s /api/%s should require authentication" method endpoint)
(case method
"GET" (mt/client :get 401 endpoint)
"POST" (mt/client :post 401 endpoint {})
"PUT" (mt/client :put 401 endpoint {})
"DELETE" (mt/client :delete 401 endpoint)))))))
;;; Integration Tests
(deftest api-workspace-full-lifecycle-test
(testing "Complete workspace lifecycle integration test via API"
(mt/with-temp [:model/Collection {col-id :id} {}
:model/Workspace
{workspace-id :id :as workspace}
{:collection_id col-id :name "Test Workspace"}]
(testing "Workspace creation"
(is (pos? workspace-id))
(is (= "Test Workspace" (:name workspace))))
(testing "Add plan to workspace"
(let [plan-result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/plan" workspace-id)
{:title "Strategic Plan"
:description "2025 strategic initiatives"
:content {:goals ["Increase efficiency" "Reduce costs"]}})]
(is (= 1 (count (:plans plan-result))))))
(testing "Add transform to workspace"
(let [transform-result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/transform" workspace-id)
{:name "Daily ETL"
:description "Daily data processing"
:source {:type "oltp"}
:target {:type "olap"}})]
(is (= 1 (count (:transforms transform-result))))))
(testing "Add user to workspace"
(let [user-result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/user" workspace-id)
{:id 100
:name "Alice Smith"
:email "alice@company.com"
:type "analyst"})]
(is (= 1 (count (:users user-result))))))
(testing "Add document to workspace"
(let [document-result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/document" workspace-id)
{:document_id 999})]
(is (= [999] (:documents document-result)))))
(testing "Add data warehouse to workspace"
(let [dwh-result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/data_warehouse" workspace-id)
{:data_warehouses_id 200
:name "Test DWH"
:type "read-only"
:credentials {:host "test.db"}})]
(is (= 1 (count (:data_warehouses dwh-result))))))
(testing "Add permission to workspace"
(let [permission-result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/permission" workspace-id)
{:table "test_table"
:permission "read"})]
(is (= 1 (count (:permissions permission-result))))))
(testing "Update workspace"
(let [update-result (mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s" workspace-id)
{:name "Updated Strategic Workspace"
:description "Updated workspace description"})]
(is (= "Updated Strategic Workspace" (:name update-result)))))
(testing "Get workspace with all components"
(let [final-workspace (mt/user-http-request :crowberto :get 200
(format "ee/workspace/%s" workspace-id))]
(is (= "Updated Strategic Workspace" (:name final-workspace)))
(is (= 1 (count (:plans final-workspace))))
(is (= 1 (count (:transforms final-workspace))))
;;(is (= 1 (count (:users final-workspace))))
(is (= [999] (:documents final-workspace)))
(is (= 1 (count (:data_warehouses final-workspace))))
(is (= 1 (count (:permissions final-workspace))))))
(testing "Delete workspace"
(mt/user-http-request :crowberto :delete 204
(format "ee/workspace/%s" workspace-id))
(is (nil? (t2/select-one :model/Workspace :id workspace-id)))))))
(comment
;; repl stuff
(do
(let [id (atom 1000)]
(defn next-id [] (swap! id inc)))
(def c (t2/insert-returning-instance! :model/Collection {:name "Test Workspace Collection"}))
(try (def w (t2/insert-returning-instance! :model/Workspace {:name "test workspace"
:collection_id (:id c)
:data_warehouses []
:users []
:plans []
:activity_logs []
:transforms []
:documents []}))
(catch Exception e (ex-data e))))
[w c]
(mt/user-http-request :crowberto :get 200
(format "ee/workspace/%s" (:id w)))
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/user" (:id w))
{:id (next-id) :name "name" :email "email" :type "type"})
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/user" (:id w))
{:id 100 :name "Alice Smith" :email "alice@company.com" :type "analyst"})
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/user" (:id w))
{:id 100 :name "Alice Smith" :email "alice@company.com" :type "analyst"})
;; could that somehow get back:
[{:operation :select :table "workspace" :query [:= :id (:id w)]}
{:operation :insert
:table "workspace"
:row {:id 100
:name "Alice Smith"
:email "alice@company.com"
:type "analyst"}}]
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/plan" (:id w))
{:title "x" :content {}})
(mt/user-http-request :crowberto :put 200
(format "ee/workspace/%s/plan/0" (:id w))
{:title (str "xx" (rand)) :content {}})
(mt/user-http-request :crowberto :put 400
(format "ee/workspace/%s/plan" (:id w))
{:title "X" :content {}})
[(mt/user-http-request :crowberto :put 200 (format "ee/workspace/%s/document" (:id w))
{:document_id (next-id)})
(t2/select-one :model/Workspace :id (:id w))]
(t2/select-one :model/Workspace :id (:id w)))

View File

@@ -0,0 +1,235 @@
(ns ^:mb/driver-tests metabase-enterprise.workspaces.isolation-manager-test
"Tests for metabase-enterprise.workspaces.isolation-manager."
(:require
[clojure.java.jdbc :as jdbc]
[clojure.string :as str]
[clojure.test :refer :all]
[mb.hawk.assert-exprs.approximately-equal :as =?]
[metabase-enterprise.workspaces.isolation-manager :as isolation-manager]
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.query-processor.compile :as qp.compile]
[metabase.test :as mt]
[metabase.driver :as driver]))
(deftest create-isolation-test
(mt/test-drivers #{:postgres :clickhouse}
(let [db (mt/db)
result (isolation-manager/create-isolation (:engine db) (:details db) "test-workspace-123")]
(try
(is (=? {:isolation-type (=?/malli [:enum :database :schema])
:populator {:user (=?/malli :string) :password (=?/malli :string)}}
result))
(if (= :database (:isolation-type result))
(is (not (str/blank? (:database-name result))))
(is (not (str/blank? (:schema-name result)))))
(if (= :schema (:isolation-type result))
(is (str/starts-with? (:schema-name result) "mb__isolation_"))
(is (str/starts-with? (:database-name result) "mb__isolation_")))
(is (str/includes? (-> result :populator :user) "populator"))
(finally
(isolation-manager/delete-isolation (:engine db) (:details db) "test-workspace-123" result))))))
(def driver-creates
{:postgres "create table %s.%s (id int, stuff text)"
:clickhouse "CREATE TABLE %s.%s (id Int32, stuff String) ENGINE = MergeTree() ORDER BY id"
:snowflake "CREATE TABLE \"%s\".\"%s\" (id INT, stuff TEXT)"})
(def driver-drops
{:postgres "DROP table %s.%s"
:clickhouse "DROP table %s.%s"
:snowflake "DROP table \"%s\".\"%s\" "})
(deftest e2e
(mt/test-drivers #{:postgres :clickhouse :snowflake}
(let [db (mt/db)
driver (:engine db)
details (merge (:details db))
workspace-id (str (gensym "testing-workspace"))
isolation-info (isolation-manager/create-isolation (:engine db)
details
workspace-id)]
(letfn [(run-query! [user-info read|write sql]
;; user-info from snowflake is nil intentionally. that does not create a new user
(let [spec (sql-jdbc.conn/connection-details->spec (:engine db)
(merge details user-info))]
(try {:success true
:results (with-open [conn (jdbc/get-connection spec)]
(driver/set-database-used! driver conn db)
(if (= read|write :read)
(jdbc/query {:connection conn} sql)
(jdbc/execute! {:connection conn} sql)))}
(catch Exception e
{:error true
:message (ex-message e)
#_#_:e e
:query sql :user-info user-info}))))]
(try
(let [{:keys [query]} (qp.compile/compile (mt/mbql-query venues {:limit 10}))
{:keys [populator isolation-type]} isolation-info
isolation (isolation-info (if (= :schema isolation-type) :schema-name :database-name))
temp-table (str (gensym "some_table"))]
(testing "populator can query original data"
(is (=? {:success true} (run-query! populator :read query))))
(testing "populator can populate new schema"
(is (=? {:success true} (run-query! populator :write
(format (driver-creates driver) isolation temp-table)))))
(testing "populator can delete table in new schema"
(is (=? {:success true} (run-query! populator :write (format (driver-drops driver) isolation temp-table)))))
(testing "populator can recreate dropped table"
(is (=? {:success true} (run-query! populator :write (format (driver-creates (:engine db)) isolation temp-table))))))
(finally
(isolation-manager/delete-isolation (:engine db) details workspace-id
isolation-info)))))))
(deftest evaluator-test
(let [steps [:overall
{}
[:subgoal1 {} "action1" "action2"]
[:subgoal2 {}
"action3"
[:subsubgoal {} "action4"]]]]
(testing "general evaluation works"
(let [result (#'isolation-manager/evaluate-steps steps (fn [x] [:success x]))]
(is (= [[[:tree :overall]
[:tree :subgoal1]
[:success "action1"]
[:success "action2"]
[:tree :subgoal2]
[:success "action3"]
[:tree :subsubgoal]
[:success "action4"]]
:running] result ))))
(testing "errors prevent more work"
(let [result (#'isolation-manager/evaluate-steps steps (fn [x]
(if (= x "action2")
[:error "action2 is failed"]
[:success x])))]
(is (= [[[:tree :overall]
[:tree :subgoal1]
[:success "action1"]
[:error "action2 is failed"]
[:skipping-tree :subgoal2]]
:error]
result)))))
(testing "errors can be marked recoverable"
(let [steps [:cleanup {:error-strategy ::isolation-manager/continue-on-error}
[:remove-privileges {} "revoke privileges"]
[:remove-schema {} "drop schema"]
[:remove-user {} "drop user"]]
result (#'isolation-manager/evaluate-steps steps
(fn [x]
[:error x "failed"]))]
(is (= [[[:tree :cleanup]
[:tree :remove-privileges]
[:error "revoke privileges" "failed"]
[:tree :remove-schema]
[:error "drop schema" "failed"]
[:tree :remove-user]
[:error "drop user" "failed"]]
:running] result))))
(testing "can error one subtree and then continue"
(let [steps [:overall-with-continue
{:error-strategy ::isolation-manager/continue-on-error}
[:subgoal1-with-fail {:error-strategy ::isolation-manager/fail} "action1" "action2"]
[:subgoal2 {}
"action3"
[:subsubgoal {} "action4"]]]
results (#'isolation-manager/evaluate-steps steps
(fn [x]
(if (= x "action1")
[:error x]
[:success x])))]
(is (= [[[:tree :overall-with-continue]
[:tree :subgoal1-with-fail] [:error "action1"] [:skipping-step "action2"]
[:tree :subgoal2] [:success "action3"]
[:tree :subsubgoal] [:success "action4"]]
:running]
results))))
(testing "can error one subtree and then continue"
(let [steps [:overall-with-continue
{:error-strategy ::isolation-manager/continue-on-error}
[:subgoal1-with-fail {:error-strategy ::isolation-manager/fail}
"action1" "action2"
[:subtree-should-be-skipped {} "not-important1" "not-important2"]]
[:subgoal2 {}
"action3"
[:subsubgoal {} "action4"]]]
results (#'isolation-manager/evaluate-steps steps
(fn [x]
(if (= x "action1")
[:error x]
[:success x])))]
(is (= [[[:tree :overall-with-continue]
[:tree :subgoal1-with-fail]
[:error "action1"] [:skipping-step "action2"]
;; tree maintains the failure strategy
[:skipping-tree :subtree-should-be-skipped]
;; but popping back out continues according to main strategy
[:tree :subgoal2] [:success "action3"]
[:tree :subsubgoal] [:success "action4"]]
:running]
results))))
(testing "rules can be opaque"
(let [steps [:compute {}
[+ 1]
[+ 1]
[:multiply {}
[* 10] [* 5]]
[:divide {:error-strategy ::isolation-manager/continue-on-error}
[/ 2] [/ 0]]
[:subtract
{}
[- 10]]]]
(let [result (atom 0)
results (#'isolation-manager/evaluate-steps steps (fn [[op x]]
(try
[:success (swap! result op x)]
(catch Exception _e
[:error :failed]))))]
(is (=? [[[:tree :compute]
[:success 1]
[:success 2]
[:tree :multiply]
[:success 20]
[:success 100]
[:tree :divide]
[:success 50]
[:error :failed]
[:tree :subtract]
[:success 40]]
:running]
results)
(= 50 @result))))))
(comment
(run-tests)
(mt/set-test-drivers! #{:postgres :clickhouse :snowflake})
(mt/set-test-drivers! #{:snowflake})
(mt/set-test-drivers! #{:postgres})
(mt/set-test-drivers! #{:clickhouse})
(do (run-test e2e)
(run-test create-isolation-test))
{:isolation-type :schema
:schema-name "mb__isolation_7748c_test_workspace_123"
:populator {:user "mb_iso_7748c_test_workspace_123_populator"
:password "d632f61c-398a-467d-856c-d8411665bd0f"}}
(mt/test-drivers #{:postgres}
(let [db (mt/db)]
(isolation-manager/delete-isolation (:engine db) (:details db) "test-workspace-123")
(tap> (isolation-manager/create-isolation (:engine db) (:details db) "test-workspace-123"))))
(mt/test-drivers #{:postgres}
(let [db (mt/db)
engine (:engine db)
connection-details (:details db)
;; Simple API call
result (isolation-manager/create-isolation engine connection-details "demo-workspace")]
(try
(tap> result)
(def result result)
(finally
(isolation-manager/delete-isolation engine connection-details "demo-workspace" result)
(println "Cleaned up demo")))))
)

View File

@@ -33,6 +33,7 @@ export type MetabaseCollectionItem = {
| "model"
| "question"
| "metric"
| "in_document"
| null;
"last-edit-info"?: {
email: string;

View File

@@ -0,0 +1,52 @@
import type {
Card,
CreateDocumentRequest,
Document,
DocumentContent,
DocumentId,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
import { idTag } from "./tags";
export const documentApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
getDocument: builder.query<Document, { id: DocumentId }>({
query: ({ id }) => ({
method: "GET",
url: `/api/ee/document/${id}`,
}),
providesTags: (result, error, { id }) =>
!error ? [idTag("document", id)] : [],
}),
createDocument: builder.mutation<Document, CreateDocumentRequest>({
query: (body) => ({
method: "POST",
url: "/api/ee/document",
body,
}),
invalidatesTags: (_, error) => (error ? [] : []), // TODO: invalidate parent collection?
}),
updateDocument: builder.mutation<
Document,
Pick<Document, "id" | "name"> & {
document?: DocumentContent;
cards?: Record<number, Card>;
}
>({
query: (document) => ({
method: "PUT",
url: `/api/ee/document/${document.id}`,
body: document,
}),
invalidatesTags: (_, error, { id }) =>
!error ? [idTag("document", id)] : [],
}),
}),
});
export const {
useGetDocumentQuery,
useCreateDocumentMutation,
useUpdateDocumentMutation,
} = documentApi;

View File

@@ -3,11 +3,14 @@ export * from "./ai-sql-generation";
export * from "./audit-info";
export * from "./api";
export * from "./billing-info";
export * from "./metabot";
export * from "./content-translation";
export * from "./gdrive";
export * from "./db-routing";
export * from "./gdrive";
export * from "./metabot";
export * from "./document";
export * from "./saml";
export * from "./scim";
export * from "./tags";
export * from "./transform";
export * from "./upload-management";
export * from "./workspace";

View File

@@ -1,11 +1,18 @@
import type { TagDescription } from "@reduxjs/toolkit/query";
import { TAG_TYPES } from "metabase/api/tags";
import type { Transform } from "metabase-types/api";
export const ENTERPRISE_TAG_TYPES = [
...TAG_TYPES,
"scim",
"metabot",
"metabot-entities-list",
"metabot-prompt-suggestions",
"gsheets-status",
"transform",
"document",
"workspace",
] as const;
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];
@@ -35,3 +42,24 @@ export function invalidateTags(
): TagDescription<EnterpriseTagType>[] {
return !error ? tags : [];
}
export function provideTransformTags(
transform: Transform,
): TagDescription<EnterpriseTagType>[] {
return [idTag("transform", transform.id)];
}
export function provideTransformListTags(
transforms: Transform[],
): TagDescription<EnterpriseTagType>[] {
return [listTag("transform"), ...transforms.flatMap(provideTransformTags)];
}
export function provideWorkspaceTags(
workspaces: Array<{ id: number }>,
): TagDescription<EnterpriseTagType>[] {
return [
listTag("workspace"),
...workspaces.map(workspace => idTag("workspace", workspace.id)),
];
}

View File

@@ -0,0 +1,89 @@
import type {
CreateTransformRequest,
Transform,
TransformId,
UpdateTransformRequest,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
import {
idTag,
invalidateTags,
listTag,
provideTransformListTags,
provideTransformTags,
} from "./tags";
export const transformApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
listTransforms: builder.query<Transform[], void>({
query: (params) => ({
method: "GET",
url: "/api/ee/transform",
params,
}),
providesTags: (transforms = []) => provideTransformListTags(transforms),
}),
getTransform: builder.query<Transform, TransformId>({
query: (id) => ({
method: "GET",
url: `/api/ee/transform/${id}`,
}),
providesTags: (transform) =>
transform ? provideTransformTags(transform) : [],
}),
executeTransform: builder.mutation<Transform, TransformId>({
query: (id) => ({
method: "POST",
url: `/api/ee/transform/${id}/execute`,
}),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [idTag("transform", id), listTag("table")]),
}),
createTransform: builder.mutation<Transform, CreateTransformRequest>({
query: (body) => ({
method: "POST",
url: "/api/ee/transform",
body,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("transform")]),
}),
updateTransform: builder.mutation<Transform, UpdateTransformRequest>({
query: ({ id, ...body }) => ({
method: "PUT",
url: `/api/ee/transform/${id}`,
body,
}),
invalidatesTags: (_, error, { id }) =>
invalidateTags(error, [listTag("transform"), idTag("transform", id)]),
}),
deleteTransform: builder.mutation<Transform, TransformId>({
query: (id) => ({
method: "DELETE",
url: `/api/ee/transform/${id}`,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("transform")]),
}),
deleteTransformTarget: builder.mutation<Transform, TransformId>({
query: (id) => ({
method: "DELETE",
url: `/api/ee/transform/${id}/table`,
}),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [idTag("transform", id), listTag("table")]),
}),
}),
});
export const {
useListTransformsQuery,
useLazyListTransformsQuery,
useGetTransformQuery,
useExecuteTransformMutation,
useCreateTransformMutation,
useUpdateTransformMutation,
useDeleteTransformMutation,
useDeleteTransformTargetMutation,
} = transformApi;

View File

@@ -0,0 +1,299 @@
import type { CollectionId } from "metabase-types/api";
import { EnterpriseApi } from "./api";
import { idTag, listTag, provideWorkspaceTags } from "./tags";
export interface Plan {
title: string;
description?: string;
content: any;
created_at: string;
}
export interface Transform {
id: string;
name: string;
description: string;
source: any;
target: any;
config?: any;
created_at: string;
}
export interface WorkspaceUser {
id: string;
name: string;
email: string;
type: string;
created_at: string;
}
export interface DataWarehouse {
id: string;
data_warehouse_id?: number;
name: string;
type: "read-only" | "read-write";
credentials: any;
created_at: string;
}
export interface Permission {
id: string;
table: string;
permission: "read" | "write";
created_at: string;
}
export interface Workspace {
id: number;
collection_id: CollectionId;
name: string;
description?: string | null;
plans?: Plan[];
activity_logs?: any[];
transforms?: Transform[];
documents?: any[];
users?: WorkspaceUser[];
data_warehouses?: DataWarehouse[];
permissions?: Permission[];
created_at?: string;
updated_at?: string;
}
export interface CreateWorkspaceRequest {
name: string;
collection_id: CollectionId;
description?: string;
}
export interface UpdateWorkspaceRequest {
id: number;
name: string;
description?: string;
}
export interface ListWorkspacesResponse {
data?: Workspace[];
}
export const workspaceApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
listWorkspaces: builder.query<Workspace[], void>({
query: () => ({
method: "GET",
url: `/api/ee/workspace`,
}),
providesTags: (workspaces = []) => provideWorkspaceTags(workspaces),
}),
getWorkspace: builder.query<Workspace, number>({
query: (id) => ({
method: "GET",
url: `/api/ee/workspace/${id}`,
}),
providesTags: (workspace) =>
workspace ? [idTag("workspace", workspace.id)] : [],
}),
createWorkspace: builder.mutation<Workspace, CreateWorkspaceRequest>({
query: (body) => ({
method: "POST",
url: `/api/ee/workspace`,
body,
}),
invalidatesTags: [listTag("workspace")],
}),
updateWorkspace: builder.mutation<Workspace, UpdateWorkspaceRequest>({
query: ({ id, ...body }) => ({
method: "PUT",
url: `/api/ee/workspace/${id}`,
body,
}),
invalidatesTags: (_, __, { id }) => [
idTag("workspace", id),
listTag("workspace"),
],
}),
deleteWorkspace: builder.mutation<void, number>({
query: (id) => ({
method: "DELETE",
url: `/api/ee/workspace/${id}`,
}),
invalidatesTags: (_, __, id) => [
idTag("workspace", id),
listTag("workspace"),
],
}),
updatePlan: builder.mutation<
Workspace,
{ workspaceId: number; planIndex: number; title: string; description?: string; content: any }
>({
query: ({ workspaceId, planIndex, ...body }) => ({
method: "PUT",
url: `/api/ee/workspace/${workspaceId}/plan/${planIndex}`,
body,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
deletePlan: builder.mutation<
void,
{ workspaceId: number; planIndex: number }
>({
query: ({ workspaceId, planIndex }) => ({
method: "DELETE",
url: `/api/ee/workspace/${workspaceId}/plan/${planIndex}`,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
// Transform endpoints (workspace-specific)
updateWorkspaceTransform: builder.mutation<
Workspace,
{ workspaceId: number; transformIndex: number; name: string; description: string; source: any; target: any; config?: any }
>({
query: ({ workspaceId, transformIndex, ...body }) => ({
method: "PUT",
url: `/api/ee/workspace/${workspaceId}/transform/${transformIndex}`,
body,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
deleteWorkspaceTransform: builder.mutation<
void,
{ workspaceId: number; transformIndex: number }
>({
query: ({ workspaceId, transformIndex }) => ({
method: "DELETE",
url: `/api/ee/workspace/${workspaceId}/transform/${transformIndex}`,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
// User endpoints
updateUser: builder.mutation<
Workspace,
{ workspaceId: number; userIndex: number; user_id: number; name: string; email: string; type: string }
>({
query: ({ workspaceId, userIndex, ...body }) => ({
method: "PUT",
url: `/api/ee/workspace/${workspaceId}/user/${userIndex}`,
body,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
deleteUser: builder.mutation<
void,
{ workspaceId: number; userIndex: number }
>({
query: ({ workspaceId, userIndex }) => ({
method: "DELETE",
url: `/api/ee/workspace/${workspaceId}/user/${userIndex}`,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
// Data warehouse endpoints
updateDataWarehouse: builder.mutation<
Workspace,
{ workspaceId: number; dataWarehouseIndex: number; data_warehouses_id: number; name: string; type: "read-only" | "read-write"; credentials: any }
>({
query: ({ workspaceId, dataWarehouseIndex, ...body }) => ({
method: "PUT",
url: `/api/ee/workspace/${workspaceId}/data_warehouse/${dataWarehouseIndex}`,
body,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
deleteDataWarehouse: builder.mutation<
void,
{ workspaceId: number; dataWarehouseIndex: number }
>({
query: ({ workspaceId, dataWarehouseIndex }) => ({
method: "DELETE",
url: `/api/ee/workspace/${workspaceId}/data_warehouse/${dataWarehouseIndex}`,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
// Permission endpoints
updatePermission: builder.mutation<
Workspace,
{ workspaceId: number; permissionIndex: number; table: string; permission: "read" | "write" }
>({
query: ({ workspaceId, permissionIndex, ...body }) => ({
method: "PUT",
url: `/api/ee/workspace/${workspaceId}/permission/${permissionIndex}`,
body,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
deletePermission: builder.mutation<
void,
{ workspaceId: number; permissionIndex: number }
>({
query: ({ workspaceId, permissionIndex }) => ({
method: "DELETE",
url: `/api/ee/workspace/${workspaceId}/permission/${permissionIndex}`,
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
// Link existing transform to workspace
linkTransformToWorkspace: builder.mutation<
Workspace,
{ workspaceId: number; transformId: number }
>({
query: ({ workspaceId, transformId }) => ({
method: "POST",
url: `/api/ee/workspace/${workspaceId}/transform/link`,
body: { transform_id: transformId },
}),
invalidatesTags: (_, __, { workspaceId }) => [
idTag("workspace", workspaceId),
listTag("workspace"),
],
}),
}),
});
export const {
useListWorkspacesQuery,
useGetWorkspaceQuery,
useCreateWorkspaceMutation,
useUpdateWorkspaceMutation,
useDeleteWorkspaceMutation,
useUpdatePlanMutation,
useDeletePlanMutation,
useUpdateWorkspaceTransformMutation,
useDeleteWorkspaceTransformMutation,
useUpdateUserMutation,
useDeleteUserMutation,
useUpdateDataWarehouseMutation,
useDeleteDataWarehouseMutation,
useUpdatePermissionMutation,
useDeletePermissionMutation,
useLinkTransformToWorkspaceMutation,
} = workspaceApi;

View File

@@ -0,0 +1,8 @@
import { push } from "react-router-redux";
import type { Dispatch } from "metabase-types/store";
export const navigateBackToDocument =
(documentId: number) => (dispatch: Dispatch) => {
dispatch(push(`/document/${documentId}`));
};

View File

@@ -0,0 +1,66 @@
import { type HTMLAttributes, useCallback } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import { useDispatch } from "metabase/lib/redux";
import { ActionIcon, type ActionIconProps, Icon, Tooltip } from "metabase/ui";
import { navigateBackToDocument } from "metabase-enterprise/documents/actions";
import { useDocumentsSelector } from "metabase-enterprise/documents/redux-utils";
import {
getDocument,
getShowNavigateBackToDocumentButton,
} from "metabase-enterprise/documents/selectors";
export type DocumentBackButtonProps = {
noLink?: boolean;
onClick?: () => void;
documentOverride?: {
id: number;
name: string;
};
} & ActionIconProps &
HTMLAttributes<HTMLButtonElement>;
export function DocumentBackButton({
noLink,
onClick,
documentOverride,
...actionIconProps
}: DocumentBackButtonProps) {
const documentState = useDocumentsSelector(getDocument);
const showButton = useDocumentsSelector(getShowNavigateBackToDocumentButton);
const document = documentOverride ?? documentState;
const dispatch = useDispatch();
const handleClick = useCallback(() => {
if (document) {
dispatch(navigateBackToDocument(document.id));
}
onClick?.();
}, [document, dispatch, onClick]);
if (!document || !showButton) {
return null;
}
const label = t`Back to ${document.name}`;
return (
<Tooltip label={label}>
<ActionIcon
variant="outline"
radius="xl"
size="2.625rem"
color="border"
aria-label={label}
onClick={handleClick}
component={noLink ? undefined : Link}
to={`/document/${document.id}`}
{...actionIconProps}
>
<Icon c="brand" name="arrow_left" />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -0,0 +1 @@
export { DocumentBackButton } from "./DocumentBackButton";

View File

@@ -0,0 +1,135 @@
.documentPage {
display: flex;
flex-direction: column;
height: calc(100vh - var(--nav-sidebar-height, 60px));
background-color: var(--mb-color-bg-white);
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1rem 0;
background-color: var(--mb-color-bg-white);
gap: 1rem;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.titleInput {
flex: 1;
max-width: 600px;
border: none;
outline: none;
background: transparent;
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
color: var(--mb-color-text-dark);
font-family: inherit;
padding: 0;
margin: 0;
}
.titleInput::placeholder {
color: var(--mb-color-text-light);
opacity: 0.7;
}
.titleInput:focus {
outline: none;
}
.contentArea {
display: flex;
flex: 1;
overflow: hidden;
}
.mainContent {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 0 2rem;
}
.documentContainer {
width: 100%;
padding: 0 2rem 4rem;
min-height: 100%;
}
@media print {
body:has(.documentContainer) {
visibility: hidden;
/* side nav */
aside {
display: none;
}
}
.documentContainer {
position: absolute;
visibility: visible;
top: 0;
right: 0;
z-index: 1000;
.header {
padding: 0;
}
:global(.node-cardEmbed) {
break-inside: avoid;
}
[data-hide-on-print] {
visibility: hidden;
}
}
}
.sidebar {
width: 360px;
flex-shrink: 0;
background-color: var(--mb-color-bg-light);
border-left: 1px solid var(--mb-color-border);
overflow-y: auto;
}
.cardEmbed {
padding: 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.cardEmbed:hover {
background-color: var(--mb-color-bg-medium);
}
.highlighted {
animation: highlight 2s ease-out;
}
@keyframes highlight {
0% {
background-color: var(--mb-color-brand-light);
box-shadow: 0 0 0 4px var(--mb-color-brand-light);
}
100% {
background-color: transparent;
box-shadow: none;
}
}
.metadataItem {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--mb-color-text-secondary);
}

View File

@@ -0,0 +1,468 @@
import { useDisclosure } from "@mantine/hooks";
import { useCallback, useEffect, useState } from "react";
import type { Route } from "react-router";
import { push, replace } from "react-router-redux";
import { usePrevious } from "react-use";
import useBeforeUnload from "react-use/lib/useBeforeUnload";
import { t } from "ttag";
import { skipToken } from "metabase/api";
import { canonicalCollectionId } from "metabase/collections/utils";
import DateTime from "metabase/common/components/DateTime";
import { LeaveRouteConfirmModal } from "metabase/common/components/LeaveConfirmModal";
import { CollectionPickerModal } from "metabase/common/components/Pickers/CollectionPicker";
import { useToast } from "metabase/common/hooks";
import { useCallbackEffect } from "metabase/common/hooks/use-callback-effect";
import { useDispatch } from "metabase/lib/redux";
import {
ActionIcon,
Box,
Button,
Flex,
Icon,
Loader,
Menu,
Text,
TextInput,
} from "metabase/ui";
import {
useCreateDocumentMutation,
useGetDocumentQuery,
useUpdateDocumentMutation,
} from "metabase-enterprise/api";
import type { Card, RegularCollectionId } from "metabase-types/api";
import {
clearDraftCards,
closeSidebar,
openVizSettingsSidebar,
resetDocuments,
setCurrentDocument,
} from "../documents.slice";
import { useDocumentState, useRegisterDocumentMetabotContext } from "../hooks";
import { useDocumentsSelector } from "../redux-utils";
import {
getDraftCards,
getSelectedEmbedIndex,
getSelectedQuestionId,
} from "../selectors";
import styles from "./DocumentPage.module.css";
import { Editor } from "./Editor";
import { EmbedQuestionSettingsSidebar } from "./EmbedQuestionSettingsSidebar";
import { downloadFile, getDownloadableMarkdown } from "./exports";
export const DocumentPage = ({
params: { id: documentId },
location,
route,
}: {
params: { id?: number | "new" };
location?: { query?: { version?: string } };
route: Route;
}) => {
const dispatch = useDispatch();
const selectedQuestionId = useDocumentsSelector(getSelectedQuestionId);
const selectedEmbedIndex = useDocumentsSelector(getSelectedEmbedIndex);
const draftCards = useDocumentsSelector(getDraftCards);
const [editorInstance, setEditorInstance] = useState<any>(null);
const [isDownloading, setIsDownloading] = useState(false);
const [currentContent, setCurrentContent] = useState<string>("");
const [createDocument] = useCreateDocumentMutation();
const [updateDocument] = useUpdateDocumentMutation();
const [
isShowingCollectionPicker,
{ open: showCollectionPicker, close: hideCollectionPicker },
] = useDisclosure(false);
const [sendToast] = useToast();
const selectedVersion = location?.query?.version
? Number(location.query.version)
: undefined;
const previousDocumentId = usePrevious(documentId);
const previousVersion = usePrevious(selectedVersion);
const [isNavigationScheduled, scheduleNavigation] = useCallbackEffect();
const isNewDocument = documentId === "new";
const { data: documentData, isLoading: isDocumentLoading } =
useGetDocumentQuery(
documentId && !isNewDocument
? { id: documentId, version: selectedVersion }
: skipToken,
);
const canWrite = isNewDocument ? true : documentData?.can_write;
const {
documentTitle,
setDocumentTitle,
documentContent,
setDocumentContent,
cardEmbeds,
updateCardEmbeds,
} = useDocumentState(documentData);
// Reset current content when document changes
useEffect(() => {
if (
documentId !== previousDocumentId ||
selectedVersion !== previousVersion
) {
setCurrentContent(documentContent || "");
}
}, [
documentId,
previousDocumentId,
selectedVersion,
previousVersion,
documentContent,
]);
useRegisterDocumentMetabotContext();
useBeforeUnload(() => {
// warn if you try to navigate away with unsaved changes
return hasUnsavedChanges();
});
// Update current content when document content changes
useEffect(() => {
setCurrentContent(documentContent || "");
}, [documentContent]);
// Reset state when creating a new document
useEffect(() => {
if (isNewDocument && previousDocumentId !== "new") {
setDocumentTitle("");
setDocumentContent("");
setCurrentContent("");
// Clear the Redux state to ensure no card embeds from previous document
dispatch(resetDocuments());
}
}, [
documentId,
setDocumentTitle,
setDocumentContent,
previousDocumentId,
dispatch,
isNewDocument,
]);
useEffect(() => {
// Set current document when document loads (includes collection_id and all other data)
if (documentData && !isNewDocument) {
dispatch(setCurrentDocument(documentData));
} else if (isNewDocument) {
dispatch(setCurrentDocument(null));
}
}, [documentData, documentId, dispatch, isNewDocument]);
const hasUnsavedChanges = useCallback(() => {
const currentTitle = documentTitle.trim();
const originalTitle = documentData?.name || "";
const titleChanged = currentTitle !== originalTitle;
// Check if there are any draft cards
const hasDraftCards = Object.keys(draftCards).length > 0;
// For new documents, show Save if there's title or content exists or draft cards
if (isNewDocument) {
const emptyDocAst = JSON.stringify({ type: "doc", content: [] });
const hasContent =
currentContent !== emptyDocAst && currentContent !== "";
return currentTitle.length > 0 || hasContent || hasDraftCards;
}
// For existing documents, compare current content with document content
// documentContent is already stringified from the hook
const contentChanged = currentContent !== (documentContent ?? "");
return titleChanged || contentChanged || hasDraftCards;
}, [
documentTitle,
isNewDocument,
documentData,
currentContent,
documentContent,
draftCards,
]);
const showSaveButton = hasUnsavedChanges() && canWrite;
const handleSave = useCallback(
async (collectionId: RegularCollectionId | null = null) => {
if (!editorInstance) {
return;
}
try {
const cardsToSave: Record<number, Card> = {};
const processedCardIds = new Set<number>();
editorInstance.state.doc.descendants((node: any) => {
if (node.type.name === "cardEmbed") {
const cardId = node.attrs.id;
if (!processedCardIds.has(cardId)) {
processedCardIds.add(cardId);
if (cardId < 0 && draftCards[cardId]) {
cardsToSave[cardId] = draftCards[cardId];
}
}
}
});
const documentAst = currentContent ? JSON.parse(currentContent) : null;
const newDocumentData: any = {
name: documentTitle,
document: documentAst,
cards: Object.keys(cardsToSave).length > 0 ? cardsToSave : undefined,
};
const result = await (documentData?.id
? updateDocument({ ...newDocumentData, id: documentData.id }).then(
(response) => {
if (response.data) {
scheduleNavigation(() => {
dispatch(push(`/document/${response.data.id}`));
});
}
return response.data;
},
)
: createDocument({
...newDocumentData,
collection_id: collectionId || undefined,
}).then((response) => {
if (response.data) {
scheduleNavigation(() => {
dispatch(replace(`/document/${response.data.id}`));
});
}
return response.data;
}));
if (result) {
sendToast({
message: documentData?.id ? t`Document saved` : t`Document created`,
});
dispatch(clearDraftCards());
}
} catch (error) {
console.error("Failed to save document:", error);
sendToast({ message: t`Error saving document`, icon: "warning" });
}
},
[
editorInstance,
documentTitle,
currentContent,
draftCards,
documentData?.id,
updateDocument,
createDocument,
scheduleNavigation,
dispatch,
sendToast,
],
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Save shortcut: Cmd+S (Mac) or Ctrl+S (Windows/Linux)
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
event.preventDefault();
if (!hasUnsavedChanges() || !canWrite) {
return;
}
isNewDocument ? showCollectionPicker() : handleSave();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
hasUnsavedChanges,
handleSave,
isNewDocument,
showCollectionPicker,
canWrite,
]);
const handleQuestionSelect = useCallback(
(cardId: number | null, embedIndex?: number | null) => {
if (
cardId !== null &&
embedIndex !== null &&
embedIndex !== undefined &&
embedIndex >= 0 &&
selectedEmbedIndex !== null
) {
// Only update the selected embed index if the sidebar is already open
dispatch(openVizSettingsSidebar({ embedIndex }));
}
},
[dispatch, selectedEmbedIndex],
);
const handleDownloadMarkdown = useCallback(() => {
if (!editorInstance) {
return;
}
(async () => {
try {
setIsDownloading(true);
const rawMarkdown = editorInstance.storage.markdown.getMarkdown();
const processedMarkdown = await getDownloadableMarkdown(
rawMarkdown,
cardEmbeds,
);
downloadFile(processedMarkdown);
} catch (error) {
console.error("Failed to download markdown:", error);
} finally {
setIsDownloading(false);
}
})();
}, [cardEmbeds, editorInstance]);
const handlePrintDocument = useCallback(() => {
window.print();
}, []);
return (
<Box className={styles.documentPage}>
<Box className={styles.contentArea}>
<Box className={styles.mainContent}>
<Box className={styles.documentContainer}>
<Box className={styles.header} mt="xl" pt="xl">
{/* <Flex gap="sm" align="center" style={{ width: "100%" }}> */}
<Flex direction="column">
<TextInput
autoFocus={isNewDocument}
value={documentTitle}
onChange={(event) =>
setDocumentTitle(event.currentTarget.value)
}
flex={1}
placeholder={t`New document`}
readOnly={!canWrite}
styles={{
input: {
border: "none",
padding: 0,
fontSize: "2rem",
fontWeight: "bold",
},
}}
/>
{documentData && (
<Flex gap="md">
<Text className={styles.metadataItem}>
<Icon name="person" />
{documentData.creator.common_name}{" "}
</Text>
<Text className={styles.metadataItem}>
<Icon name="clock" />
<DateTime value={documentData.updated_at} unit="day" />
</Text>
</Flex>
)}
</Flex>
<Flex gap="md" align="center">
{showSaveButton && (
<Button
onClick={() => {
isNewDocument ? showCollectionPicker() : handleSave();
}}
variant="filled"
data-hide-on-print
>
{t`Save`}
</Button>
)}
<Menu position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
size="md"
aria-label={t`More options`}
data-hide-on-print
>
<Icon name="ellipsis" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
isDownloading ? (
<Loader size="xs" />
) : (
<Icon name="download" />
)
}
onClick={handleDownloadMarkdown}
disabled={isDownloading}
>
{isDownloading ? t`Downloading...` : t`Download`}
</Menu.Item>
<Menu.Item
leftSection={<Icon name="document" />}
onClick={handlePrintDocument}
>
{t`Print Document`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
</Box>
<Editor
onEditorReady={setEditorInstance}
onCardEmbedsChange={updateCardEmbeds}
onQuestionSelect={handleQuestionSelect}
content={documentContent || ""}
onChange={setCurrentContent}
editable={canWrite}
isLoading={isDocumentLoading}
/>
</Box>
</Box>
{selectedQuestionId && selectedEmbedIndex !== null && (
<Box className={styles.sidebar}>
<EmbedQuestionSettingsSidebar
cardId={selectedQuestionId}
onClose={() => dispatch(closeSidebar())}
editorInstance={editorInstance}
/>
</Box>
)}
{isShowingCollectionPicker && (
<CollectionPickerModal
title={t`Where should we save this document?`}
onClose={hideCollectionPicker}
value={{ id: "root", model: "collection" }}
options={{
showPersonalCollections: true,
showRootCollection: true,
}}
onChange={(collection) => {
handleSave(canonicalCollectionId(collection.id));
hideCollectionPicker();
}}
/>
)}
<LeaveRouteConfirmModal
isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
route={route}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,343 @@
import type { Editor } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { t } from "ttag";
import { cardApi, useListRecentsQuery } from "metabase/api";
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
import Search from "metabase/entities/search";
import { getName } from "metabase/lib/name";
import { useDispatch } from "metabase/lib/redux";
import { RecentsListContent } from "metabase/nav/components/search/RecentsList/RecentsListContent";
import { SearchResults } from "metabase/nav/components/search/SearchResults";
import {
ResultNameSection,
ResultTitle,
SearchResultContainer,
} from "metabase/search/components/SearchResult";
import { IconWrapper } from "metabase/search/components/SearchResult/components/ItemIcon.styled";
import { Box, Group, Icon, Popover, Text } from "metabase/ui";
import type {
RecentItem,
SearchModel,
UnrestrictedLinkEntity,
} from "metabase-types/api";
const COMMAND_MODELS_TO_SEARCH: SearchModel[] = ["card", "dataset"];
interface ExtraItemProps {
isSelected?: boolean;
onClick?: () => void;
}
const SearchResultsFooter = ({ isSelected, onClick }: ExtraItemProps) => (
<Box mx="sm" mb="sm" mt={-8}>
<SearchResultContainer
align="center"
isActive
isSelected={isSelected}
onClick={onClick}
>
<IconWrapper active archived={false} type="search">
<Icon name="search" />
</IconWrapper>
<ResultNameSection justify="center" gap="xs">
<Group gap="xs" align="center" wrap="nowrap">
<ResultTitle
role="heading"
data-testid="search-result-item-name"
truncate
>
{t`Browse all`}
</ResultTitle>
</Group>
</ResultNameSection>
</SearchResultContainer>
</Box>
);
const MetabotMenuItem = ({ isSelected, onClick }: ExtraItemProps) => (
<Box>
<SearchResultContainer isActive isSelected={isSelected} onClick={onClick}>
<IconWrapper active archived={false} type="search">
<Icon name="metabot" />
</IconWrapper>
<ResultNameSection gap="xs">
<Group gap="xs" align="center" wrap="nowrap">
<ResultTitle
role="heading"
data-testid="search-result-item-name"
truncate
>
{t`Ask metabot`}
</ResultTitle>
</Group>
<Text size="sm" c="text-medium">
{t`It wants to help!`}
</Text>
</ResultNameSection>
</SearchResultContainer>
</Box>
);
interface CommandPluginProps {
editor: Editor;
}
export const CommandPlugin = ({ editor }: CommandPluginProps) => {
const dispatch = useDispatch();
const [showPopover, setShowPopover] = useState(false);
const [modal, setModal] = useState<"question-picker" | null>(null);
const [query, setQuery] = useState("");
const [commandRange, setCommandRange] = useState<{
from: number;
to: number;
} | null>(null);
const [anchorPos, setAnchorPos] = useState<{ x: number; y: number } | null>(
null,
);
const virtualRef = useRef<HTMLDivElement>(null);
const { data: recents = [], isLoading: isRecentsLoading } =
useListRecentsQuery(undefined, { refetchOnMountOrArgChange: true });
const filteredRecents = recents
.filter((item) =>
COMMAND_MODELS_TO_SEARCH.includes(item.model as SearchModel),
)
.slice(0, 4);
const insertMetabotBlock = useCallback(async () => {
if (!commandRange) {
return;
}
const insertPosition = commandRange.from;
editor
.chain()
.focus()
.deleteRange(commandRange)
.insertContentAt(insertPosition, {
type: "metabot",
attrs: {},
})
.setTextSelection(insertPosition + 1)
.run();
setShowPopover(false);
setCommandRange(null);
}, [editor, commandRange]);
useEffect(() => {
if (!editor) {
return;
}
const updateHandler = () => {
const { $from } = editor.state.selection;
if (commandRange && showPopover) {
// Check if we're still in command mode
const currentText = editor.state.doc.textBetween(
commandRange.from,
Math.min(editor.state.doc.content.size, $from.pos),
"",
);
if (currentText.startsWith("/")) {
setQuery(currentText.slice(1));
setCommandRange({
from: commandRange.from,
to: $from.pos,
});
} else {
setShowPopover(false);
setCommandRange(null);
}
}
const text = $from.nodeBefore?.text || "";
// Check if we're typing after "/" and it's the start of the paragraph
if (text.trimStart() === "/") {
const from = $from.pos - 1;
setCommandRange({ from, to: $from.pos });
// Get cursor position
const coords = editor.view.coordsAtPos(from);
setAnchorPos({ x: coords.left, y: coords.bottom });
setShowPopover(true);
setQuery("");
}
};
const keydownHandler = (event: KeyboardEvent) => {
if (!showPopover) {
return;
}
// Prevent cursor movement in editor for arrow keys
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
event.preventDefault();
return;
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setShowPopover(false);
setCommandRange(null);
editor.commands.focus();
}
};
editor.on("update", updateHandler);
editor.on("selectionUpdate", updateHandler);
const editorElement = editor.view.dom;
editorElement.addEventListener("keydown", keydownHandler, true);
return () => {
editor.off("update", updateHandler);
editor.off("selectionUpdate", updateHandler);
editorElement.removeEventListener("keydown", keydownHandler, true);
};
}, [editor, commandRange, showPopover]);
const handleSelect = async (item: UnrestrictedLinkEntity | null) => {
if (!commandRange) {
return;
}
if (item === null) {
setShowPopover(false);
setCommandRange(null);
editor.commands.focus();
return;
}
const wrappedItem = Search.wrapEntity(item, dispatch);
const insertPosition = commandRange.from;
try {
// Fetch the full card data using RTK Query
const { data: originalCard } = await dispatch(
cardApi.endpoints.getCard.initiate({ id: wrappedItem.id }),
);
if (!originalCard) {
throw new Error("Failed to fetch card data");
}
// Simply insert the original card ID - backend will handle cloning when saving document
editor
.chain()
.focus()
.deleteRange(commandRange)
.insertContentAt(insertPosition, {
type: "cardEmbed",
attrs: {
id: originalCard.id,
},
})
.setTextSelection(insertPosition + 1)
.run();
setShowPopover(false);
setCommandRange(null);
} catch (error) {
console.error("Failed to create snapshot:", error);
}
};
const handleRecentSelect = (item: RecentItem) => {
handleSelect({
...item,
description: item.description ?? undefined,
name: getName(item),
});
};
useEffect(() => {
if (virtualRef.current && anchorPos) {
virtualRef.current.style.position = "fixed";
virtualRef.current.style.left = `${anchorPos.x}px`;
virtualRef.current.style.top = `${anchorPos.y}px`;
virtualRef.current.style.width = "1px";
virtualRef.current.style.height = "1px";
}
}, [anchorPos]);
return (
<>
<Popover
opened={showPopover}
position="bottom-start"
width={320}
shadow="md"
withinPortal
closeOnClickOutside={false}
middlewares={{ flip: true, shift: true }}
onClose={() => {
setShowPopover(false);
setCommandRange(null);
}}
>
<Popover.Target>
<div
ref={virtualRef}
style={{ position: "fixed", pointerEvents: "none" }}
/>
</Popover.Target>
<Popover.Dropdown
style={{
maxHeight: 400,
overflow: "auto",
}}
>
{query.length > 0 ? (
<SearchResults
searchText={query}
limit={4}
forceEntitySelect
onEntitySelect={handleSelect}
models={COMMAND_MODELS_TO_SEARCH}
footerComponent={SearchResultsFooter}
onFooterSelect={() => setModal("question-picker")}
showFooterOnNoResults
/>
) : (
<RecentsListContent
isLoading={isRecentsLoading}
results={filteredRecents}
onClick={handleRecentSelect}
headerChildren={[
Object.assign(MetabotMenuItem, {
onClick: insertMetabotBlock,
}),
]}
footerChildren={[
Object.assign(SearchResultsFooter, {
onClick: () => setModal("question-picker"),
}),
]}
/>
)}
</Popover.Dropdown>
</Popover>
{modal === "question-picker" && (
<QuestionPickerModal
onChange={async (item) => {
await handleSelect(item);
setModal(null);
}}
onClose={() => setModal(null)}
/>
)}
</>
);
};

View File

@@ -0,0 +1,199 @@
.editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editorContent {
flex: 1;
cursor: text;
position: relative;
}
.editorContent :global(.ProseMirror) {
padding: 1.5rem 0 2rem 0;
min-height: 100%;
outline: none;
max-width: 900px;
margin: 0 auto;
color: var(--mb-color-text-dark);
font-size: 1rem;
line-height: 1.5;
}
/* Apply typography styles only to elements NOT inside .editor-style-boundary */
.editorContent
:global(.ProseMirror)
:where(:global(h1), :global(h2), :global(h3)):not(
:global(.editor-style-boundary) *
) {
font-weight: 700;
color: var(--mb-color-text-dark);
}
.editorContent
:global(.ProseMirror)
:global(h1):not(:global(.editor-style-boundary) *) {
font-size: 1.75rem;
margin: 1.5rem 0 1rem;
line-height: 1.3;
}
.editorContent
:global(.ProseMirror)
:global(h2):not(:global(.editor-style-boundary) *) {
font-size: 1.375rem;
margin: 1.25rem 0 0.75rem;
line-height: 1.3;
}
.editorContent
:global(.ProseMirror)
:global(h3):not(:global(.editor-style-boundary) *) {
font-size: 1.125rem;
margin: 1rem 0 0.5rem;
line-height: 1.4;
}
.editorContent
:global(.ProseMirror)
:global(p):not(:global(.editor-style-boundary) *) {
margin: 0 0 1rem 0;
line-height: 1.5;
font-size: 1rem;
}
.editorContent
:global(.ProseMirror)
:where(:global(ul), :global(ol)):not(:global(.editor-style-boundary) *) {
margin: 0 0 1rem 0;
padding-left: 1.5rem;
}
.editorContent
:global(.ProseMirror)
:global(ul):not(:global(.editor-style-boundary) *) {
list-style-type: disc;
}
.editorContent
:global(.ProseMirror)
:global(ol):not(:global(.editor-style-boundary) *) {
list-style-type: decimal;
}
.editorContent
:global(.ProseMirror)
:global(li):not(:global(.editor-style-boundary) *) {
margin: 0 0 0.25rem 0;
line-height: 1.5;
display: list-item;
}
.editorContent
:global(.ProseMirror)
:global(ul ul):not(:global(.editor-style-boundary) *) {
list-style-type: circle;
margin-top: 0.25rem;
}
.editorContent
:global(.ProseMirror)
:global(ul ul ul):not(:global(.editor-style-boundary) *) {
list-style-type: square;
}
.editorContent
:global(.ProseMirror)
:where(:global(li > ul), :global(li > ol)):not(
:global(.editor-style-boundary) *
) {
margin-top: 0.25rem;
}
.editorContent
:global(.ProseMirror)
:global(li p):not(:global(.editor-style-boundary) *) {
line-height: 1.5;
margin-bottom: 0.25rem;
}
.editorContent
:global(.ProseMirror)
:global(code):not(:global(.editor-style-boundary) *) {
background-color: var(--mb-base-color-orion-5);
padding: 0.125rem 0.375rem;
border-radius: var(--mantine-radius-xs);
font-family: var(--mb-default-monospace-font-family, monospace);
font-size: 0.875rem;
color: var(--mb-color-text-dark);
border: 1px solid var(--mb-base-color-orion-10);
}
.editorContent
:global(.ProseMirror)
:global(pre):not(:global(.editor-style-boundary) *) {
background-color: var(--mb-base-color-gray-10);
color: var(--mb-color-text-dark);
padding: 1rem;
border-radius: var(--mantine-radius-sm);
overflow-x: auto;
margin: 0 0 1rem 0;
font-size: 0.875rem;
border: 1px solid var(--mb-base-color-gray-20);
}
.editorContent
:global(.ProseMirror)
:global(pre code):not(:global(.editor-style-boundary) *) {
background: none;
padding: 0;
color: inherit;
font-size: inherit;
border: none;
}
.editorContent
:global(.ProseMirror)
:global(blockquote):not(:global(.editor-style-boundary) *) {
border-left: 3px solid var(--mb-base-color-orion-20);
margin: 0 0 1rem 0;
color: var(--mb-color-text-medium);
font-style: italic;
background-color: var(--mb-base-color-orion-5);
padding: 1rem;
padding-left: 1.25rem;
border-radius: var(--mantine-radius-xs);
}
.editorContent
:global(.ProseMirror)
:global(hr):not(:global(.editor-style-boundary) *) {
border: none;
border-top: 1px solid var(--mb-color-border);
margin: 1.5rem 0;
}
.editorContent :global(.ProseMirror:focus) {
outline: none;
}
.editorContent :global(p.is-empty:first-child:before) {
color: var(--mb-color-text-light);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
font-size: 1rem;
}
.img {
max-width: 80%;
display: block;
margin: 1rem auto;
&:global(.ProseMirror-selectednode) {
box-shadow: 0 0 0 1px var(--mb-color-brand);
border-radius: var(--mantine-radius-xs);
}
}

View File

@@ -0,0 +1,210 @@
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import { Placeholder } from "@tiptap/extension-placeholder";
import {
EditorContent,
type Editor as TiptapEditor,
useEditor,
} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import cx from "classnames";
import type React from "react";
import { useEffect, useRef } from "react";
import { t } from "ttag";
import { DND_IGNORE_CLASS_NAME } from "metabase/common/components/dnd";
import CS from "metabase/css/core/index.css";
import { useSelector } from "metabase/lib/redux";
import { getSetting } from "metabase/selectors/settings";
import { Box, Loader } from "metabase/ui";
import { CommandPlugin } from "./CommandPlugin";
import styles from "./Editor.module.css";
import { MentionPlugin } from "./MentionPlugin";
import { CardEmbed } from "./extensions/CardEmbed";
import { DisableMetabotSidebar } from "./extensions/DisableMetabotSidebar";
import { EmojiExtension } from "./extensions/Emojis";
import { MetabotNode } from "./extensions/MetabotEmbed";
import { SmartLinkEmbed } from "./extensions/SmartLink";
import { Markdown } from "./extensions/markdown/index";
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
import type { CardEmbedRef } from "./types";
interface EditorProps {
onEditorReady?: (editor: TiptapEditor) => void;
onCardEmbedsChange?: (refs: CardEmbedRef[]) => void;
content: string;
onChange?: (content: string) => void;
onQuestionSelect?: (cardId: number | null) => void;
editable?: boolean;
isLoading?: boolean;
}
export const Editor: React.FC<EditorProps> = ({
onEditorReady,
onCardEmbedsChange,
content,
onChange,
editable = true,
onQuestionSelect,
isLoading = false,
}) => {
const siteUrl = useSelector((state) => getSetting(state, "site-url"));
const editor = useEditor(
{
extensions: [
StarterKit,
Image.configure({
inline: false,
HTMLAttributes: {
class: styles.img,
},
}),
SmartLinkEmbed.configure({
HTMLAttributes: {
class: "smart-link",
},
siteUrl,
}),
Link.configure({
HTMLAttributes: {
class: CS.link,
},
}),
Placeholder.configure({
placeholder: t`Start writing, press "/" to insert a chart, or "@" to insert a reference...`,
}),
Markdown,
CardEmbed.configure({
HTMLAttributes: {
class: "card-embed",
},
}),
MetabotNode,
DisableMetabotSidebar,
EmojiExtension,
],
autofocus: false,
immediatelyRender: false,
},
[siteUrl],
);
// Track the previous content to avoid unnecessary updates
const previousContent = useRef<string>("");
// Update editor content when content prop changes
useEffect(() => {
if (editor && !editor.isDestroyed && content !== previousContent.current) {
// Use microtask to defer content update and avoid flushSync warning
queueMicrotask(() => {
if (editor.isDestroyed) {
return;
}
try {
if (!content || content.trim() === "") {
editor.commands.setContent("");
} else {
// Expect JSON AST format
const parsedContent = JSON.parse(content);
editor.commands.setContent(parsedContent);
}
previousContent.current = content;
} catch (error) {
console.error("Failed to parse JSON AST content:", error);
// Set empty document if JSON parsing fails
editor.commands.setContent("");
previousContent.current = content;
}
});
}
}, [editor, content]);
// Notify parent when editor is ready
useEffect(() => {
if (editor && onEditorReady) {
onEditorReady(editor);
}
}, [editor, onEditorReady]);
// Set up change handler
useEffect(() => {
if (!editor || !onChange) {
return;
}
const handleUpdate = () => {
const currentAst = JSON.stringify(editor.state.doc.toJSON());
onChange(currentAst);
};
editor.on("update", handleUpdate);
return () => {
editor.off("update", handleUpdate);
};
}, [editor, onChange]);
// Update editor editable state
useEffect(() => {
if (editor) {
editor.setEditable(editable);
}
}, [editor, editable]);
useCardEmbedsTracking(editor, onCardEmbedsChange);
useQuestionSelection(editor, onQuestionSelect);
if (!editor) {
return null;
}
if (isLoading) {
return (
<Box className={cx(styles.editor, DND_IGNORE_CLASS_NAME)}>
<Loader />
</Box>
);
}
return (
<Box className={cx(styles.editor, DND_IGNORE_CLASS_NAME)}>
<Box
className={styles.editorContent}
onClick={(e) => {
// Focus editor when clicking on empty space
const target = e.target as HTMLElement;
if (
target.classList.contains(styles.editorContent) ||
target.classList.contains("ProseMirror")
) {
const clickY = e.clientY;
const proseMirrorElement = target.querySelector(".ProseMirror");
if (proseMirrorElement) {
const proseMirrorRect =
proseMirrorElement.getBoundingClientRect();
const isClickBelowContent = clickY > proseMirrorRect.bottom;
if (isClickBelowContent) {
// Only move to end if clicking below the actual content
editor.commands.focus("end");
} else {
// Just focus without changing cursor position for clicks in padding areas
editor.commands.focus();
}
} else {
// Fallback: just focus without position change
editor.commands.focus();
}
}
}}
>
<EditorContent editor={editor} />
<MentionPlugin editor={editor} />
<CommandPlugin editor={editor} />
</Box>
</Box>
);
};

View File

@@ -0,0 +1,284 @@
import type { Editor } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
import { t } from "ttag";
import { useListRecentsQuery } from "metabase/api";
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
import Search from "metabase/entities/search";
import { getName } from "metabase/lib/name";
import { useDispatch } from "metabase/lib/redux";
import { RecentsListContent } from "metabase/nav/components/search/RecentsList/RecentsListContent";
import { SearchResults } from "metabase/nav/components/search/SearchResults";
import {
ResultNameSection,
ResultTitle,
SearchResultContainer,
} from "metabase/search/components/SearchResult";
import { IconWrapper } from "metabase/search/components/SearchResult/components/ItemIcon.styled";
import { Box, Group, Icon, Popover } from "metabase/ui";
import type {
RecentItem,
SearchModel,
UnrestrictedLinkEntity,
} from "metabase-types/api";
const MODELS_TO_SEARCH: SearchModel[] = [
"card",
"dataset",
"dashboard",
"collection",
"database",
"table",
];
interface ExtraItemProps {
isSelected?: boolean;
onClick?: () => void;
}
const SearchResultsFooter = ({ isSelected, onClick }: ExtraItemProps) => (
<Box mx="sm" mb="sm" mt={-8}>
<SearchResultContainer
align="center"
isActive
isSelected={isSelected}
onClick={onClick}
>
<IconWrapper active archived={false} type="search">
<Icon name="search" />
</IconWrapper>
<ResultNameSection justify="center" gap="xs">
<Group gap="xs" align="center" wrap="nowrap">
<ResultTitle
role="heading"
data-testid="search-result-item-name"
truncate
>
{t`Browse all`}
</ResultTitle>
</Group>
</ResultNameSection>
</SearchResultContainer>
</Box>
);
interface MentionPluginProps {
editor: Editor;
}
export const MentionPlugin = ({ editor }: MentionPluginProps) => {
const dispatch = useDispatch();
const [showPopover, setShowPopover] = useState(false);
const [modal, setModal] = useState<"question-picker" | null>(null);
const [query, setQuery] = useState("");
const [mentionRange, setMentionRange] = useState<{
from: number;
to: number;
} | null>(null);
const [anchorPos, setAnchorPos] = useState<{ x: number; y: number } | null>(
null,
);
const virtualRef = useRef<HTMLDivElement>(null);
const { data: recents = [], isLoading: isRecentsLoading } =
useListRecentsQuery(undefined, { refetchOnMountOrArgChange: true });
const filteredRecents = recents
.filter((item) => MODELS_TO_SEARCH.includes(item.model as SearchModel))
.slice(0, 4);
useEffect(() => {
if (!editor) {
return;
}
const updateHandler = () => {
const { $from } = editor.state.selection;
if (mentionRange && showPopover) {
// Check if we're still in mention mode
const currentText = editor.state.doc.textBetween(
mentionRange.from,
Math.min(editor.state.doc.content.size, $from.pos),
"",
);
if (currentText.startsWith("@")) {
setQuery(currentText.slice(1));
setMentionRange({
from: mentionRange.from,
to: $from.pos,
});
} else {
setShowPopover(false);
setMentionRange(null);
}
}
const text = $from.nodeBefore?.text || "";
// Check if we're typing after @
if (text.endsWith("@")) {
const from = $from.pos - 1;
setMentionRange({ from, to: $from.pos });
// Get cursor position
const coords = editor.view.coordsAtPos(from);
setAnchorPos({ x: coords.left, y: coords.bottom });
setShowPopover(true);
setQuery("");
}
};
const keydownHandler = (event: KeyboardEvent) => {
if (!showPopover) {
return;
}
// Prevent cursor movement in editor for arrow keys
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
event.preventDefault();
return;
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setShowPopover(false);
setMentionRange(null);
editor.commands.focus();
}
};
editor.on("update", updateHandler);
editor.on("selectionUpdate", updateHandler);
const editorElement = editor.view.dom;
editorElement.addEventListener("keydown", keydownHandler, true);
return () => {
editor.off("update", updateHandler);
editor.off("selectionUpdate", updateHandler);
editorElement.removeEventListener("keydown", keydownHandler, true);
};
}, [editor, mentionRange, showPopover]);
const handleSelect = async (item: UnrestrictedLinkEntity | null) => {
if (!mentionRange) {
return;
}
if (item === null) {
setShowPopover(false);
setMentionRange(null);
editor.commands.focus();
return;
}
const wrappedItem = Search.wrapEntity(item, dispatch);
const insertPosition = mentionRange.from;
editor
.chain()
.focus()
.deleteRange(mentionRange)
.insertContentAt(insertPosition, {
type: "smartLink",
attrs: {
entityId: wrappedItem.id,
model: wrappedItem.model,
},
})
.setTextSelection(insertPosition + 1)
.run();
setShowPopover(false);
setMentionRange(null);
};
const handleRecentSelect = (item: RecentItem) => {
handleSelect({
...item,
description: item.description ?? undefined,
name: getName(item),
});
};
useEffect(() => {
if (virtualRef.current && anchorPos) {
virtualRef.current.style.position = "fixed";
virtualRef.current.style.left = `${anchorPos.x}px`;
virtualRef.current.style.top = `${anchorPos.y}px`;
virtualRef.current.style.width = "1px";
virtualRef.current.style.height = "1px";
}
}, [anchorPos]);
return (
<>
<Popover
opened={showPopover}
position="bottom-start"
width={320}
shadow="md"
withinPortal
closeOnClickOutside={false}
middlewares={{ flip: true, shift: true }}
onClose={() => {
setShowPopover(false);
setMentionRange(null);
}}
>
<Popover.Target>
<div
ref={virtualRef}
style={{ position: "fixed", pointerEvents: "none" }}
/>
</Popover.Target>
<Popover.Dropdown
style={{
maxHeight: 400,
overflow: "auto",
}}
>
{query.length > 0 ? (
<SearchResults
searchText={query}
limit={4}
forceEntitySelect
onEntitySelect={handleSelect}
models={MODELS_TO_SEARCH}
footerComponent={SearchResultsFooter}
onFooterSelect={() => setModal("question-picker")}
showFooterOnNoResults
/>
) : (
<RecentsListContent
isLoading={isRecentsLoading}
results={filteredRecents}
onClick={handleRecentSelect}
headerChildren={[]}
footerChildren={[
Object.assign(SearchResultsFooter, {
onClick: () => setModal("question-picker"),
}),
]}
/>
)}
</Popover.Dropdown>
</Popover>
{modal === "question-picker" && (
<QuestionPickerModal
onChange={async (item) => {
await handleSelect(item);
setModal(null);
}}
onClose={() => setModal(null)}
/>
)}
</>
);
};

View File

@@ -0,0 +1,2 @@
// Class name used to prevent editor typography styles from affecting embedded content
export const EDITOR_STYLE_BOUNDARY_CLASS = "editor-style-boundary";

View File

@@ -0,0 +1,81 @@
.embedContainer {
margin: 1rem 0;
}
.embedWrapper {
display: block;
width: 100%;
}
.cardEmbed {
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-md);
margin: 1rem 0;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
cursor: pointer;
transition: border-color 0.2s ease;
display: flex;
flex-direction: column;
}
.cardEmbed.selected {
border-color: var(--mb-color-brand);
outline: none;
}
.cardEmbed * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.questionHeader {
padding: 0.5rem 1rem;
}
.questionResults {
height: 400px;
position: relative;
}
.loadingContainer {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
height: 100%;
justify-content: center;
}
.errorContainer {
padding: 1rem;
color: var(--mb-color-error);
}
.questionTimestamp {
padding: 0 1rem;
margin: 0 0 1rem;
font-size: 0.75rem;
display: flex;
justify-content: flex-end;
gap: 0.25rem;
}
.titleContainer {
display: flex;
align-items: center;
gap: 0.5rem;
}
.titleEditIcon {
opacity: 0;
transition: opacity 0.2s ease;
}
.titleContainer:hover .titleEditIcon {
opacity: 1;
}

View File

@@ -0,0 +1,551 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import cx from "classnames";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { push } from "react-router-redux";
import { t } from "ttag";
import { skipToken, useGetCardQuery, useGetCardQueryQuery } from "metabase/api";
import {
useGetAdhocQueryMetadataQuery,
useGetAdhocQueryQuery,
} from "metabase/api/dataset";
import { useDispatch } from "metabase/lib/redux";
import { loadMetadataForCard } from "metabase/questions/actions";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Icon, Loader, Menu, Text, TextInput } from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
import Question from "metabase-lib/v1/Question";
import { getUrl } from "metabase-lib/v1/urls";
import type { Card } from "metabase-types/api";
import {
openVizSettingsSidebar,
setShowNavigateBackToDocumentButton,
} from "../../../../documents.slice";
import { useDocumentsSelector } from "../../../../redux-utils";
import { getCardWithDraft } from "../../../../selectors";
import { EDITOR_STYLE_BOUNDARY_CLASS } from "../../constants";
import { formatCardEmbed } from "../markdown/card-embed-format";
import styles from "./CardEmbedNode.module.css";
import { ModifyQuestionModal } from "./ModifyQuestionModal";
import { NativeQueryModal } from "./NativeQueryModal";
export interface CardEmbedAttributes {
id: number;
name?: string;
}
export const CardEmbedNode: Node<{
HTMLAttributes: CardEmbedAttributes;
}> = Node.create({
name: "cardEmbed",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: (element) => {
const id = element.getAttribute("data-id");
if (id) {
return parseInt(id);
}
return null;
},
},
name: {
default: null,
parseHTML: (element) => element.getAttribute("data-name"),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${CardEmbedNode.name}"]`,
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
"div",
mergeAttributes(
HTMLAttributes,
{
"data-type": CardEmbedNode.name,
"data-id": node.attrs.id,
"data-name": node.attrs.name,
},
this.options.HTMLAttributes,
),
formatCardEmbed(node.attrs as CardEmbedAttributes),
];
},
renderText({ node }) {
return formatCardEmbed(node.attrs as CardEmbedAttributes);
},
addNodeView() {
return () => {
const dom = document.createElement("div");
dom.setAttribute("data-type", CardEmbedNode.name);
dom.className = styles.embedContainer;
const content = document.createElement("div");
dom.appendChild(content);
return {
dom,
contentDOM: content,
};
};
},
});
export const CardEmbedComponent = memo(
({ node, updateAttributes, selected, editor, getPos }: NodeViewProps) => {
const { id, name } = node.attrs;
const dispatch = useDispatch();
const canWrite = editor.options.editable;
let embedIndex = -1;
if (editor && getPos) {
const currentPos = getPos() ?? 0;
let nodeCount = 0;
// Count cardEmbed nodes that appear before this position
editor.state.doc.descendants((node, pos) => {
if (node.type.name === "cardEmbed") {
if (pos < currentPos) {
nodeCount++;
} else if (pos === currentPos) {
embedIndex = nodeCount;
return false; // Stop traversing
}
}
});
}
const { data: card, isLoading: isLoadingCard } = useGetCardQuery(
{ id },
{ skip: !id || id < 0 }, // Skip if draft card (negative ID)
);
// Get card with draft if available
const cardWithDraft = useDocumentsSelector((state) =>
getCardWithDraft(state, id, card),
);
// Use the draft card if available, otherwise use the fetched card
const cardToUse = cardWithDraft ?? card;
// Use different endpoints for draft vs regular cards
const { data: regularDataset, isLoading: isLoadingRegularDataset } =
useGetCardQueryQuery(
{ cardId: id },
{ skip: !id || id < 0 || !card }, // Skip for draft cards
);
const { data: draftDataset, isLoading: isLoadingDraftDataset } =
useGetAdhocQueryQuery(
id < 0 && cardToUse?.dataset_query
? {
...cardToUse.dataset_query,
database: cardToUse.database_id ?? null,
parameters: [],
}
: skipToken,
);
// Use appropriate dataset based on card type
const dataset = id < 0 ? draftDataset : regularDataset;
const isLoadingDataset =
id < 0 ? isLoadingDraftDataset : isLoadingRegularDataset;
const rawSeries =
cardToUse && dataset?.data
? [
{
card: cardToUse,
started_at: dataset.started_at,
data: dataset.data,
},
]
: null;
const metadata = useDocumentsSelector(getMetadata);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState(name || "");
const titleInputRef = useRef<HTMLInputElement>(null);
const [isModifyModalOpen, setIsModifyModalOpen] = useState(false);
const displayName = name || cardToUse?.name;
const isNativeQuestion = cardToUse?.dataset_query?.type === "native";
const isLoading = isLoadingCard || isLoadingDataset;
// Only show error if we've tried to load and failed, not if we haven't tried yet
const hasTriedToLoad =
cardToUse !== undefined || isLoadingCard || isLoadingDataset;
const error =
hasTriedToLoad && !isLoading && id && !cardToUse
? "Failed to load question"
: null;
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [isEditingTitle]);
const handleTitleSave = () => {
const trimmedTitle = editedTitle.trim();
if (trimmedTitle && trimmedTitle !== cardToUse?.name) {
updateAttributes({ name: trimmedTitle });
} else {
updateAttributes({ name: null });
setEditedTitle("");
}
setIsEditingTitle(false);
};
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleTitleSave();
} else if (e.key === "Escape") {
setEditedTitle(name || "");
setIsEditingTitle(false);
}
};
// Load metadata for the card
const { data: adhocMetadata } = useGetAdhocQueryMetadataQuery(
id < 0 && cardToUse?.dataset_query ? cardToUse.dataset_query : skipToken,
);
useEffect(() => {
if (cardToUse && id >= 0) {
// For regular cards, use the loadMetadataForCard action
dispatch(loadMetadataForCard(cardToUse));
}
}, [cardToUse, dispatch, adhocMetadata, id]);
const handleEditVisualizationSettings = () => {
if (embedIndex !== -1) {
dispatch(openVizSettingsSidebar({ embedIndex }));
}
};
const handleTitleClick = () => {
if (cardToUse && metadata) {
try {
dispatch(setShowNavigateBackToDocumentButton(true));
const question = new Question(cardToUse, metadata);
const url = getUrl(question, { includeDisplayIsLocked: true });
dispatch(push(url));
} catch (error) {
console.error("Failed to navigate to question:", error);
}
}
};
const handleReplaceQuestion = () => {
// Get the position of this node in the editor
const pos = editor.state.doc.nodeAt(0) ? getPos() : 0;
if (typeof pos === "number") {
editor
.chain()
.focus()
.setTextSelection({ from: pos, to: pos + node.nodeSize })
.deleteSelection()
.insertContent("/")
.run();
}
};
// Handle drill-through navigation
const handleChangeCardAndRun = useCallback(
({
nextCard,
}: {
nextCard: Card;
previousCard?: Card;
objectId?: number;
}) => {
if (!metadata) {
console.warn("Metadata not available for drill-through navigation");
return;
}
try {
dispatch(setShowNavigateBackToDocumentButton(true));
// For drill-through, we need to ensure the card is treated as adhoc
// Remove the ID so getUrl creates an adhoc question URL instead of navigating to saved question
const adhocCard = { ...nextCard, id: null };
const question = new Question(adhocCard, metadata);
const url = getUrl(question, { includeDisplayIsLocked: true });
dispatch(push(url));
} catch (error) {
console.error("Failed to create question URL:", error);
// Fallback: navigate to a new question with the dataset_query
if (nextCard.dataset_query) {
dispatch(setShowNavigateBackToDocumentButton(true));
const params = new URLSearchParams();
params.set("dataset_query", JSON.stringify(nextCard.dataset_query));
dispatch(push(`/question?${params.toString()}`));
}
}
},
[dispatch, metadata],
);
if (isLoadingCard && !cardToUse) {
return (
<NodeViewWrapper className={styles.embedWrapper}>
<Box
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
>
<Box className={styles.questionHeader}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
<Box className={styles.titleContainer}>
<Text size="md" color="text-dark" fw={700}>
{t`Loading question...`}
</Text>
</Box>
</Box>
</Box>
<Box className={styles.questionResults}>
<Box className={styles.loadingContainer}>
<Loader />
</Box>
</Box>
</Box>
</NodeViewWrapper>
);
}
if (error) {
return (
<NodeViewWrapper className={styles.embedWrapper}>
<Box
className={cx(styles.errorContainer, EDITOR_STYLE_BOUNDARY_CLASS)}
>
<Text color="error">{t`Failed to load question`}</Text>
</Box>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper className={styles.embedWrapper}>
<Box
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
>
{cardToUse && (
<Box className={styles.questionHeader}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
}}
>
{isEditingTitle ? (
<TextInput
ref={titleInputRef}
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
onBlur={handleTitleSave}
onKeyDown={handleTitleKeyDown}
size="md"
style={{ flex: 1 }}
styles={{
input: {
fontWeight: 700,
fontSize: "1rem",
border: "1px solid transparent",
padding: 0,
height: "auto",
minHeight: "auto",
lineHeight: 1.55,
backgroundColor: "transparent",
"&:focus": {
border: "1px solid var(--mb-color-border)",
backgroundColor: "var(--mb-color-bg-white)",
padding: "0 0.25rem",
},
},
}}
/>
) : (
<Box className={styles.titleContainer}>
<Text
size="md"
color="text-dark"
fw={700}
onClick={handleTitleClick}
style={{ cursor: "pointer" }}
>
{displayName}
</Text>
{canWrite && (
<Icon
name="pencil"
size={14}
color="var(--mb-color-text-medium)"
className={styles.titleEditIcon}
style={{ cursor: "pointer" }}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setEditedTitle(displayName);
setIsEditingTitle(true);
}}
/>
)}
</Box>
)}
{!isEditingTitle && (
<Menu withinPortal position="bottom-end">
<Menu.Target>
<Box
component="button"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "0.25rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "4px",
}}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Icon
name="ellipsis"
size={16}
color="var(--mb-color-text-medium)"
/>
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleEditVisualizationSettings}
disabled={!canWrite}
leftSection={<Icon name="palette" size={14} />}
>
{t`Edit Visualization`}
</Menu.Item>
<Menu.Item
onClick={() => setIsModifyModalOpen(true)}
disabled={!canWrite}
leftSection={
<Icon
name={isNativeQuestion ? "sql" : "notebook"}
size={14}
/>
}
>
{t`Edit Query`}
</Menu.Item>
<Menu.Item
onClick={handleReplaceQuestion}
disabled={!canWrite}
leftSection={<Icon name="refresh" size={14} />}
>
{t`Replace question`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Box>
</Box>
)}
{rawSeries ? (
<>
<Box className={styles.questionResults}>
<Visualization
rawSeries={rawSeries}
metadata={metadata}
onChangeCardAndRun={handleChangeCardAndRun}
getExtraDataForClick={() => ({})}
isEditing={false}
isDashboard={false}
showTitle={false}
/>
</Box>
</>
) : (
<Box className={styles.questionResults}>
<ChartSkeleton display={cardToUse?.display || "table"} />
</Box>
)}
</Box>
{isModifyModalOpen &&
cardToUse &&
(isNativeQuestion ? (
<NativeQueryModal
card={cardToUse}
isOpen={isModifyModalOpen}
onClose={() => setIsModifyModalOpen(false)}
editor={editor}
initialDataset={dataset}
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
/>
) : (
<ModifyQuestionModal
card={cardToUse}
isOpen={isModifyModalOpen}
onClose={() => setIsModifyModalOpen(false)}
editor={editor}
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
/>
))}
</NodeViewWrapper>
);
},
(prevProps, nextProps) => {
return (
prevProps.node.attrs.id === nextProps.node.attrs.id &&
prevProps.node.attrs.name === nextProps.node.attrs.name &&
prevProps.selected === nextProps.selected
);
},
);
CardEmbedComponent.displayName = "CardEmbedComponent";

View File

@@ -0,0 +1,222 @@
import type { Editor } from "@tiptap/react";
import { useMemo, useState } from "react";
import { useAsync } from "react-use";
import { t } from "ttag";
import _ from "underscore";
import { skipToken } from "metabase/api";
import { useGetAdhocQueryMetadataQuery } from "metabase/api/dataset";
import { useDispatch, useStore } from "metabase/lib/redux";
import { Notebook } from "metabase/querying/notebook/components/Notebook";
import { loadMetadataForCard } from "metabase/questions/actions";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Button, Loader, Modal, Text } from "metabase/ui";
import * as Lib from "metabase-lib";
import Question from "metabase-lib/v1/Question";
import type { Card } from "metabase-types/api";
import {
createDraftCard,
generateDraftCardId,
} from "../../../../documents.slice";
import {
useDocumentsDispatch,
useDocumentsSelector,
} from "../../../../redux-utils";
interface ModifyQuestionModalProps {
card: Card;
isOpen: boolean;
onClose: () => void;
onSave: (result: { card_id: number; name: string }) => void;
editor?: Editor;
}
export const ModifyQuestionModal = ({
card,
isOpen,
onClose,
onSave,
editor,
}: ModifyQuestionModalProps) => {
const store = useStore();
const dispatch = useDispatch();
const documentsDispatch = useDocumentsDispatch();
const metadata = useDocumentsSelector(getMetadata);
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
null,
);
const isDraftCard = card.id < 0;
const { data: adhocMetadata, isLoading: isAdhocMetadataLoading } =
useGetAdhocQueryMetadataQuery(
isDraftCard && card.dataset_query ? card.dataset_query : skipToken,
{ skip: !isDraftCard || !card.dataset_query },
);
const metadataState = useAsync(async () => {
if (isOpen && card && !isDraftCard) {
await dispatch(loadMetadataForCard(card));
}
}, [isOpen, card, dispatch, isDraftCard]);
const question = useMemo(() => {
const isMetadataLoading = isDraftCard
? isAdhocMetadataLoading
: metadataState.loading;
const hasMetadataError = isDraftCard ? false : metadataState.error;
const hasMetadata = isDraftCard ? !!adhocMetadata : !!metadata;
if (
isMetadataLoading ||
hasMetadataError ||
!card ||
!hasMetadata ||
!isOpen
) {
return null;
}
const baseQuestion = new Question(card, metadata);
if (!modifiedQuestion) {
setModifiedQuestion(baseQuestion);
}
return baseQuestion;
}, [
isDraftCard,
isAdhocMetadataLoading,
metadataState.loading,
metadataState.error,
adhocMetadata,
card,
metadata,
isOpen,
modifiedQuestion,
]);
const handleUpdateQuestion = async (newQuestion: Question) => {
const currentDependencies = modifiedQuestion
? Lib.dependentMetadata(
modifiedQuestion.query(),
modifiedQuestion.id(),
modifiedQuestion.type(),
)
: [];
const nextDependencies = Lib.dependentMetadata(
newQuestion.query(),
newQuestion.id(),
newQuestion.type(),
);
if (!_.isEqual(currentDependencies, nextDependencies)) {
if (!isDraftCard) {
await dispatch(loadMetadataForCard(newQuestion.card()));
const freshMetadata = getMetadata(store.getState());
const questionWithFreshMetadata = new Question(
newQuestion.card(),
freshMetadata,
);
setModifiedQuestion(questionWithFreshMetadata);
} else {
setModifiedQuestion(newQuestion);
}
} else {
setModifiedQuestion(newQuestion);
}
};
const handleSave = async () => {
if (!modifiedQuestion || !editor) {
return;
}
try {
const modifiedData = {
dataset_query: modifiedQuestion.datasetQuery(),
display: modifiedQuestion.display(),
visualization_settings:
modifiedQuestion.card().visualization_settings ?? {},
};
const newCardId = generateDraftCardId();
documentsDispatch(
createDraftCard({
originalCard: card,
modifiedData,
draftId: newCardId,
}),
);
onSave({ card_id: newCardId, name: card.name });
onClose();
} catch (error) {
console.error("Failed to save modified question:", error);
}
};
return (
<Modal
opened={isOpen}
onClose={onClose}
size="80%"
title={t`Modify question`}
padding="lg"
>
{(isDraftCard ? isAdhocMetadataLoading : metadataState.loading) ? (
<Box
style={{
height: "70vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Loader size="lg" />
</Box>
) : question && modifiedQuestion ? (
<>
<Box style={{ height: "70vh", overflow: "auto" }}>
<Notebook
question={modifiedQuestion}
isDirty={true}
isRunnable={true}
isResultDirty={true}
reportTimezone="UTC"
hasVisualizeButton={false}
updateQuestion={handleUpdateQuestion}
runQuestionQuery={async () => {}}
/>
</Box>
<Box
mt="lg"
style={{
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
}}
>
<Button variant="subtle" onClick={onClose}>
{t`Cancel`}
</Button>
<Button variant="filled" onClick={handleSave}>
{t`Save and use`}
</Button>
</Box>
</>
) : (
<Box
style={{
height: "70vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text>{t`Failed to load question data`}</Text>
</Box>
)}
</Modal>
);
};

View File

@@ -0,0 +1,666 @@
import type { Editor } from "@tiptap/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAsync } from "react-use";
import { c, t } from "ttag";
import EmptyCodeResult from "assets/img/empty-states/code.svg";
import { skipToken } from "metabase/api";
import {
datasetApi,
useGetAdhocQueryMetadataQuery,
} from "metabase/api/dataset";
import { ErrorMessage } from "metabase/common/components/ErrorMessage";
import { isMac } from "metabase/lib/browser";
import { useDispatch } from "metabase/lib/redux";
import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
import DataReference from "metabase/query_builder/components/dataref/DataReference";
import { TagEditorSidebar } from "metabase/query_builder/components/template_tags/TagEditorSidebar";
import { loadMetadataForCard } from "metabase/questions/actions";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Button, Flex, Loader, Modal, Stack, Text } from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
import NoResultsView from "metabase/visualizations/components/Visualization/NoResultsView/NoResultsView";
import Question from "metabase-lib/v1/Question";
import type NativeQuery from "metabase-lib/v1/queries/NativeQuery";
import type {
Card,
DatabaseId,
Dataset,
DatasetQuery,
} from "metabase-types/api";
import {
createDraftCard,
generateDraftCardId,
} from "../../../../documents.slice";
import {
useDocumentsDispatch,
useDocumentsSelector,
} from "../../../../redux-utils";
type DataReferenceStackItem = {
type: string;
item: unknown;
};
interface NativeQueryModalProps {
card: Card;
isOpen: boolean;
onClose: () => void;
onSave: (result: { card_id: number; name: string }) => void;
editor?: Editor;
initialDataset?: Dataset;
}
const EDITOR_HEIGHT_RATIO = 0.4;
const EDITOR_MIN_HEIGHT = 200;
const EDITOR_HEIGHT_OFFSET = 80;
const MODAL_SIDEBAR_FEATURES = {
dataReference: true,
variables: false,
snippets: false,
promptInput: false,
formatQuery: false,
aiGeneration: false,
} as const;
const getRunQueryShortcut = () => (isMac() ? t`⌘ + return` : t`Ctrl + enter`);
const isFailedDataset = (dataset: Dataset | null | undefined): boolean => {
return !!(dataset?.error || dataset?.status === "failed");
};
const hasEmptyResults = (dataset: Dataset | null | undefined): boolean => {
return (
!isFailedDataset(dataset) &&
!!dataset?.data &&
Array.isArray(dataset.data.rows) &&
dataset.data.rows.length === 0
);
};
const getErrorMessage = (sqlError: any, queryError: unknown): string => {
if (sqlError?.error) {
return sqlError.error;
}
if (typeof queryError === "object" && queryError && "message" in queryError) {
return String(queryError.message);
}
return t`An error occurred while running your query. Please check your query and try again.`;
};
const QueryExecutionEmptyState = ({
hasInitialData,
}: {
hasInitialData: boolean;
}) => {
if (hasInitialData) {
return <NoResultsView />;
}
const keyboardShortcut = getRunQueryShortcut();
return (
<Flex w="100%" h="100%" align="center" justify="center">
<Stack maw="25rem" gap={0} ta="center" align="center">
<Box maw="3rem" mb="0.75rem">
<img src={EmptyCodeResult} alt="Code prompt icon" />
</Box>
<Text c="text-medium">
{c("{0} refers to the keyboard shortcut")
.jt`To run your code, click on the Run button or type ${(
<b key="shortcut">({keyboardShortcut})</b>
)}`}
</Text>
<Text c="text-medium">{t`Here's where your results will appear`}</Text>
</Stack>
</Flex>
);
};
const LoadingModal = ({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) => (
<Modal opened={isOpen} onClose={onClose} size="95%" title={t`Edit SQL Query`}>
<Box
style={{
height: "400px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{children}
</Box>
</Modal>
);
export const NativeQueryModal = ({
card,
isOpen,
onClose,
onSave,
editor,
initialDataset,
}: NativeQueryModalProps) => {
const dispatch = useDispatch();
const documentsDispatch = useDocumentsDispatch();
const metadata = useDocumentsSelector(getMetadata);
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
null,
);
const [currentQuery, setCurrentQuery] = useState<DatasetQuery | null>(null);
const [hasExecutedQuery, setHasExecutedQuery] = useState(false);
const [isShowingTemplateTagsEditor, setIsShowingTemplateTagsEditor] =
useState(false);
const [isShowingDataReference, setIsShowingDataReference] = useState(false);
const [dataReferenceStack, setDataReferenceStack] = useState<
DataReferenceStackItem[]
>([]);
const {
data: queryResult,
isLoading: isQueryRunning,
error: queryError,
refetch: refetchQuery,
} = datasetApi.useGetAdhocQueryQuery(currentQuery || skipToken, {
skip: !currentQuery,
});
const datasetToUse =
queryResult || (!hasExecutedQuery ? initialDataset : null);
const sqlError = isFailedDataset(datasetToUse) ? datasetToUse : null;
const isDraftCard = card.id < 0;
const { data: adhocMetadata, isLoading: isAdhocMetadataLoading } =
useGetAdhocQueryMetadataQuery(
isDraftCard && card.dataset_query ? card.dataset_query : skipToken,
{ skip: !isDraftCard || !card.dataset_query },
);
const metadataState = useAsync(async () => {
if (isOpen && card && !isDraftCard) {
await dispatch(loadMetadataForCard(card));
}
}, [isOpen, card, dispatch, isDraftCard]);
// Question initialization
const question = useMemo(() => {
const isMetadataLoading = isDraftCard
? isAdhocMetadataLoading
: metadataState.loading;
const hasMetadataError = isDraftCard ? false : metadataState.error;
const hasMetadata = isDraftCard ? !!adhocMetadata : !!metadata;
if (
isMetadataLoading ||
hasMetadataError ||
!card ||
!hasMetadata ||
!isOpen
) {
return null;
}
const baseQuestion = new Question(card, metadata);
if (!modifiedQuestion) {
setModifiedQuestion(baseQuestion);
}
return baseQuestion;
}, [
isDraftCard,
isAdhocMetadataLoading,
metadataState.loading,
metadataState.error,
adhocMetadata,
card,
metadata,
isOpen,
modifiedQuestion,
]);
// Reset query execution state when modal opens
useEffect(() => {
if (isOpen) {
setCurrentQuery(null);
setHasExecutedQuery(false);
}
}, [isOpen]);
// Dataset query handler following QB patterns
const setDatasetQuery = useCallback(
async (query: NativeQuery | DatasetQuery) => {
if (!modifiedQuestion) {
return modifiedQuestion;
}
try {
let datasetQuery: DatasetQuery;
const isNativeQueryObject =
"queryText" in query && typeof query.queryText === "function";
if (isNativeQueryObject) {
// Handle NativeQuery object
const nativeQuery = query as NativeQuery;
datasetQuery = {
type: "native",
native: {
query: nativeQuery.queryText() || "",
"template-tags": nativeQuery.templateTagsMap() || {},
},
database: modifiedQuestion.databaseId(),
} as DatasetQuery;
} else {
// Handle dataset_query object (including database changes)
datasetQuery = query as DatasetQuery;
}
const newQuestion = modifiedQuestion.setDatasetQuery(datasetQuery);
setModifiedQuestion(newQuestion);
return newQuestion;
} catch (error) {
console.error("Failed to update dataset query:", error);
return modifiedQuestion;
}
},
[modifiedQuestion],
);
// Query execution handler
const handleRunQuery = useCallback(() => {
if (!modifiedQuestion) {
return;
}
const datasetQuery = modifiedQuestion.datasetQuery();
// Check if query has changed from what we currently have
const queryChanged =
!currentQuery ||
JSON.stringify(currentQuery) !== JSON.stringify(datasetQuery);
if (queryChanged) {
// Query changed, set new query which will trigger RTK Query
setCurrentQuery(datasetQuery);
} else {
// Same query, use refetch for proper loading state
refetchQuery();
}
setHasExecutedQuery(true);
}, [modifiedQuestion, currentQuery, refetchQuery]);
// Save handler
const handleSave = async () => {
if (!modifiedQuestion || !editor) {
return;
}
try {
const modifiedData = {
dataset_query: modifiedQuestion.datasetQuery(),
display: modifiedQuestion.display(),
visualization_settings:
modifiedQuestion.card().visualization_settings ?? {},
};
const newCardId = generateDraftCardId();
documentsDispatch(
createDraftCard({
originalCard: card,
modifiedData,
draftId: newCardId,
}),
);
onSave({ card_id: newCardId, name: card.name });
onClose();
} catch (error) {
console.error("Failed to save modified question:", error);
}
};
// Transform query results to visualization series following QB patterns
const rawSeries = useMemo(() => {
if (!modifiedQuestion || !datasetToUse || sqlError) {
return null;
}
try {
const data = datasetToUse.data || datasetToUse;
return [
{
card: {
...modifiedQuestion.card(),
display: "table" as const, // Force table display for results
visualization_settings: {
...modifiedQuestion.card().visualization_settings,
"table.pivot": false,
"table.columns": undefined,
},
},
data: {
rows: data.rows || [],
cols: data.cols || [],
results_metadata: data.results_metadata || [],
insights: data.insights,
rows_truncated: data.rows_truncated || 0,
},
json_query: datasetToUse.json_query,
started_at: datasetToUse.started_at || new Date().toISOString(),
running_time: datasetToUse.running_time,
row_count: datasetToUse.row_count,
status: "completed",
},
];
} catch (error) {
console.error("Error formatting series data:", error);
return null;
}
}, [modifiedQuestion, datasetToUse, sqlError]);
const modalStyles = {
content: {
height: "90vh",
maxHeight: "90vh",
display: "flex",
flexDirection: "column" as const,
},
body: {
flex: 1,
display: "flex",
flexDirection: "column" as const,
padding: 0,
minHeight: 0,
overflow: "hidden" as const,
},
};
const editorContainerStyle = {
flexShrink: 0,
borderBottom: "1px solid var(--mb-color-border)",
position: "relative" as const,
};
if (isDraftCard ? isAdhocMetadataLoading : metadataState.loading) {
return (
<LoadingModal isOpen={isOpen} onClose={onClose}>
<Loader size="lg" />
</LoadingModal>
);
}
if (!question || !modifiedQuestion) {
return (
<LoadingModal isOpen={isOpen} onClose={onClose}>
<Text>{t`Failed to load question data`}</Text>
</LoadingModal>
);
}
return (
<Modal
opened={isOpen}
onClose={onClose}
size="95%"
title={t`Edit SQL Query`}
padding="lg"
styles={modalStyles}
>
<Box style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* Main content area with horizontal layout */}
<Box
style={{
flex: 1,
display: "flex",
flexDirection: "row",
minHeight: 0,
}}
>
{/* Left side - Editor and Results */}
<Box
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
>
<Box style={editorContainerStyle}>
{/* NativeQueryEditor with minimal configuration for modal context */}
{(modifiedQuestion?.legacyNativeQuery() ||
question?.legacyNativeQuery()) && (
<NativeQueryEditor
question={modifiedQuestion}
query={
modifiedQuestion?.legacyNativeQuery() ||
question?.legacyNativeQuery() ||
({} as any)
}
isNativeEditorOpen
isInitiallyOpen
hasTopBar
hasEditingSidebar
hasParametersList={false}
sidebarFeatures={MODAL_SIDEBAR_FEATURES}
viewHeight={400}
isRunnable
isRunning={isQueryRunning}
isResultDirty={false}
isShowingDataReference={isShowingDataReference}
isShowingTemplateTagsEditor={isShowingTemplateTagsEditor}
isShowingSnippetSidebar={false}
setDatasetQuery={setDatasetQuery}
runQuestionQuery={handleRunQuery}
runQuery={handleRunQuery}
toggleTemplateTagsEditor={() =>
setIsShowingTemplateTagsEditor(!isShowingTemplateTagsEditor)
}
toggleDataReference={() => {
if (!isShowingDataReference && modifiedQuestion) {
const databaseId = modifiedQuestion.databaseId();
if (databaseId) {
setDataReferenceStack([
{ type: "database", item: { id: databaseId } },
]);
}
}
setIsShowingDataReference(!isShowingDataReference);
}}
toggleSnippetSidebar={() => {}}
setNativeEditorSelectedRange={() => {}}
openDataReferenceAtQuestion={() => {}}
openSnippetModalWithSelectedText={() => {}}
insertSnippet={() => {}}
setParameterValue={() => {}}
onOpenModal={() => {}}
cancelQuery={() => {}}
closeSnippetModal={() => {}}
handleResize={() => {}}
canChangeDatabase
toggleEditor={() => {}}
onSetDatabaseId={(databaseId: DatabaseId) => {
if (modifiedQuestion) {
const updatedDatasetQuery = {
...modifiedQuestion.datasetQuery(),
database: databaseId,
};
const newQuestion =
modifiedQuestion.setDatasetQuery(updatedDatasetQuery);
setModifiedQuestion(newQuestion);
}
}}
resizable
resizableBoxProps={{
height: Math.max(
EDITOR_MIN_HEIGHT,
Math.floor(
window.innerHeight * EDITOR_HEIGHT_RATIO -
EDITOR_HEIGHT_OFFSET,
),
),
style: { border: "none" },
}}
/>
)}
</Box>
{/* Results area */}
<Box
id="results-container"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
paddingTop: "0.25rem",
overflow: "hidden",
position: "relative",
}}
>
{!isQueryRunning && (sqlError || queryError) ? (
<ErrorMessage
type="serverError"
title={t`Query execution failed`}
message={getErrorMessage(sqlError, queryError)}
action={null}
/>
) : !isQueryRunning && hasEmptyResults(datasetToUse) ? (
<QueryExecutionEmptyState hasInitialData={!!datasetToUse} />
) : !isQueryRunning && rawSeries ? (
<Box style={{ flex: 1, minHeight: "300px" }}>
<Visualization
rawSeries={rawSeries}
metadata={metadata}
onChangeCardAndRun={() => {}}
getExtraDataForClick={() => ({})}
isEditing={false}
isDashboard={false}
showTitle={false}
/>
</Box>
) : !isQueryRunning ? (
<QueryExecutionEmptyState hasInitialData={false} />
) : !datasetToUse ? (
<Box
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Loader size="lg" />
</Box>
) : null}
{/* Loading overlay for when rerunning queries with existing results */}
{isQueryRunning && datasetToUse && (
<Box
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "var(--mb-color-bg-white)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 10,
}}
>
<Loader size="lg" />
</Box>
)}
</Box>
</Box>
{/* Right side - Sidebars */}
{/* Template tags sidebar */}
{isShowingTemplateTagsEditor && modifiedQuestion && (
<Box
style={{
width: "300px",
flexShrink: 0,
backgroundColor: "var(--mb-color-bg-white)",
borderLeft: "1px solid var(--mb-color-border)",
overflow: "auto",
}}
>
<TagEditorSidebar
question={modifiedQuestion}
query={modifiedQuestion.legacyNativeQuery()!}
onClose={() => setIsShowingTemplateTagsEditor(false)}
setDatasetQuery={setDatasetQuery}
/>
</Box>
)}
{/* Data reference sidebar */}
{isShowingDataReference && modifiedQuestion && (
<Box
style={{
width: "350px",
flexShrink: 0,
backgroundColor: "var(--mb-color-bg-white)",
borderLeft: "1px solid var(--mb-color-border)",
overflow: "auto",
}}
>
<DataReference
dataReferenceStack={dataReferenceStack}
onClose={() => setIsShowingDataReference(false)}
popDataReferenceStack={() => {
if (dataReferenceStack.length === 1) {
setIsShowingDataReference(false);
} else {
setDataReferenceStack(dataReferenceStack.slice(0, -1));
}
}}
pushDataReferenceStack={(item: DataReferenceStackItem) => {
setDataReferenceStack([...dataReferenceStack, item]);
}}
/>
</Box>
)}
</Box>
{/* Footer with actions */}
<Box
style={{
position: "sticky",
bottom: 0,
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
borderTop: "1px solid var(--mb-color-border)",
padding: "1rem",
flexShrink: 0,
backgroundColor: "var(--mb-color-bg-white)",
zIndex: 10,
}}
>
<Button variant="subtle" onClick={onClose}>
{t`Cancel`}
</Button>
<Button
variant="filled"
onClick={handleSave}
disabled={!modifiedQuestion}
>
{t`Save and use`}
</Button>
</Box>
</Box>
</Modal>
);
};

View File

@@ -0,0 +1,11 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { CardEmbedComponent, CardEmbedNode } from "./CardEmbedNode";
export * from "./CardEmbedNode";
export const CardEmbed = CardEmbedNode.extend({
addNodeView() {
return ReactNodeViewRenderer(CardEmbedComponent);
},
});

View File

@@ -0,0 +1,23 @@
.Column {
padding: 1rem;
margin-left: 0.5rem;
margin-right: 0.5rem;
flex: 1 1 0;
min-height: 5rem;
background-color: var(--mb-color-bg-light);
border-radius: 1rem;
position: relative;
&:hover {
background-color: var(--mb-color-bg-medium);
}
}
.ColumnBlock {
padding: 0.5rem;
display: flex;
& > [data-node-view-content-react] {
display: contents;
}
}

View File

@@ -0,0 +1,120 @@
import {
Extension,
InputRule,
Node,
type NodeViewProps,
mergeAttributes,
} from "@tiptap/core";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import { Icon } from "metabase/ui";
import Styles from "./Columns.module.css";
const ColumnBlock = Node.create({
name: "columnBlock",
group: "block",
content: "column{1,}",
isolating: true,
selectable: true,
renderHTML({ HTMLAttributes }) {
const attrs = mergeAttributes(HTMLAttributes);
return ["div", attrs, 0];
},
addInputRules() {
return [
new InputRule({
find: /::columns/g,
handler: ({ state, range }) => {
state.tr.delete(range.from, range.to);
const columnBlockNode = state.schema.nodes.columnBlock.create({}, [
state.schema.nodes.column.create({}, [
state.schema.nodes.paragraph.create(),
]),
state.schema.nodes.column.create({}, [
state.schema.nodes.paragraph.create(),
]),
state.schema.nodes.column.create({}, [
state.schema.nodes.paragraph.create(),
]),
]);
state.tr.insert(range.from, columnBlockNode);
},
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(ColumnBlockView);
},
});
const ColumnBlockView = () => {
return (
<NodeViewWrapper>
<NodeViewContent className={Styles.ColumnBlock} />
</NodeViewWrapper>
);
};
const Column = Node.create({
name: "column",
group: "column",
content: "(block)*",
isolating: true,
selectable: true,
renderHTML({ HTMLAttributes }) {
const attrs = mergeAttributes(HTMLAttributes);
return ["div", attrs, 0];
},
addNodeView() {
return ReactNodeViewRenderer(ColumnView, {
className: Styles.Column,
});
},
});
const ColumnView = ({ deleteNode }: NodeViewProps) => {
return (
<NodeViewWrapper>
<Icon
name="close"
pos="absolute"
top="6"
right="6"
cursor="pointer"
onClick={deleteNode}
/>
<NodeViewContent />
</NodeViewWrapper>
);
};
export const ColumnExtension = Extension.create({
name: "columnExtension",
addExtensions() {
const extensions = [];
if (this.options.column !== false) {
extensions.push(Column);
}
if (this.options.columnBlock !== false) {
extensions.push(ColumnBlock);
}
return extensions;
},
});

View File

@@ -0,0 +1,20 @@
import { Extension } from "@tiptap/core";
import { Plugin } from "prosemirror-state";
export const DisableMetabotSidebar = Extension.create({
name: "take-back-mod-b",
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handleKeyDown: (view, event) => {
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
event.stopPropagation();
}
},
},
}),
];
},
});

View File

@@ -0,0 +1,86 @@
import { type VirtualElement, computePosition } from "@floating-ui/dom";
import Emoji, { gitHubEmojis } from "@tiptap/extension-emoji";
import { ReactRenderer } from "@tiptap/react";
import { EmojiList } from "./EmojiList";
export const EmojiExtension = Emoji.configure({
emojis: gitHubEmojis,
enableEmoticons: true,
suggestion: {
items: ({ editor, query }) => {
return editor.storage.emoji.emojis
.filter(({ shortcodes, tags }) => {
return (
shortcodes.find((shortcode) =>
shortcode.startsWith(query.toLowerCase()),
) || tags.find((tag) => tag.startsWith(query.toLowerCase()))
);
})
.slice(0, 5);
},
allowSpaces: false,
render: () => {
let component: ReactRenderer | undefined;
function repositionComponent(clientRect: DOMRect | null) {
if (!component || !component.element) {
return;
}
const virtualElement: VirtualElement = {
getBoundingClientRect() {
return clientRect;
},
};
computePosition(virtualElement, component.element as HTMLElement, {
placement: "bottom-start",
}).then((pos) => {
Object.assign((component?.element as HTMLElement).style, {
left: `${pos.x}px`,
top: `${pos.y}px`,
position: pos.strategy === "fixed" ? "fixed" : "absolute",
});
});
}
return {
onStart: (props) => {
component = new ReactRenderer(EmojiList, {
props,
editor: props.editor,
});
document.body.appendChild(component.element);
repositionComponent(props.clientRect());
},
onUpdate(props) {
component.updateProps(props);
repositionComponent(props.clientRect());
},
onKeyDown(props) {
if (props.event.key === "Escape") {
document.body.removeChild(component.element);
component.destroy();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
if (document.body.contains(component.element)) {
document.body.removeChild(component.element);
}
component.destroy();
},
};
},
},
});

View File

@@ -0,0 +1,17 @@
.buttonRoot {
&:hover {
background-color: var(--mb-color-brand-lighter) !important;
}
&:hover.isSelected,
&.isSelected {
background-color: var(--mb-color-brand-light) !important;
}
color: var(--mb-color-text-medium) !important;
img {
height: 1rem;
width: 1rem;
}
}

View File

@@ -0,0 +1,90 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { Button, Card } from "metabase/ui";
import Styles from "./EmojiList.module.css";
export const EmojiList = forwardRef(function InnerEmojiList(
{ items, command },
ref,
) {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(
(index) => {
const item = items[index];
if (item) {
command({ name: item.name });
}
},
[items, command],
);
const upHandler = useCallback(() => {
setSelectedIndex((index) => (index + items.length - 1) % items.length);
}, [setSelectedIndex, items]);
const downHandler = useCallback(() => {
setSelectedIndex((index) => (index + 1) % items.length);
}, [setSelectedIndex, items]);
const enterHandler = useCallback(() => {
selectItem(selectedIndex);
}, [selectItem, selectedIndex]);
useEffect(() => setSelectedIndex(0), [items]);
useImperativeHandle(ref, () => {
return {
onKeyDown: (x) => {
if (x.event.key === "ArrowUp") {
upHandler();
return true;
}
if (x.event.key === "ArrowDown") {
downHandler();
return true;
}
if (x.event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
};
}, [upHandler, downHandler, enterHandler]);
return (
<Card withBorder p="sm">
{items.map((item, index) => (
<Button
className={cx({ [Styles.isSelected]: index === selectedIndex })}
classNames={{
root: Styles.buttonRoot,
}}
justify="start"
key={index}
onClick={() => selectItem(index)}
variant="subtle"
leftSection={
item.fallbackImage ? <img src={item.fallbackImage} /> : item.emoji
}
>
{`:${item.name}:`}
</Button>
))}
</Card>
);
});

View File

@@ -0,0 +1 @@
export * from "./EmojiExtension";

View File

@@ -0,0 +1,15 @@
.codeBlockTextArea {
background-color: var(--mb-color-bg-light);
color: var(--mb-color-text-primary);
width: 100%;
padding: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-lg);
border: none;
outline: none;
resize: none;
&:disabled {
opacity: 0.5;
cursor: progress;
}
}

View File

@@ -0,0 +1,251 @@
import { Node, mergeAttributes } from "@tiptap/core";
import {
type NodeViewProps,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { t } from "ttag";
import CS from "metabase/css/core/index.css";
import { Button, Flex, Icon, Text, Tooltip } from "metabase/ui";
import MetabotThinkingStyles from "metabase-enterprise/metabot/components/MetabotChat/MetabotThinking.module.css";
import { useMetabotDocumentQuery } from "../../hooks/useMetabotDocumentQuery";
import Styles from "./MetabotEmbed.module.css";
const createTextNode = (str: string) => ({
type: "paragraph",
content: [{ type: "text", text: str }],
});
export const MetabotNode = Node.create<{
HTMLAttributes: Record<string, unknown>;
}>({
name: "metabot",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
text: {
default: null,
parseHTML: (element) => element.getAttribute("data-text"),
},
};
},
parseHTML() {
return [
{
tag: 'span[data-type="metabot"]',
},
];
},
addNodeView() {
return ReactNodeViewRenderer(MetabotComponent);
},
renderHTML({ node, HTMLAttributes }) {
return [
"div",
mergeAttributes(
HTMLAttributes,
{
"data-type": "metabot",
"data-text": node.attrs.text,
},
this.options.HTMLAttributes,
),
`{{ metabot:${node.attrs.text} }}`,
];
},
renderText({ node }) {
return `{{ metabot:${node.attrs.text} }}`;
},
});
export const MetabotComponent = memo(
({ editor, getPos, deleteNode, updateAttributes, node }: NodeViewProps) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const controllerRef = useRef<AbortController | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const queryMetabot = useMetabotDocumentQuery();
const { text: prompt } = node.attrs;
const handleRunMetabot = useCallback(async () => {
const controller = new AbortController();
controllerRef.current = controller;
setIsLoading(true);
setErrorText("");
editor.commands.focus();
const { cardId, description, error } = await queryMetabot({
prompt: prompt.trim(),
signal: controller.signal,
});
if (controller.signal.aborted) {
return;
}
setIsLoading(false);
if (error) {
setErrorText(error);
return;
}
const nodePosition = getPos();
if (nodePosition == null) {
setErrorText(t`Could not find Metabot block`);
return;
}
editor
.chain()
.insertContentAt(nodePosition, {
type: "cardEmbed",
attrs: {
id: cardId,
},
})
.insertContentAt(
nodePosition + 1,
createTextNode(`🤖 Created with Metabot 💙`),
)
.insertContentAt(nodePosition + 1, createTextNode(description))
.run();
deleteNode();
}, [prompt, editor, queryMetabot, deleteNode, getPos]);
const handleStopMetabot = () => {
controllerRef.current?.abort();
setIsLoading(false);
setErrorText("");
};
useEffect(() => {
// Grab focus from TipTap editor and put it in textarea when component mounts
if (inputRef.current) {
setTimeout(() => {
inputRef.current?.focus();
const unfocus = (e: KeyboardEvent) => {
if (e.key === "Tab") {
inputRef.current?.blur();
editor.commands.focus();
}
};
inputRef.current?.addEventListener("keydown", unfocus);
return () =>
inputRef.current?.removeEventListener("keydown", unfocus);
}, 50); // Small delay to ensure focus is set after rendering
}
}, [inputRef, editor.commands]);
useEffect(() => {
const currentInput = inputRef.current;
if (currentInput) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && e.metaKey) {
e.preventDefault();
handleRunMetabot();
}
};
currentInput.addEventListener("keydown", handleKeyDown);
return () => {
currentInput.removeEventListener("keydown", handleKeyDown);
};
}
}, [handleRunMetabot]);
return (
<NodeViewWrapper as="span">
<Flex
bg="bg-light"
bd="1px solid var(--border-color)"
style={{ borderRadius: "4px" }}
pos="relative"
h={180}
direction="column"
>
<Button
variant="subtle"
pos="absolute"
top={0}
right={0}
p="sm"
m="sm"
size="sm"
opacity="0.5"
onClick={() => deleteNode()}
>
<Icon name="close" />
</Button>
<Flex flex={1} direction="column" style={{ overflow: "auto" }}>
<textarea
disabled={isLoading}
ref={inputRef}
className={Styles.codeBlockTextArea}
value={prompt || ""}
onChange={(e) => updateAttributes({ text: e.target.value })}
placeholder={t`Can you show me monthly sales data for the past year in a line chart?`}
/>
</Flex>
<Flex px="md" pb="md" pt="xs" gap="sm">
<Flex flex={1} my="auto">
{isLoading ? (
<Text
flex={1}
className={MetabotThinkingStyles.toolCallStarted}
>
{t`Working on it...`}
</Text>
) : errorText ? (
<Flex gap="sm" c="text-secondary">
<Icon name="warning" h="1.2rem" style={{ flexShrink: 0 }} />
<Text c="text-secondary" lh={1.4}>
{errorText}
</Text>
</Flex>
) : null}
</Flex>
<Tooltip
label={t`Stop generating`}
disabled={!isLoading}
position="bottom"
>
<Button
size="sm"
onClick={() =>
isLoading ? handleStopMetabot() : handleRunMetabot()
}
classNames={{
label: CS.flex, // ensures icon is vertically centered
}}
>
{isLoading ? <Icon name="close" /> : t`Run`}
</Button>
</Tooltip>
</Flex>
</Flex>
</NodeViewWrapper>
);
},
(prevProps, nextProps) => {
return (
prevProps.node.attrs.text === nextProps.node.attrs.text &&
prevProps.selected === nextProps.selected
);
},
);
MetabotComponent.displayName = "MetabotComponent";

View File

@@ -0,0 +1 @@
export * from "./MetabotEmbed";

View File

@@ -0,0 +1,20 @@
.smartLink {
color: var(--mb-color-brand);
font-weight: 700;
padding: 0.125rem;
border-radius: var(--mantine-radius-sm);
transition: 200ms background-color;
&:hover {
background-color: var(--mb-color-background-hover);
}
}
.icon {
margin-bottom: -0.125rem;
margin-right: 0.25rem;
}
.smartLinkInner {
border-bottom: 1px solid var(--mb-color-brand-light);
}

View File

@@ -0,0 +1,325 @@
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { memo } from "react";
import { t } from "ttag";
import { cardApi } from "metabase/api";
import { collectionApi } from "metabase/api/collection";
import { dashboardApi } from "metabase/api/dashboard";
import { databaseApi } from "metabase/api/database";
import { tableApi } from "metabase/api/table";
import { getIcon } from "metabase/lib/icon";
import { modelToUrl } from "metabase/lib/urls/modelToUrl";
import { extractEntityId } from "metabase/lib/urls/utils";
import { Icon } from "metabase/ui";
import type { SearchModel } from "metabase-types/api";
import styles from "./SmartLinkNode.module.css";
export interface SmartLinkAttributes {
entityId: number;
model: string;
}
// Utility function to parse entity URLs and extract entityId and model
export function parseEntityUrl(
url: string,
siteUrl?: string,
): { entityId: number; model: SearchModel } | null {
try {
const urlObj = new URL(url);
// Validate URL matches the site-url if provided
if (siteUrl) {
const siteUrlObj = new URL(siteUrl);
// Check if origins match
if (urlObj.origin !== siteUrlObj.origin) {
return null; // URL is not from this Metabase instance
}
// Check if the path starts with the site-url path
const siteUrlPath = siteUrlObj.pathname;
if (!urlObj.pathname.startsWith(siteUrlPath)) {
return null; // URL is not under the correct path
}
} else {
// Fallback: validate against current window origin
if (urlObj.origin !== window.location.origin) {
return null;
}
}
// Remove site-url path prefix if present to get the clean pathname
let cleanPathname = urlObj.pathname;
if (siteUrl) {
const siteUrlPath = new URL(siteUrl).pathname;
// Remove the site-url path prefix, ensuring we don't create double slashes
cleanPathname = urlObj.pathname.replace(siteUrlPath, "");
// Ensure the pathname starts with /
if (!cleanPathname.startsWith("/")) {
cleanPathname = "/" + cleanPathname;
}
}
// Match different entity URL patterns
const patterns = [
{ pattern: /^\/question\/(\d+)/, model: "card" as SearchModel },
{ pattern: /^\/model\/(\d+)/, model: "dataset" as SearchModel },
{ pattern: /^\/dashboard\/(\d+)/, model: "dashboard" as SearchModel },
{ pattern: /^\/collection\/(\d+)/, model: "collection" as SearchModel },
{
pattern: /^\/browse\/(\d+)\/table\/(\d+)/,
model: "table" as SearchModel,
idIndex: 2,
},
{ pattern: /^\/browse\/(\d+)/, model: "database" as SearchModel },
];
for (const { pattern, model, idIndex = 1 } of patterns) {
const match = cleanPathname.match(pattern);
if (match) {
const entityId = extractEntityId(match[idIndex]);
if (entityId) {
return { entityId, model };
}
}
}
return null;
} catch {
return null;
}
}
export const SmartLinkNode = Node.create<{
HTMLAttributes: Record<string, unknown>;
siteUrl?: string;
}>({
name: "smartLink",
group: "inline",
inline: true,
atom: true,
priority: 1000, // Higher priority than Link extension (default 100)
addAttributes() {
return {
entityId: {
default: null,
parseHTML: (element) => {
const id = element.getAttribute("data-entity-id");
return id ? parseInt(id, 10) : null;
},
},
model: {
default: null,
parseHTML: (element) => element.getAttribute("data-model"),
},
};
},
parseHTML() {
return [
{
tag: 'span[data-type="smart-link"]',
},
];
},
renderHTML({ node, HTMLAttributes }) {
const { entityId, model } = node.attrs;
return [
"span",
mergeAttributes(
HTMLAttributes,
{
"data-type": "smart-link",
"data-entity-id": entityId,
"data-model": model,
},
this.options.HTMLAttributes,
),
`{% entity id="${entityId}" model="${model}" %}`,
];
},
renderText({ node }) {
const { entityId, model } = node.attrs;
return `{% entity id="${entityId}" model="${model}" %}`;
},
addPasteRules() {
return [
nodePasteRule({
find: /https?:\/\/[^\s]+/g,
type: this.type,
getAttributes: (match) => {
const url = match[0];
const parsedEntity = parseEntityUrl(url, this.options.siteUrl);
if (parsedEntity) {
return {
entityId: parsedEntity.entityId,
model: parsedEntity.model,
};
}
return null; // Return null to prevent node creation
},
}),
];
},
});
const useEntityData = (entityId: number | null, model: SearchModel | null) => {
const cardQuery = cardApi.useGetCardQuery(
{ id: entityId! },
{ skip: !entityId || (model !== "card" && model !== "dataset") },
);
const dashboardQuery = dashboardApi.useGetDashboardQuery(
{ id: entityId! },
{ skip: !entityId || model !== "dashboard" },
);
const collectionQuery = collectionApi.useGetCollectionQuery(
{ id: entityId! },
{ skip: !entityId || model !== "collection" },
);
const tableQuery = tableApi.useGetTableQuery(
{ id: entityId! },
{ skip: !entityId || model !== "table" },
);
const databaseQuery = databaseApi.useGetDatabaseQuery(
{ id: entityId! },
{ skip: !entityId || model !== "database" },
);
// Determine which query is active and return its state
if (model === "card" || model === "dataset") {
return {
entity: cardQuery.data,
isLoading: cardQuery.isLoading,
error: cardQuery.error,
};
}
if (model === "dashboard") {
return {
entity: dashboardQuery.data,
isLoading: dashboardQuery.isLoading,
error: dashboardQuery.error,
};
}
if (model === "collection") {
return {
entity: collectionQuery.data,
isLoading: collectionQuery.isLoading,
error: collectionQuery.error,
};
}
if (model === "table") {
return {
entity: tableQuery.data,
isLoading: tableQuery.isLoading,
error: tableQuery.error,
};
}
if (model === "database") {
return {
entity: databaseQuery.data,
isLoading: databaseQuery.isLoading,
error: databaseQuery.error,
};
}
return { entity: null, isLoading: false, error: null };
};
export const SmartLinkComponent = memo(
({ node }: NodeViewProps) => {
const { entityId, model } = node.attrs;
const { entity, isLoading, error } = useEntityData(entityId, model);
if (isLoading) {
return (
<NodeViewWrapper as="span">
<span className={styles.smartLink}>
<span className={styles.smartLinkInner}>
<Icon name="hourglass" className={styles.icon} />
{t`Loading ${model}...`}
</span>
</span>
</NodeViewWrapper>
);
}
if (error || !entity) {
return (
<NodeViewWrapper as="span">
<span className={styles.smartLink}>
<span className={styles.smartLinkInner}>
<Icon name="warning" className={styles.icon} />
{error ? t`Failed to load` : t`Unknown`} {model}
</span>
</span>
</NodeViewWrapper>
);
}
const entityUrl = modelToUrl({
id: entity.id,
model: entity.model || model,
name: entity.name,
database: entity.db_id
? { id: entity.db_id }
: entity.database_id
? { id: entity.database_id }
: undefined,
});
const iconData = getIcon({
model: entity.model || model,
display: entity.display,
is_personal: entity.is_personal,
});
return (
<NodeViewWrapper as="span">
<a
href={entityUrl || "#"}
target="_blank"
rel="noreferrer"
onMouseUp={(e) => {
// Stop tiptap from opening this link twice
e.stopPropagation();
}}
className={styles.smartLink}
>
<span className={styles.smartLinkInner}>
<Icon name={iconData.name} className={styles.icon} />
{entity.name}
</span>
</a>
</NodeViewWrapper>
);
},
(prevProps, nextProps) => {
// Custom comparison function to prevent re-renders
// Only re-render if these specific props change
return (
prevProps.node.attrs.entityId === nextProps.node.attrs.entityId &&
prevProps.node.attrs.model === nextProps.node.attrs.model &&
prevProps.selected === nextProps.selected
);
},
);
SmartLinkComponent.displayName = "SmartLinkComponent";

View File

@@ -0,0 +1,11 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { SmartLinkComponent, SmartLinkNode } from "./SmartLinkNode";
export * from "./SmartLinkNode";
export const SmartLinkEmbed = SmartLinkNode.extend({
addNodeView() {
return ReactNodeViewRenderer(SmartLinkComponent);
},
});

View File

@@ -0,0 +1,32 @@
// Shared utility for generating card embed markdown format
export function formatCardEmbed(attrs: { id: number; name?: string }): string {
if (attrs.name) {
return `{% card id=${attrs.id} name="${attrs.name}" %}`;
} else {
return `{% card id=${attrs.id} %}`;
}
}
// Shared utility for generating smart link markdown format
export function formatSmartLink(attrs: {
entityId?: number;
model?: string;
}): string {
return `{% entity id="${attrs.entityId}" model="${attrs.model}" %}`;
}
// Shared utility for generating spacer markdown format
export function formatSpacer(lines: number): string {
return `{% spacer lines=${lines} %}`;
}
// Regex pattern for matching card embeds in markdown
export const CARD_EMBED_PATTERN =
/{%\s*card\s+id=(\d+)(?:\s+name="([^"]*)")?\s*%}/g;
// Regex pattern for matching smart links in markdown
export const SMART_LINK_PATTERN =
/{%\s*entity\s+id="(\d+)"\s+model="([^"]+)"\s*%}/g;
// Regex pattern for matching spacers in markdown
export const SPACER_PATTERN = /{%\s*spacer\s+lines=(\d+)\s*%}/g;

View File

@@ -0,0 +1,84 @@
import type MarkdownIt from "markdown-it";
import type { ParentType, StateBlock } from "markdown-it";
export function cardPlugin(md: MarkdownIt): void {
md.block.ruler.before("paragraph", "card", cardBlock, {
alt: ["paragraph", "reference", "blockquote", "list"],
});
}
function cardBlock(
state: StateBlock,
startLine: number,
_endLine: number,
silent: boolean,
): boolean {
const pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
// Check if line starts with {% card
if (pos + 7 > max) {
return false;
}
if (state.src.slice(pos, pos + 7) !== "{% card") {
return false;
}
// Find the closing %}
const closePos = state.src.indexOf("%}", pos + 7);
if (closePos === -1 || closePos > max) {
return false;
}
// Extract attributes
const content = state.src.slice(pos + 7, closePos).trim();
const attrs = parseCardAttributes(content);
if (!attrs.id) {
return false;
}
if (silent) {
return true;
}
const oldParent = state.parentType;
const oldLineMax = state.lineMax;
state.parentType = "card" as ParentType;
// Create card token
const token = state.push("card", "div", 0);
token.attrSet("id", attrs.id);
if (attrs.name) {
token.attrSet("name", attrs.name);
}
token.map = [startLine, startLine + 1];
state.parentType = oldParent;
state.lineMax = oldLineMax;
state.line = startLine + 1;
return true;
}
function parseCardAttributes(content: string): {
id?: string;
name?: string;
} {
const attrs: { id?: string; name?: string } = {};
// Match id=123
const idMatch = content.match(/id=(\d+)/);
if (idMatch) {
attrs.id = idMatch[1];
}
// Match name="..." (with proper quote handling)
const nameMatch = content.match(/name="([^"]*)"/);
if (nameMatch) {
attrs.name = nameMatch[1];
}
return attrs;
}

View File

@@ -0,0 +1,215 @@
import { Extension } from "@tiptap/core";
import MarkdownIt from "markdown-it";
import {
MarkdownParser,
MarkdownSerializer,
type ParseSpec,
} from "prosemirror-markdown";
import { cardPlugin } from "./cardPlugin";
import { linkPlugin } from "./linkPlugin";
import { createParserTokens } from "./markdown-to-prosemirror";
import {
createMarkSerializers,
createNodeSerializers,
} from "./prosemirror-to-markdown";
import { spacerPlugin } from "./spacerPlugin";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
markdown: {
getMarkdown: () => string extends ReturnType ? string : ReturnType;
setMarkdown: (markdown: string) => ReturnType;
};
}
interface Storage {
markdown: MarkdownStorage;
}
}
export interface MarkdownStorage {
getMarkdown: () => string;
setMarkdown: (markdown: string, emit?: boolean) => void;
parser: MarkdownParser | null;
serializer: MarkdownSerializer | null;
}
export const Markdown = Extension.create<
{
extraNodeSerializers?: MarkdownSerializer["nodes"];
extraTokenSpecs?: Record<string, ParseSpec>;
extraMarkSerializers?: MarkdownSerializer["marks"];
},
MarkdownStorage
>({
name: "markdown",
addOptions() {
return {
extraNodeSerializers: {},
extraTokenSpecs: {},
extraMarkSerializers: {},
};
},
addStorage() {
return {
parser: null,
serializer: null,
getMarkdown: () => "",
setMarkdown: () => {},
} as MarkdownStorage;
},
onBeforeCreate() {
const getSerializer = () => {
if (this.storage.serializer) {
return this.storage.serializer;
}
const schema = this.editor.state.schema;
const nodeSerializers = createNodeSerializers(
this.options.extraNodeSerializers,
);
const markSerializers = createMarkSerializers(
this.options.extraMarkSerializers,
);
// Validate that all nodes in schema have serializers
const missingNodeSerializers = Object.keys(schema.nodes).filter(
(nodeName) => !nodeSerializers[nodeName],
);
if (missingNodeSerializers.length > 0) {
console.error(
`Missing serializers for nodes: ${missingNodeSerializers.join(", ")}`,
);
}
// Validate that all marks in schema have serializers
const missingMarkSerializers = Object.keys(schema.marks).filter(
(markName) => !markSerializers[markName],
);
if (missingMarkSerializers.length > 0) {
throw new Error(
`Missing serializers for marks: ${missingMarkSerializers.join(", ")}`,
);
}
this.storage.serializer = new MarkdownSerializer(
nodeSerializers,
markSerializers,
);
return this.storage.serializer;
};
const getParser = () => {
if (this.storage.parser) {
return this.storage.parser;
}
const schema = this.editor.state.schema;
const md = new MarkdownIt("commonmark", {
html: false,
linkify: true,
breaks: false,
});
md.use(spacerPlugin);
md.use(cardPlugin);
md.use(linkPlugin);
// Add a rule to expand spacer tokens to multiple empty paragraphs
md.core.ruler.after("block", "expand_spacers", (state) => {
const tokens = state.tokens;
const newTokens = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === "spacer") {
const lines = parseInt(token.attrGet("lines") ?? "1", 10);
// Create multiple empty paragraph tokens
for (let j = 0; j < lines; j++) {
const pOpen = new state.Token("paragraph_open", "p", 1);
pOpen.block = true;
pOpen.map = token.map; // Preserve source mapping
const inline = new state.Token("inline", "", 0);
inline.content = "";
inline.children = [];
inline.nesting = 0;
const pClose = new state.Token("paragraph_close", "p", -1);
pClose.block = true;
newTokens.push(pOpen, inline, pClose);
}
} else {
newTokens.push(token);
}
}
state.tokens = newTokens;
});
const tokens = createParserTokens(this.options.extraTokenSpecs);
this.storage.parser = new MarkdownParser(schema, md, tokens);
return this.storage.parser;
};
this.storage.getMarkdown = () => {
return getSerializer().serialize(this.editor.state.doc, {
tightLists: false, // Allow loose lists to preserve spacing
});
};
this.storage.setMarkdown = (markdown: string) => {
try {
if (!markdown || typeof markdown !== "string") {
console.warn("Invalid markdown content provided:", markdown);
return;
}
const doc = getParser().parse(markdown);
if (!doc) {
console.warn("Failed to parse markdown into document");
return;
}
if (!this.editor || this.editor.isDestroyed) {
console.warn("Editor is not available or destroyed");
return;
}
// Check if the editor is ready and has a valid schema
if (!this.editor.state || !this.editor.state.schema) {
console.warn("Editor not ready for content update");
return;
}
// Validate that the document matches the schema
try {
const docJSON = doc.toJSON();
this.editor.chain().setContent(docJSON).run();
} catch (validationError) {
console.error("Document validation failed:", validationError);
}
} catch (error) {
console.error("Failed to parse and set markdown content:", error);
}
};
},
addCommands() {
return {
setMarkdown: (markdown: string) => () => {
this.storage.setMarkdown(markdown);
return true;
},
};
},
});

View File

@@ -0,0 +1,63 @@
import type MarkdownIt from "markdown-it";
import type { StateInline } from "markdown-it";
export function linkPlugin(md: MarkdownIt): void {
md.inline.ruler.before("text", "smartlink", smartLinkInline);
}
function smartLinkInline(state: StateInline, silent: boolean): boolean {
const pos = state.pos;
const max = state.posMax;
// Check if we have enough characters for {% entity
if (pos + 9 > max) {
return false;
}
if (state.src.slice(pos, pos + 9) !== "{% entity") {
return false;
}
// Find the closing %}
const closePos = state.src.indexOf("%}", pos + 9);
if (closePos === -1) {
return false;
}
// Extract attributes
const content = state.src.slice(pos + 9, closePos).trim();
const attrs = parseEntityAttributes(content);
if (!attrs.id || !attrs.model) {
return false;
}
if (!silent) {
const token = state.push("smartlink", "", 0);
token.attrSet("id", attrs.id);
token.attrSet("model", attrs.model);
}
state.pos = closePos + 2;
return true;
}
function parseEntityAttributes(content: string): {
id?: string;
model?: string;
} {
const attrs: { id?: string; model?: string } = {};
// Match id="..."
const idMatch = content.match(/id="([^"]*)"/);
if (idMatch) {
attrs.id = idMatch[1];
}
// Match model="..."
const modelMatch = content.match(/model="([^"]*)"/);
if (modelMatch) {
attrs.model = modelMatch[1];
}
return attrs;
}

View File

@@ -0,0 +1,95 @@
import Blockquote from "@tiptap/extension-blockquote";
import Bold from "@tiptap/extension-bold";
import BulletList from "@tiptap/extension-bullet-list";
import Code from "@tiptap/extension-code";
import CodeBlock from "@tiptap/extension-code-block";
import HardBreak from "@tiptap/extension-hard-break";
import Heading from "@tiptap/extension-heading";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Image from "@tiptap/extension-image";
import Italic from "@tiptap/extension-italic";
import ListItem from "@tiptap/extension-list-item";
import OrderedList from "@tiptap/extension-ordered-list";
import Paragraph from "@tiptap/extension-paragraph";
import Strike from "@tiptap/extension-strike";
import Underline from "@tiptap/extension-underline";
import type { Token } from "markdown-it";
import type { ParseSpec } from "prosemirror-markdown";
import { CardEmbedNode } from "../CardEmbed/CardEmbedNode";
import { SmartLinkNode } from "../SmartLink/SmartLinkNode";
// Parser tokens - how markdown tokens are converted to ProseMirror nodes/marks
export const PARSER_TOKENS: Record<string, ParseSpec> = {
// Basic blocks
paragraph: { block: Paragraph.name },
blockquote: { block: Blockquote.name },
heading: {
block: Heading.name,
getAttrs: (tok: Token) => ({ level: +tok.tag.slice(1) }),
},
// Lists
bullet_list: { block: BulletList.name },
ordered_list: {
block: OrderedList.name,
getAttrs: (tok: Token) => ({ order: +(tok.attrGet("start") ?? 1) }),
},
list_item: { block: ListItem.name },
// Code
code_block: { block: CodeBlock.name, noCloseToken: true },
fence: {
block: CodeBlock.name,
getAttrs: (tok: Token) => ({ params: tok.info || "" }),
noCloseToken: true,
},
// Other nodes
hr: { node: HorizontalRule.name },
hardbreak: { node: HardBreak.name },
image: {
node: Image.name,
getAttrs: (tok: Token) => ({
src: tok.attrGet("src"),
alt: tok.content || null,
title: tok.attrGet("title") || null,
}),
},
// Marks
strong: { mark: Bold.name },
em: { mark: Italic.name },
code_inline: { mark: Code.name, noCloseToken: true },
link: {
mark: "link",
getAttrs: (tok: Token) => ({
href: tok.attrGet("href"),
title: tok.attrGet("title") || null,
}),
},
s: { mark: Strike.name },
u: { mark: Underline.name },
// Custom nodes
card: {
node: CardEmbedNode.name,
getAttrs: (tok: Token) => ({
id: parseInt(tok.attrGet("id") || "0", 10),
name: tok.attrGet("name") || null,
}),
},
smartlink: {
node: SmartLinkNode.name,
getAttrs: (tok: Token) => ({
entityId: parseInt(tok.attrGet("id") || "0", 10),
model: tok.attrGet("model"),
}),
},
};
export function createParserTokens(
extraTokens: Record<string, ParseSpec> = {},
): Record<string, ParseSpec> {
return { ...PARSER_TOKENS, ...extraTokens };
}

View File

@@ -0,0 +1,143 @@
import Blockquote from "@tiptap/extension-blockquote";
import Bold from "@tiptap/extension-bold";
import BulletList from "@tiptap/extension-bullet-list";
import Code from "@tiptap/extension-code";
import CodeBlock from "@tiptap/extension-code-block";
import HardBreak from "@tiptap/extension-hard-break";
import Heading from "@tiptap/extension-heading";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Image from "@tiptap/extension-image";
import Italic from "@tiptap/extension-italic";
import ListItem from "@tiptap/extension-list-item";
import OrderedList from "@tiptap/extension-ordered-list";
import Paragraph from "@tiptap/extension-paragraph";
import Strike from "@tiptap/extension-strike";
import Text from "@tiptap/extension-text";
import Underline from "@tiptap/extension-underline";
import {
type MarkdownSerializer,
defaultMarkdownSerializer,
} from "prosemirror-markdown";
import { CardEmbedNode } from "../CardEmbed/CardEmbedNode";
import { SmartLinkNode } from "../SmartLink/SmartLinkNode";
import {
formatCardEmbed,
formatSmartLink,
formatSpacer,
} from "./card-embed-format";
export const NODE_SERIALIZERS: MarkdownSerializer["nodes"] = {
doc: (state, node) => {
state.renderContent(node);
},
// Text nodes
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Paragraph.name]: (state, node, parent, index) => {
// Handle empty paragraphs at document level
if (parent.type.name === "doc" && node.content.size === 0) {
// Look ahead to find consecutive empty paragraphs
let emptyCount = 1; // Current paragraph is empty
let nextIndex = index + 1;
while (nextIndex < parent.childCount) {
const nextChild = parent.child(nextIndex);
if (
nextChild.type.name === "paragraph" &&
nextChild.content.size === 0
) {
emptyCount++;
nextIndex++;
} else {
break;
}
}
// Only output spacer for the FIRST empty paragraph in a sequence
// The rest will be skipped by checking if we're in the middle of a sequence
let isFirstInSequence = true;
if (index > 0) {
const prevChild = parent.child(index - 1);
if (
prevChild.type.name === "paragraph" &&
prevChild.content.size === 0
) {
isFirstInSequence = false;
}
}
if (isFirstInSequence) {
state.write(formatSpacer(emptyCount));
state.closeBlock(node);
}
// Skip subsequent empty paragraphs in the sequence
} else {
// Handle non-empty paragraphs or paragraphs in nested contexts normally
defaultMarkdownSerializer.nodes.paragraph(state, node, parent, index);
}
},
// Headings
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
// Lists
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
// Blocks
[Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
[CodeBlock.name]: defaultMarkdownSerializer.nodes.code_block,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
// Breaks
[HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
// Image (from TipTap Image extension)
[Image.name]: defaultMarkdownSerializer.nodes.image,
// Card embed nodes
[CardEmbedNode.name]: (state, node) => {
state.write(formatCardEmbed(node.attrs));
state.closeBlock(node);
},
// Smart link nodes
[SmartLinkNode.name]: (state, node) => {
state.write(formatSmartLink(node.attrs));
},
};
// Mark serializers - how ProseMirror marks are converted to markdown
export const MARK_SERIALIZERS: MarkdownSerializer["marks"] = {
[Bold.name]: defaultMarkdownSerializer.marks.strong,
[Italic.name]: defaultMarkdownSerializer.marks.em,
[Code.name]: defaultMarkdownSerializer.marks.code,
link: defaultMarkdownSerializer.marks.link,
[Strike.name]: {
open: "~~",
close: "~~",
mixable: true,
expelEnclosingWhitespace: true,
},
[Underline.name]: {
open: "<u>",
close: "</u>",
mixable: true,
expelEnclosingWhitespace: true,
},
};
export function createNodeSerializers(
extraSerializers: MarkdownSerializer["nodes"] = {},
): MarkdownSerializer["nodes"] {
return { ...NODE_SERIALIZERS, ...extraSerializers };
}
export function createMarkSerializers(
extraSerializers: MarkdownSerializer["marks"] = {},
): MarkdownSerializer["marks"] {
return { ...MARK_SERIALIZERS, ...extraSerializers };
}

View File

@@ -0,0 +1,37 @@
import type MarkdownIt from "markdown-it";
import type { StateBlock } from "markdown-it";
export const spacerPlugin = (md: MarkdownIt) => {
const RE = /^\{%[ \t]*spacer[ \t]+lines=(\d+)[ \t]*%}[ \t]*$/;
function spacerRule(
state: StateBlock,
startLine: number,
_endLine: number,
silent: boolean,
) {
const pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineStr = state.src.slice(pos, max);
const m = RE.exec(lineStr);
if (!m) {
return false;
}
if (silent) {
return true;
}
const token = state.push("spacer", "", 0);
token.block = true;
token.map = [startLine, startLine + 1];
token.attrSet("lines", m[1]);
state.line = startLine + 1;
return true;
}
md.block.ruler.before("fence", "spacer", spacerRule, {
alt: ["paragraph", "blockquote", "list"],
});
};

View File

@@ -0,0 +1,2 @@
export { useCardEmbedSelection as useQuestionSelection } from "./useCardEmbedSelection";
export { useCardEmbedsTracking } from "./useCardEmbedsTracking";

View File

@@ -0,0 +1,70 @@
import type { Editor as TiptapEditor } from "@tiptap/react";
import { useEffect } from "react";
/**
* Hook that tracks question selection in the Tiptap editor.
*
* This hook monitors the editor's selection state and detects when:
* - A question embed node is directly selected
* - A question static node is directly selected
* - The cursor is positioned inside a question embed
* - The selection is cleared (ESC key or selection outside questions)
*
* @param editor - The Tiptap editor instance
* @param onCardSelect - Callback function that receives the selected question ID or null
*/
export const useCardEmbedSelection = (
editor: TiptapEditor | null,
onCardSelect:
| ((cardId: number | null, embedIndex?: number | null) => void)
| undefined,
): void => {
useEffect(() => {
if (!editor || !onCardSelect) {
return;
}
/**
* Checks the current selection and determines if a question is selected.
* Handles both direct node selection and cursor position within question embeds.
*/
const updateSelection = () => {
const { selection } = editor.state;
const { doc } = editor.state;
const nodeAtCursor = doc.nodeAt(selection.from);
if (nodeAtCursor && nodeAtCursor.type.name === "cardEmbed") {
// Find which embed index this node corresponds to
let embedIndex = -1;
let nodeCount = 0;
doc.descendants((node, pos) => {
if (node.type.name === "cardEmbed") {
if (pos === selection.from) {
embedIndex = nodeCount;
return false;
}
nodeCount++;
}
return true;
});
onCardSelect(nodeAtCursor.attrs.id, embedIndex);
} else {
onCardSelect(null, null);
}
};
// Initial selection check
updateSelection();
// Listen for selection changes
editor.on("selectionUpdate", updateSelection);
// Cleanup
return () => {
editor.off("selectionUpdate", updateSelection);
};
}, [editor, onCardSelect]);
};

View File

@@ -0,0 +1,71 @@
import type { Editor as TiptapEditor } from "@tiptap/react";
import { useEffect } from "react";
import type { CardEmbedRef } from "../types";
/**
* Extracts all question references from the editor document.
*
* This function traverses the entire document tree and collects references to
* question embeds.
*
* @param editor - The Tiptap editor instance
* @returns Array of question embed references found in the document
*/
const extractCardEmbeds = (editor: TiptapEditor): CardEmbedRef[] => {
const refs: CardEmbedRef[] = [];
editor.state.doc.descendants((node: any) => {
// Handle regular question embeds
if (node.type.name === "cardEmbed") {
refs.push({
id: node.attrs.id,
name: node.attrs.name,
});
}
});
return refs;
};
/**
* Hook that tracks all question references in the Tiptap editor document.
*
* This hook monitors the editor content and maintains an up-to-date list of all
* question embeds. It automatically updates whenever the document changes and
* notifies the parent component through the provided callback.
*
* Features:
* - Tracks question embeds
* - Updates on every document change
*
* @param editor - The Tiptap editor instance
* @param onCardEmbedsChange - Callback function that receives the current list of question references
*/
export const useCardEmbedsTracking = (
editor: TiptapEditor | null,
onCardEmbedsChange: ((refs: CardEmbedRef[]) => void) | undefined,
): void => {
useEffect(() => {
if (!editor || !onCardEmbedsChange) {
return;
}
/**
* Updates the question references by scanning the entire document.
* Called initially and on every document update.
*/
const updateCardEmbeds = () => {
const refs = extractCardEmbeds(editor);
onCardEmbedsChange(refs);
};
// Listen for document updates
editor.on("update", updateCardEmbeds);
// Cleanup
return () => {
editor.off("update", updateCardEmbeds);
};
}, [editor, onCardEmbedsChange]);
};

View File

@@ -0,0 +1,81 @@
import { useCallback } from "react";
import { t } from "ttag";
import { useCreateCardMutation } from "metabase/api";
import { uuid } from "metabase/lib/uuid";
import { aiStreamingQuery } from "metabase-enterprise/api/ai-streaming";
const ENDPOINT = "/api/ee/metabot-v3/v2/agent-streaming"; // TODO: we should get our own endpoint
const EXTRA_PROMPT = ", do not ask me any further clarifying questions";
export const useMetabotDocumentQuery = () => {
const [createQuestion] = useCreateCardMutation();
const queryMetabot = useCallback(
async ({ prompt, signal }: { prompt: string; signal?: AbortSignal }) => {
const messageId = uuid();
const response = await aiStreamingQuery(
{
url: ENDPOINT,
signal,
body: {
message: prompt + EXTRA_PROMPT,
conversation_id: messageId,
context: { user_is_viewing: [] },
history: [],
state: {},
},
},
{},
);
// FIXME: get the backend to do all this processing - this is just a hacky prototype
const description = response?.text;
const navigateTo = response.data.find(
(d: any) => d.type === "navigate_to",
);
if (!navigateTo) {
return { error: description };
}
const encodedQuery = navigateTo?.value?.replace?.("/question#", "");
let query = {};
try {
query = JSON.parse(atob(encodedQuery));
} catch (e) {
console.error("Failed to parse query", e, encodedQuery);
return { error: description };
}
if (!query) {
return { error: description };
}
const createQuestionResponse = await createQuestion({
visualization_settings: {},
...(query as any),
type: "question",
name: "Exploration",
});
const questionId = createQuestionResponse?.data?.id;
if (!questionId) {
return { error: t`Failed to create question from Metabot response` };
}
return {
cardId: questionId,
description: description || t`Data exploration`,
};
},
[createQuestion],
);
return queryMetabot;
};

View File

@@ -0,0 +1 @@
export * from "./Editor";

View File

@@ -0,0 +1,4 @@
export interface CardEmbedRef {
id: number;
name?: string;
}

View File

@@ -0,0 +1,251 @@
import type { Editor } from "@tiptap/react";
import { useCallback } from "react";
import { t } from "ttag";
import { useDispatch } from "metabase/lib/redux";
import {
ActionIcon,
Box,
Button,
Group,
Icon,
Loader,
Menu,
Stack,
Text,
} from "metabase/ui";
import { QuestionChartSettings } from "metabase/visualizations/components/ChartSettings";
import type {
CardDisplayType,
VisualizationSettings,
} from "metabase-types/api";
import {
closeSidebar,
updateVisualizationType,
updateVizSettings,
} from "../documents.slice";
import { useCardWithDataset } from "../hooks/useCardWithDataset";
import { useDraftCardOperations } from "../hooks/useDraftCardOperations";
import { useDocumentsSelector } from "../redux-utils";
import { getSelectedEmbedIndex } from "../selectors";
import { useVisualizationOptions } from "../utils/visualizationUtils";
interface EmbedQuestionSettingsSidebarProps {
cardId: number;
onClose: () => void;
editorInstance?: Editor;
}
export const EmbedQuestionSettingsSidebar = ({
cardId,
editorInstance,
}: EmbedQuestionSettingsSidebarProps) => {
const dispatch = useDispatch();
const selectedEmbedIndex = useDocumentsSelector(getSelectedEmbedIndex);
// Use extracted hook for card and dataset fetching
const {
cardWithDraft,
dataset,
isResultsLoading,
series,
question,
isCardLoading,
draftCard,
card,
regularDataset,
} = useCardWithDataset(cardId);
// Use extracted hook for visualization options
const { sensibleItems, nonsensibleItems, selectedElem } =
useVisualizationOptions(dataset, cardWithDraft?.display as CardDisplayType);
// Use extracted hook for draft card operations
const { ensureDraftCard } = useDraftCardOperations(
draftCard,
card,
cardId,
editorInstance,
selectedEmbedIndex,
regularDataset,
);
const handleSettingsChange = (settings: VisualizationSettings) => {
if (selectedEmbedIndex !== null) {
// If no draft exists, create one with the current settings change
if (!draftCard) {
const baseCard = card;
const newSettings = {
...baseCard?.visualization_settings,
...settings,
};
const actualCardId = ensureDraftCard(
{ visualization_settings: newSettings },
true,
);
// Use the returned ID (might be different if this was a duplicate)
dispatch(updateVizSettings({ cardId: actualCardId, settings }));
} else {
// Draft already exists, just update it
dispatch(updateVizSettings({ cardId, settings }));
}
}
};
const handleVisualizationTypeChange = (display: CardDisplayType) => {
if (selectedEmbedIndex !== null) {
// If no draft exists, create one with the current display change
if (!draftCard) {
const actualCardId = ensureDraftCard({ display }, true);
// Use the returned ID (might be different if this was a duplicate)
dispatch(updateVisualizationType({ cardId: actualCardId, display }));
} else {
// Draft already exists, just update it
dispatch(updateVisualizationType({ cardId, display }));
}
}
};
const handleDone = useCallback(() => {
// Simply close the sidebar - draft changes are kept in Redux
dispatch(closeSidebar());
}, [dispatch]);
if (isCardLoading || isResultsLoading || !series) {
return (
<Stack gap="lg" p="lg" style={{ height: "100%" }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flex: 1,
flexDirection: "column",
gap: "1rem",
}}
>
<Loader size="lg" />
<Text>{t`Loading question settings...`}</Text>
</Box>
</Stack>
);
}
if (!cardWithDraft || !series) {
return (
<Stack gap="lg" p="lg" style={{ height: "100%" }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flex: 1,
}}
>
<Text color="error">{t`Failed to load question`}</Text>
</Box>
</Stack>
);
}
return (
<Box
style={{
height: "100%",
display: "flex",
alignItems: "stretch",
flexDirection: "column",
backgroundColor: "var(--mb-color-bg-white)",
}}
>
<Box
style={{
borderBottom: "1px solid var(--mb-color-border)",
backgroundColor: "var(--mb-color-bg-white)",
}}
>
<Group w="100%" justify="space-between" align="flex-start">
<Group align="center" p="md">
<Text size="md" fw="bold">{t`Visualize as`}</Text>
<Menu position="bottom-start">
<Menu.Target>
<Button
variant="default"
disabled={!selectedElem}
rightSection={<Icon ml="xs" size={10} name="chevrondown" />}
leftSection={
selectedElem?.iconName ? (
<Icon name={selectedElem.iconName} />
) : null
}
justify="space-between"
>
{selectedElem?.label}
</Button>
</Menu.Target>
<Menu.Dropdown>
{sensibleItems.map(({ iconName, label, value }, index) => (
<Menu.Item
key={`${value}/${index}`}
onClick={() => handleVisualizationTypeChange(value)}
leftSection={iconName ? <Icon name={iconName} /> : null}
>
{label}
</Menu.Item>
))}
{nonsensibleItems.length > 0 && (
<>
<Menu.Label>{t`Other charts`}</Menu.Label>
{nonsensibleItems.map(
({ iconName, label, value }, index) => (
<Menu.Item
key={`${value}/${index}`}
onClick={() => handleVisualizationTypeChange(value)}
leftSection={
iconName ? <Icon name={iconName} /> : null
}
>
{label}
</Menu.Item>
),
)}
</>
)}
</Menu.Dropdown>
</Menu>
</Group>
<ActionIcon
mt="1rem"
mr="1rem"
color="text-dark"
onClick={handleDone}
>
<Icon name="close" />
</ActionIcon>
</Group>
</Box>
<Box style={{ flex: 1, overflow: "auto" }}>
<QuestionChartSettings
question={question}
series={series}
onChange={handleSettingsChange}
computedSettings={cardWithDraft.visualization_settings || {}}
/>
</Box>
<Box
style={{
borderTop: "1px solid var(--mb-color-border)",
padding: "1rem",
backgroundColor: "var(--mb-color-bg-white)",
}}
>
<Button fullWidth variant="filled" onClick={handleDone}>
{t`Done`}
</Button>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,107 @@
import {
getChartImagePngDataUri,
getChartSelector,
} from "metabase/visualizations/lib/image-exports";
import {
CARD_EMBED_PATTERN,
SMART_LINK_PATTERN,
SPACER_PATTERN,
} from "./Editor/extensions/markdown/card-embed-format";
import type { CardEmbedRef } from "./Editor/types";
// Helper function to convert a question to PNG data URL using existing Metabase utilities
export const convertQuestionToPng = async (
cardId: number,
): Promise<string | null> => {
try {
// Use the existing chart selection and PNG generation system
const cardSelector = getChartSelector({ cardId });
const pngDataUrl = await getChartImagePngDataUri(cardSelector);
return pngDataUrl || null;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Failed to convert question ${cardId} to PNG:`, error);
return null;
}
};
export const getDownloadableMarkdown = async (
markdown: string,
cardEmbeds: CardEmbedRef[],
): Promise<string> => {
let processedMarkdown = markdown;
// First, remove all spacers
processedMarkdown = processedMarkdown.replace(SPACER_PATTERN, "");
// Replace smart links with regular markdown links
processedMarkdown = processedMarkdown.replace(
SMART_LINK_PATTERN,
(match, url, text) => `[${text}](${url})`,
);
// Extract card references from markdown using shared regex pattern
const cardMatches = Array.from(
processedMarkdown.matchAll(CARD_EMBED_PATTERN),
);
// eslint-disable-next-line no-console
console.log("Processing cards found in markdown:", cardMatches.length);
if (cardMatches.length === 0) {
return processedMarkdown;
}
for (let i = 0; i < cardMatches.length; i++) {
const match = cardMatches[i];
const [fullMatch, cardIdStr, customName] = match;
const cardId = parseInt(cardIdStr, 10);
try {
// Convert question to PNG using existing Metabase utilities
const pngDataUrl = await convertQuestionToPng(cardId);
if (!pngDataUrl) {
// eslint-disable-next-line no-console
console.warn(`Failed to generate PNG for question ${cardId}`);
continue;
}
// Get question name from cardEmbed for fallback
const cardEmbed = cardEmbeds.find((ref: any) => ref.id === cardId);
const displayName = customName || cardEmbed?.name || `Question ${cardId}`;
// Replace the specific card reference in markdown with embedded image
const specificPattern = new RegExp(
fullMatch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"g",
);
processedMarkdown = processedMarkdown.replace(
specificPattern,
`![${displayName}](${pngDataUrl})`,
);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Failed to process card ${cardId}:`, error);
// Keep the original card reference if conversion fails
}
}
return processedMarkdown;
};
export const downloadFile = (markdown: string) => {
// Create and download the file
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `document-${new Date().toISOString().split("T")[0]}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};

Some files were not shown because too many files have changed in this diff Show More