Compare commits

...

763 Commits

Author SHA1 Message Date
Bryan Maass
6453bfc560 reorder transforms jobs functions so ns loads 2025-08-12 10:59:49 -06:00
Bryan Maass
c57cbdebdb Merge remote-tracking branch 'origin/poc-transform-table' 2025-08-12 10:48:14 -06:00
Bryan Maass
2b036df2f1 Merge remote-tracking branch 'origin/feature/reports' 2025-08-12 10:38:11 -06:00
Eric Normand
ccbce4072d [ci skip] productionizing 2025-08-08 11:16:12 -05:00
William Zimrin
4b67d17c2c fix build 2025-08-08 11:15:44 -05:00
William Zimrin
3001bf4c5d basic job execution works 2025-08-08 11:15:44 -05:00
Alexander Polyankin
57cae37e7f Small fixes [skip ci] 2025-08-08 12:14:00 -04:00
Alexander Polyankin
0cc2bafc6a Small fixes [skip ci] 2025-08-08 12:11:31 -04:00
Alexander Polyankin
165b098e60 Small fixes [skip ci] 2025-08-08 12:04:48 -04:00
Alexander Polyankin
c34fbce32d Run CI 2025-08-08 11:59:40 -04:00
Alexander Polyankin
2bede1f7d7 Small fixes [skip ci] 2025-08-08 11:58:23 -04:00
Alexander Polyankin
2e34ba3dbe Small fixes [skip ci] 2025-08-08 11:55:11 -04:00
Alexander Polyankin
1da6ff58fc Small fixes [skip ci] 2025-08-08 11:52:56 -04:00
Alexander Polyankin
e87131e2bc Small fixes [skip ci] 2025-08-08 11:45:50 -04:00
Aleksandr Lesnenko
0ab1d4ccb6 no emojis and markdown downloading 2025-08-08 12:44:25 -03:00
Aleksandr Lesnenko
ea64672746 drop emojis 2025-08-08 12:44:25 -03:00
Alexander Polyankin
ca28fd877e Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-08 11:42:30 -04:00
Alexander Polyankin
5e11e1d61b Small fixes [skip ci] 2025-08-08 11:41:48 -04:00
Eric Normand
07e76075ad [ci skip] Synchronize remote transform request 2025-08-08 10:29:01 -05:00
Alexander Polyankin
397828b8b7 Small fixes [skip ci] 2025-08-08 10:43:04 -04:00
Eric Normand
95f52ef338 [ci skip Use virtual thread instead of future for transform execution 2025-08-08 09:41:26 -05:00
Eric Normand
f9bdd5ee0a add quartzite.schedule.calendar-interval/build to macroexpand list 2025-08-08 09:41:26 -05:00
Eric Normand
3a59a91a9f [ci skip] Remove todo 2025-08-08 09:41:26 -05:00
Alexander Polyankin
334116e05b Small fixes [skip ci] 2025-08-08 10:40:32 -04:00
Brad Anderson
b9a446a933 Localizes "Created with Metabot" text 2025-08-08 10:21:15 -04:00
Alexander Polyankin
df4dfa576c Small fixes [skip ci] 2025-08-08 10:13:07 -04:00
Alexander Polyankin
9d6de7cb81 Small fixes [skip ci] 2025-08-08 10:05:25 -04:00
Brad Anderson
7bd57d1d66 MetabotEmbed polish:
* Prevents prompt from highlighting when you hit "Run" (potentially bringing up the editor bubble menu).
* Prevents useEffect hook from running way more than it needs to.
* Adds margin below MetabotEmbed.
* Hitting escape with nothing in the textarea will now remove the MetabotEmbed block.
* Makes layout less janky when Metabot responds with a long error message.
2025-08-08 09:55:47 -04:00
Alexander Polyankin
8d4863d9a3 Small fixes [skip ci] 2025-08-08 09:32:42 -04:00
Alexander Polyankin
bc9da53ab1 Small fixes [skip ci] 2025-08-08 09:22:48 -04:00
Alexander Polyankin
d05166c2d4 Small fixes [skip ci] 2025-08-08 01:34:36 -04:00
Alexander Polyankin
1ec269b115 Small fixes [skip ci] 2025-08-08 01:25:34 -04:00
Alexander Polyankin
15f80f8a2a Small fixes [skip ci] 2025-08-08 01:13:19 -04:00
Alexander Polyankin
c993b3035a Small fixes [skip ci] 2025-08-08 01:10:24 -04:00
Alexander Polyankin
f0c57c07ef Small fixes [skip ci] 2025-08-08 00:54:44 -04:00
Aleksandr Lesnenko
dfeafc9489 do not show bubble menu on disallowed nodes 2025-08-08 01:42:49 -03:00
Aleksandr Lesnenko
9f0c6d1e2e fix replace question 2025-08-08 01:40:50 -03:00
Aleksandr Lesnenko
e716d1d253 clean 2025-08-08 01:37:28 -03:00
Alexander Polyankin
395123c21a Small fixes [skip ci] 2025-08-08 00:36:57 -04:00
Alexander Polyankin
2c8431a93f Small fixes [skip ci] 2025-08-08 00:14:30 -04:00
Alexander Polyankin
7c8b01bc2a Small fixes [skip ci] 2025-08-08 00:12:04 -04:00
Alexander Polyankin
1cd4f17d50 Small fixes [skip ci] 2025-08-07 23:57:05 -04:00
Alexander Polyankin
48fb2b2209 Small fixes [skip ci] 2025-08-07 23:53:15 -04:00
Alexander Polyankin
8a12220a39 Small fixes [skip ci] 2025-08-07 23:52:03 -04:00
Alexander Polyankin
57c293888b Small fixes [skip ci] 2025-08-07 23:43:52 -04:00
Alexander Polyankin
020704ae57 Small fixes [skip ci] 2025-08-07 23:36:13 -04:00
Alexander Polyankin
662d3108fe Small fixes [skip ci] 2025-08-07 23:33:14 -04:00
Alexander Polyankin
dd4cb65777 Small fixes [skip ci] 2025-08-07 23:29:18 -04:00
Alexander Polyankin
06dbbc933b Small fixes [skip ci] 2025-08-07 23:21:24 -04:00
Alexander Polyankin
8b67a9af32 Small fixes [skip ci] 2025-08-07 23:18:07 -04:00
Alexander Polyankin
ef33301a24 Small fixes [skip ci] 2025-08-07 23:15:37 -04:00
Alexander Polyankin
8436f740a3 Small fixes [skip ci] 2025-08-07 23:13:34 -04:00
Alexander Polyankin
3f1be1386e Small fixes [skip ci] 2025-08-07 23:08:08 -04:00
Alexander Polyankin
699e763783 Small fixes [skip ci] 2025-08-07 23:03:45 -04:00
Alexander Polyankin
6ea4eb6a44 Small fixes [skip ci] 2025-08-07 22:38:12 -04:00
Alexander Polyankin
f136645482 Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-07 22:31:53 -04:00
Alexander Polyankin
f866fecdfe Small fixes [skip ci] 2025-08-07 22:31:42 -04:00
Alexander Polyankin
c8384764ff Small fixes [skip ci] 2025-08-07 22:31:29 -04:00
Alexander Polyankin
7f8908dba5 Small fixes [skip ci] 2025-08-07 22:11:34 -04:00
Timothy Dean
7e7670e57e [ci skip] CRUD endpoints for jobs and tags. Will need cleanup. 2025-08-07 19:44:42 -06:00
Alexander Polyankin
fa72a54d9f Small fixes [skip ci] 2025-08-07 21:26:08 -04:00
Aleksandr Lesnenko
f7c8221468 improved mention and command 2025-08-07 22:10:42 -03:00
Alexander Polyankin
668b2fac9f Small fixes [skip ci] 2025-08-07 20:02:17 -04:00
Alexander Polyankin
81b69b44df Small fixes [skip ci] 2025-08-07 19:58:35 -04:00
Alexander Polyankin
bee4ba9eea Small fixes [skip ci] 2025-08-07 19:58:07 -04:00
Alexander Polyankin
9f5a37f138 Small fixes [skip ci] 2025-08-07 19:42:18 -04:00
William Zimrin
9dbcefd21b can execute groups of transforms 2025-08-07 16:08:57 -05:00
William Zimrin
098ff0271b move execute-jobs to jobs ns 2025-08-07 16:08:57 -05:00
Brad Anderson
4135955236 UXW-461 Save button always shows up for new documents (and user has necessary permissions) 2025-08-07 16:24:52 -04:00
Timothy Dean
45c5d75dd3 Add table-existence impls for all transforms dbs (and maybe more) 2025-08-07 14:20:04 -06:00
Brad Anderson
b2a0c144ba UXW-461 Prevents jump when Save button appears/disappears. Also adds subtle fade-in transition when it appears. 2025-08-07 16:13:11 -04:00
Alexander Polyankin
480c4b125f Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-07 15:50:48 -04:00
Alexander Polyankin
81e95f8f1e UI changes [skip ci] 2025-08-07 15:50:35 -04:00
Alexander Polyankin
9b56f27bf9 UI changes [skip ci] 2025-08-07 15:44:49 -04:00
William Zimrin
a3e2c79df6 transform/execute is synchronous again 2025-08-07 14:43:58 -05:00
Brad Anderson
9a56a9e96b UXW-459 Fix issues with LeaveRouteConfirmModal and new documents 2025-08-07 15:25:57 -04:00
Eric Normand
96beaa2f89 [ci skip] Little helper function with friendly error message. 2025-08-07 14:20:11 -05:00
Alexander Polyankin
9d1cc1cf48 UI changes [skip ci] 2025-08-07 15:18:59 -04:00
Nick Fitzpatrick
436840eac9 hide object detail row in document tables 2025-08-07 16:18:07 -03:00
Eric Normand
ed9e470184 Move model access in worker_run ns 2025-08-07 14:14:23 -05:00
Alexander Polyankin
7691c2e2b6 UI changes [skip ci] 2025-08-07 14:53:28 -04:00
Eric Normand
c66c44768c [ci skip] Better api for canceling transforms; plus dispatches to worker 2025-08-07 13:50:00 -05:00
Alexander Polyankin
efc7f4d468 UI changes [skip ci] 2025-08-07 14:48:54 -04:00
Alexander Polyankin
460ab95a57 UI changes [skip ci] 2025-08-07 14:40:39 -04:00
Alexander Polyankin
79788a1297 UI changes [skip ci] 2025-08-07 14:37:31 -04:00
Timothy Dean
7cda572393 Add dummy endpoints for jobs and tags 2025-08-07 12:32:39 -06:00
Eric Normand
0431ed8174 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-07 13:30:53 -05:00
Eric Normand
4962055f41 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-07 13:29:03 -05:00
Alexander Polyankin
e483ea3163 UI changes [skip ci] 2025-08-07 14:28:58 -04:00
Brad Anderson
ab0bd1b8ff UXW-459 Fix issue where creating a new document when starting from another document overwrites the existing document (and shows previous document's metadata in new document) 2025-08-07 14:27:13 -04:00
Alexander Polyankin
cbf95208b7 UI changes [skip ci] 2025-08-07 14:26:17 -04:00
Eric Normand
ad20bb5d6c [ci skip] worker cancelations 2025-08-07 13:25:28 -05:00
Alexander Polyankin
9a50f3d2ab UI changes [skip ci] 2025-08-07 14:14:22 -04:00
Alexander Polyankin
bbaf599e92 UI changes [skip ci] 2025-08-07 14:06:46 -04:00
Alexander Polyankin
90a22c7a8d UI changes [skip ci] 2025-08-07 14:02:50 -04:00
Alexander Polyankin
7c7293b2be UI changes [skip ci] 2025-08-07 13:44:19 -04:00
Nick Fitzpatrick
c800fb73d0 Handle URL slugs, remove version code from document page, handle 404 and 403 2025-08-07 14:43:28 -03:00
Brad Anderson
0463d74f76 UXW-453 Fix 404 when clicking draft card title 2025-08-07 13:36:34 -04:00
Alexander Polyankin
66959e5d16 UI changes [skip ci] 2025-08-07 13:22:04 -04:00
Alexander Polyankin
69d648c26d UI changes [skip ci] 2025-08-07 13:14:09 -04:00
Alexander Polyankin
93d5d7f87b UI changes [skip ci] 2025-08-07 13:09:51 -04:00
Alexander Polyankin
8ca79acb03 UI changes [skip ci] 2025-08-07 13:06:13 -04:00
Eric Normand
9996b90fed [ci skip] Various todos 2025-08-07 12:04:47 -05:00
Eric Normand
fd76d041e6 [ci skip] Disable invariant checks 2025-08-07 12:04:24 -05:00
Nick Fitzpatrick
f8924beba5 add title to untitled docs 2025-08-07 13:54:45 -03:00
Alexander Polyankin
d3d47d423e UI changes [skip ci] 2025-08-07 12:47:56 -04:00
Alexander Polyankin
f511b1d271 UI changes [skip ci] 2025-08-07 12:37:16 -04:00
Alexander Polyankin
c33c1980e1 UI changes [skip ci] 2025-08-07 12:35:00 -04:00
Alexander Polyankin
3515dff0e5 UI changes [skip ci] 2025-08-07 12:34:39 -04:00
Edward Paget
92eb7e62a1 fix: add entity id column to documents 2025-08-07 11:27:27 -05:00
Brad Anderson
6351f1c4ef Replaces hard-coded metabot-generated card title with title from the /generate-content response (or translates a default title if none provided) 2025-08-07 12:25:19 -04:00
Alexander Polyankin
fc5d2d764a UI changes [skip ci] 2025-08-07 12:10:52 -04:00
Alexander Polyankin
55b2dc2b8e Merge remote-tracking branch 'origin/poc-transform-table' into poc-transform-table 2025-08-07 12:09:55 -04:00
Alexander Polyankin
79e78c00a8 UI changes [skip ci] 2025-08-07 12:09:40 -04:00
William Zimrin
4adaef819b added execute-jobs function 2025-08-07 10:44:05 -05:00
Edward Paget
eb8f2d91d0 fix: foreign key indexes 2025-08-07 10:34:07 -05:00
Aleksandr Lesnenko
8c5adc5f85 fixed drills & query editing for metabot cards 2025-08-07 10:37:14 -04:00
Brad Anderson
a792b1706e Updates MetabotEmbed to use most up-to-date api contract 2025-08-07 10:37:14 -04:00
Brad Anderson
268352d02b Stubs out the necessary updates to make MetabotEmbed work with an UnsavedCard from the BE rather than a full Card 2025-08-07 10:37:14 -04:00
Brad Anderson
acafad1b6a Updates MetabotEmbed to use most up-to-date api contract 2025-08-07 10:37:14 -04:00
Brad Anderson
4f95b0ebb7 Updates MetabotEmbed "stop generating" functionality to work with regular (non-streaming) api call 2025-08-07 10:37:14 -04:00
Brad Anderson
92017cfe7c Updates MetabotEmbed to always use draft cards 2025-08-07 10:37:14 -04:00
Brad Anderson
9ee59951ae Updates MetabotEmbed to work with upcoming BE changes 2025-08-07 10:37:14 -04:00
Nick Fitzpatrick
403bf4eafd Show tooltip with precise time in document metadata 2025-08-07 11:12:24 -03:00
Nick Fitzpatrick
010af9dc10 undo moves should work once archived is boolean 2025-08-07 11:12:24 -03:00
Alexander Polyankin
95ef0b3aea UI changes [skip ci] 2025-08-07 09:51:45 -04:00
Alexander Polyankin
67df0b5946 UI changes [skip ci] 2025-08-07 09:49:32 -04:00
Alexander Polyankin
13049dab5d UI changes [skip ci] 2025-08-07 09:47:46 -04:00
Alexander Polyankin
563dafbfd7 UI changes [skip ci] 2025-08-07 09:37:28 -04:00
Edward Paget
8c1e179da7 fix: bad var reference in tests 2025-08-07 08:36:37 -05:00
Alexander Polyankin
961097c64f UI changes [skip ci] 2025-08-07 09:35:45 -04:00
Alexander Polyankin
9bea09d575 UI changes [skip ci] 2025-08-07 09:08:38 -04:00
Alexander Polyankin
949903738d UI changes [skip ci] 2025-08-06 23:20:32 -04:00
Alexander Polyankin
569be3656e UI changes [skip ci] 2025-08-06 23:07:58 -04:00
Alexander Polyankin
ee4716edb1 UI changes [skip ci] 2025-08-06 23:01:20 -04:00
Alexander Polyankin
0697911466 UI changes [skip ci] 2025-08-06 22:57:30 -04:00
Alexander Polyankin
2421008499 UI changes [skip ci] 2025-08-06 22:54:40 -04:00
Alexander Polyankin
94dc7d1003 UI changes [skip ci] 2025-08-06 22:44:31 -04:00
Alexander Polyankin
e3ef53bc14 UI changes [skip ci] 2025-08-06 22:40:07 -04:00
Alexander Polyankin
cc407adf92 UI changes [skip ci] 2025-08-06 22:35:50 -04:00
Alexander Polyankin
20abdf0c68 UI changes [skip ci] 2025-08-06 22:17:50 -04:00
Alexander Polyankin
04c5a5def6 UI changes [skip ci] 2025-08-06 22:16:37 -04:00
Alexander Polyankin
81291b0432 UI changes [skip ci] 2025-08-06 22:01:40 -04:00
Alexander Polyankin
5343a095ff UI changes [skip ci] 2025-08-06 21:59:27 -04:00
Alexander Polyankin
02c1bb9fff UI changes [skip ci] 2025-08-06 21:55:24 -04:00
Alexander Polyankin
9ced87a83a UI changes [skip ci] 2025-08-06 21:40:58 -04:00
Alexander Polyankin
0d436b0fb5 UI changes [skip ci] 2025-08-06 21:32:39 -04:00
Alexander Polyankin
937494ff3b UI changes [skip ci] 2025-08-06 21:31:22 -04:00
Aleksandr Lesnenko
40368f85fb l10n 2025-08-06 22:29:48 -03:00
Aleksandr Lesnenko
978e12c2d6 swap icon 2025-08-06 22:26:38 -03:00
Alexander Polyankin
e82e59a698 UI changes [skip ci] 2025-08-06 21:21:01 -04:00
Alexander Polyankin
36088d2f62 UI changes [skip ci] 2025-08-06 21:07:37 -04:00
Alexander Polyankin
4bd1a0ddee UI changes [skip ci] 2025-08-06 20:44:46 -04:00
Aleksandr Lesnenko
ea4c347006 UXW-416: replace question should replace only 1 question 2025-08-06 19:57:52 -03:00
Aleksandr Lesnenko
5556654506 UXW-400: formatting floating menu 2025-08-06 19:19:34 -03:00
Eric Normand
6dad74e2f6 [ci skip] More cancelation organization 2025-08-06 17:00:51 -05:00
Nathan Voxland
4ea6c16f19 Merge remote-tracking branch 'origin/feature/reports' into feature/reports 2025-08-06 16:48:59 -05:00
Nathan Voxland
6620040235 New metabot documents API 2025-08-06 16:48:47 -05:00
Edward Paget
f809d7747c fix: module sort 2025-08-06 16:36:22 -05:00
Edward Paget
181e8f31d2 fix: tests and kondos 2025-08-06 16:21:47 -05:00
Eric Normand
32295f47b9 [ci skip] Make cancelation table that acts as queue 2025-08-06 15:56:39 -05:00
Edward Paget
83bd4ba534 Merge branch 'master' into feature/reports 2025-08-06 15:50:29 -05:00
Edward Paget
f770d21cdf fix: for OSS tests 2025-08-06 15:48:48 -05:00
Edward Paget
f6f6b2d3a9 fix: ordering of migrations for mysql 2025-08-06 15:36:19 -05:00
Edward Paget
745ab32bde fix: tests and lints 2025-08-06 15:33:44 -05:00
Nick Fitzpatrick
6760658aec App Bar Breadcrumbs for documents 2025-08-06 17:18:47 -03:00
Eric Normand
3d1b366ecc [ci skip] Invariants on is_active field in WorkerRun 2025-08-06 15:11:48 -05:00
Edward Paget
bad6bccac2 fix: require non-blank string for name 2025-08-06 15:03:50 -05:00
Edward Paget
7448009633 feat: add document delete endpoint 2025-08-06 14:59:33 -05:00
Edward Paget
7418a137c8 fix: always add documents to bookmark lookup 2025-08-06 14:42:37 -05:00
Edward Paget
40269ca9c0 fix: add perms info to documents in collections 2025-08-06 14:41:31 -05:00
Eric Normand
f2f7857cae [ci skip] various fixes 2025-08-06 14:41:03 -05:00
Eric Normand
123793d2be [ci skip] convert hard-coded map to multimethod 2025-08-06 14:25:15 -05:00
Edward Paget
42bee66fba fix: hide documents in collection items if not EE 2025-08-06 14:05:14 -05:00
Edward Paget
76ee0b6f8c feat: allow documents to be bookmarked 2025-08-06 13:52:35 -05:00
William Zimrin
15e8dfa0d2 expanded ordering tests 2025-08-06 13:47:20 -05:00
Eric Normand
2da623bf82 Merge branch 'poc-transform-table' of github.com:metabase/metabase into poc-transform-table 2025-08-06 13:40:18 -05:00
Eric Normand
1cb4ccc689 [ci skip] Move sync back to worker module 2025-08-06 13:20:14 -05:00
Aleksandr Lesnenko
912aca8811 fix UXW-412 again 2025-08-06 15:13:33 -03:00
Brad Anderson
2e9f07ede2 Fix issue where submitting empty metabot prompt (by hitting "Run" or cmd+enter) makes it spin forever 2025-08-06 14:00:11 -04:00
Nick Fitzpatrick
9568b9956d Document Collection Item Caching 2025-08-06 14:23:13 -03:00
Edward Paget
80867e1335 feat: allow documents to be archived 2025-08-06 11:49:50 -05:00
William Zimrin
ac6a903e3f add default schema support 2025-08-06 11:15:03 -05:00
William Zimrin
28459254bc move more native parsing code into the driver 2025-08-06 11:07:37 -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
402 changed files with 64376 additions and 754 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
@@ -643,6 +644,7 @@
:macroexpand
{clojurewerkz.quartzite.jobs/build macros.quartz/build-job
clojurewerkz.quartzite.schedule.calendar-interval/build macros.quartz/build-job
clojurewerkz.quartzite.schedule.cron/schedule macros.quartz/schedule
clojurewerkz.quartzite.schedule.simple/schedule macros.quartz/simple-schedule
clojurewerkz.quartzite.triggers/build macros.quartz/build-trigger

File diff suppressed because it is too large Load Diff

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

@@ -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,6 +29,7 @@
[metabase-enterprise.scim.routes]
[metabase-enterprise.serialization.api]
[metabase-enterprise.stale.api]
[metabase-enterprise.transforms.api]
[metabase-enterprise.upload-management.api]
[metabase.api.macros :as api.macros]
[metabase.api.util.handlers :as handlers]
@@ -49,9 +51,11 @@
: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")})
@@ -98,9 +102,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)
"/transform" (premium-handler metabase-enterprise.transforms.api/routes :transforms)
"/transform-job" (premium-handler metabase-enterprise.transforms.api/transform-job-routes :transforms)
"/transform-tag" (premium-handler metabase-enterprise.transforms.api/transform-tag-routes :transforms)
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)})
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑

View File

@@ -0,0 +1,10 @@
(ns metabase-enterprise.bookmarks.models.bookmark
"Enterprise DocumentBookmark model. Provides bookmarking functionality for documents."
(:require
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/DocumentBookmark [_model] :document_bookmark)
(doto :model/DocumentBookmark
(derive :metabase/model))

View File

@@ -11,9 +11,12 @@
[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]
[metabase-enterprise.worker.init]))

View File

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

View File

@@ -0,0 +1,227 @@
(ns metabase-enterprise.documents.api.document
"`/api/ee/document/` routes"
(: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.core :as card]
[metabase.query-permissions.core :as query-perms]
[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]
{:items (t2/hydrate (t2/select :model/Document {:where [:and
(collection/visible-collection-filter-clause)
[:= :archived false]]})
:creator :can_write)})
(api.macros/defendpoint :post "/"
"Create a new `Document`."
[_route-params
_query-params
{:keys [name document collection_id cards]}
:- [:map
[:name ms/NonBlankString]
[: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} ms/NonBlankString]
[:document {:optional true} :any]
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:cards {:optional true} [:maybe [:map-of :int CardCreateSchema]]]
[:archived {:optional true} [:maybe :boolean]]]]
(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))
;; Handle archiving logic
(let [document-updates (dissoc (api/updates-with-archived-directly existing-document body) :cards)]
(t2/with-transaction [_conn]
(t2/update! :model/Document document-id
(cond-> document-updates
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 appropriate events
(if (:archived document-updates)
(events/publish-event! :event/document-delete
{:object updated-document
:user-id api/*current-user-id*})
(events/publish-event! :event/document-update
{:object updated-document
:user-id api/*current-user-id*}))
updated-document))))
(api.macros/defendpoint :delete "/:document-id"
"Permanently deletes an archived Document."
[{:keys [document-id]} :- [:map [:document-id ms/PositiveInt]]]
(let [document (api/check-404 (t2/select-one :model/Document :id document-id))]
(api/check-403 (mi/can-write? document))
(when-not (:archived document)
(let [msg (tru "Document must be archived before it can be deleted.")]
(throw (ex-info msg {:status-code 400, :errors {:archived msg}}))))
(t2/delete! :model/Document :id document-id)
(events/publish-event! :event/document-delete
{:object document
:user-id api/*current-user-id*})
api/generic-204-no-content))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/document/` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,11 @@
(ns metabase-enterprise.documents.core
(:require
[metabase-enterprise.documents.api.document]
[potemkin :as p]))
(comment
metabase-enterprise.documents.api.document/keep-me)
(p/import-vars
[metabase-enterprise.documents.api.document
get-document])

View File

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

View File

@@ -0,0 +1,63 @@
(ns metabase-enterprise.documents.models.document
(:require
[metabase.api.common :as api]
[metabase.collections.models.collection :as collection]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[metabase.users.models.user]
[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?)
(derive :hook/entity-id))
(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.
If the document is archived, also archives all associated cards.
Returns the number of cards updated."
[document-id collection-id & {:keys [archived archived-directly]}]
(let [update-map {:collection_id collection-id :archived (boolean archived) :archived_directly (boolean archived-directly)}]
(t2/update! :model/Card
:document_id document-id
update-map)))
(t2/define-after-update :model/Document
[{:keys [id collection_id archived archived_directly]}]
;; Sync cards to match document's collection and archival status
(sync-document-cards-collection! id collection_id :archived archived :archived-directly archived_directly))
;;; ------------------------------------------------ Serdes Hashing -------------------------------------------------
(defmethod serdes/hash-fields :model/Document
[_table]
[:name (serdes/hydrated-hash :collection) :created-at])

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,24 @@
(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.core :as revisions]))
(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 revisions/serialize-instance :model/Document
[_model _id instance]
(apply dissoc instance excluded-columns-for-document-revision))

View File

@@ -3,6 +3,7 @@
(:require
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.api.document]
[metabase-enterprise.metabot-v3.api.metabot]
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
@@ -93,4 +94,5 @@
(handlers/routes
(api.macros/ns-handler *ns* +auth)
(handlers/route-map-handler
{"/metabot" metabase-enterprise.metabot-v3.api.metabot/routes})))
{"/metabot" metabase-enterprise.metabot-v3.api.metabot/routes
"/document" metabase-enterprise.metabot-v3.api.document/routes})))

View File

@@ -0,0 +1,23 @@
(ns metabase-enterprise.metabot-v3.api.document
"`/api/ee/metabot-v3/document` routes"
(:require
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.util.malli.schema :as ms]))
(api.macros/defendpoint :post "/generate-content"
"Create a new piece of content to insert into the document."
[_route-params
_query-params
body :- [:map
[:instructions ms/NonBlankString]]]
(metabot-v3.client/document-generate-content {:instructions (:instructions body)
:user_id api/*current-user-id*
:conversation_id (str (random-uuid))}))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-v3/document` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -1,12 +1,16 @@
(ns metabase-enterprise.metabot-v3.client
(:require
[buddy.core.hash :as buddy-hash]
[buddy.sign.jwt :as jwt]
[clj-http.client :as http]
[clj-time.core :as time]
[clojure.java.io :as io]
[clojure.set :as set]
[clojure.string :as str]
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
@@ -26,6 +30,18 @@
(def ^:dynamic ^:private *debug* false)
(defn get-ai-service-token
"Get the token for the AI service."
([]
(get-ai-service-token api/*current-user-id* metabot-v3.config/internal-metabot-id))
([user-id metabot-id]
(let [secret (buddy-hash/sha256 (metabot-v3.settings/site-uuid-for-metabot-tools))
claims {:user user-id
:exp (time/plus (time/now) (time/seconds (metabot-v3.settings/metabot-ai-service-token-ttl)))
:metabot-id metabot-id}]
(jwt/encrypt claims secret {:alg :dir, :enc :a128cbc-hs256}))))
(defn- ->json-bytes ^bytes [x]
(with-open [os (java.io.ByteArrayOutputStream.)
w (java.io.OutputStreamWriter. os)]
@@ -91,6 +107,9 @@
(defn- example-question-generation-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/example-question-generation/batch"))
(defn- document-generate-content-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/document/generate-content"))
(mu/defn request :- ::metabot-v3.client.schema/ai-service.response
"Make a V2 request to the AI Service."
[{:keys [context messages profile-id conversation-id session-id state]}
@@ -280,6 +299,23 @@
{:request (assoc options :body body)
:response response})))))
(mu/defn document-generate-content
"Ask the AI service to generate a new node for a document."
[body :- [:map
[:instructions :string]]]
(let [url (document-generate-content-endpoint)
options (build-request-options body)
headers (merge (:headers options) {"x-metabase-session-token" (get-ai-service-token)
"x-metabase-url" (system/site-url)})
options (assoc options :headers headers)
response (post! url options)]
(if (= (:status response) 200)
(:body response)
(throw (ex-info (format "Error in request to AI service: unexpected status code: %d %s"
(:status response) (:reason-phrase response))
{:request (assoc options :body body)
:response response})))))
(def chart-analysis-schema
"Schema for chart analysis data input."
[:map

View File

@@ -1,6 +1,7 @@
(ns metabase-enterprise.metabot-v3.dummy-tools
(:require
[medley.core :as m]
[metabase-enterprise.documents.core :as documents]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.legacy-mbql.normalize :as mbql.normalize]
@@ -14,6 +15,8 @@
[metabase.warehouse-schema.models.field-values :as field-values]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn get-current-user
"Get information about the current user."
[]
@@ -289,6 +292,20 @@
{: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/get-document document-id)]
{: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

@@ -3,7 +3,6 @@
(:require
[buddy.core.hash :as buddy-hash]
[buddy.sign.jwt :as jwt]
[clj-time.core :as time]
[clojure.set :as set]
[malli.core :as mc]
[malli.transform :as mtx]
@@ -35,14 +34,6 @@
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]))
(defn- get-ai-service-token
[user-id metabot-id]
(let [secret (buddy-hash/sha256 (metabot-v3.settings/site-uuid-for-metabot-tools))
claims {:user user-id
:exp (time/plus (time/now) (time/seconds (metabot-v3.settings/metabot-ai-service-token-ttl)))
:metabot-id metabot-id}]
(jwt/encrypt claims secret {:alg :dir, :enc :a128cbc-hs256})))
(defn- decode-ai-service-token
[token]
(try
@@ -55,7 +46,7 @@
(defn handle-envelope
"Executes the AI loop in the context of a new session. Returns the response of the AI service."
[{:keys [metabot-id] :as e}]
(let [session-id (get-ai-service-token api/*current-user-id* metabot-id)]
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
(try
(metabot-v3.client/request (assoc e :session-id session-id))
(catch Exception ex
@@ -69,7 +60,7 @@
(defn streaming-handle-envelope
"Executes the AI loop in the context of a new session. Returns the response of the AI service."
[{:keys [metabot-id] :as e}]
(let [session-id (get-ai-service-token api/*current-user-id* metabot-id)]
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
(metabot-v3.client/streaming-request (assoc e :session-id session-id))))
(mr/def ::bucket
@@ -540,6 +531,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 +786,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

@@ -16,6 +16,7 @@
"Card"
"Collection"
"Dashboard"
"Document"
"NativeQuerySnippet"
"Timeline"])
@@ -57,6 +58,8 @@
"DatabaseRouter"
"DashboardBookmark"
"DataPermissions"
"Document"
"DocumentBookmark"
"GroupTableAccessPolicy"
"HTTPAction"
"ImplicitAction"

View File

@@ -0,0 +1,219 @@
(ns metabase-enterprise.transforms.api
(:require
[clojure.set :as set]
[metabase-enterprise.transforms.api.transform-job]
[metabase-enterprise.transforms.api.transform-tag]
[metabase-enterprise.transforms.execute :as transforms.execute]
[metabase-enterprise.transforms.models.transform :as transform.model]
[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.request.core :as request]
[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])
(:import (java.util.concurrent
Executors
ExecutorService)))
(comment metabase-enterprise.transforms.api.transform-job/keep-me
metabase-enterprise.transforms.api.transform-tag/keep-me)
(set! *warn-on-reflection* true)
(defonce ^:private ^ExecutorService executor (Executors/newVirtualThreadPerTaskExecutor))
(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 :transform_tag_ids))
(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]
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]]]
(api/check-superuser)
(check-database-feature body)
(when (transforms.util/target-table-exists? body)
(api/throw-403))
(let [tag-ids (:tag_ids body)
transform (t2/insert-returning-instance!
:model/Transform (select-keys body [:name :description :source :target :execution_trigger]))]
;; Add tag associations if provided
(when (seq tag-ids)
(transform.model/update-transform-tags! (:id transform) tag-ids))
;; Return with hydrated tag_ids
(t2/hydrate transform :transform_tag_ids)))
(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 :transform_tag_ids)
(assoc :table target-table))))
(api.macros/defendpoint :get "/execution"
[params :-
[:map
[: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"
:offset (request/offset)
:limit (request/limit))))
:data #(mapv (fn [run]
(-> run
(set/rename-keys {:run_id :id
:work_id :transform_id
:run_method :trigger})))
%)))
(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]
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]]]
(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 (dissoc body :tag_ids))
;; Update tag associations if provided
(when (contains? body :tag_ids)
(transform.model/update-transform-tags! id (:tag_ids body)))
(t2/hydrate (t2/select-one :model/Transform id) :transform_tag_ids))
(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 "/:id/cancel"
[{:keys [id]} :- [:map
[:id :string]]]
(log/info "canceling transform " id)
(api/check-superuser)
(let [run (api/check-404 (worker/running-execution-for-work-id id "transform"))]
(if (:is_local run)
(worker/mark-cancel-started-run! (:run_id run))
(worker/cancel! (:run_id run))))
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)]
(.submit executor ^Runnable
(bound-fn* #(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)))
(def ^{:arglists '([request respond raise])} transform-tag-routes
"`/api/ee/transform-tag` routes."
(api.macros/ns-handler 'metabase-enterprise.transforms.api.transform-tag +auth))
(def ^{:arglists '([request respond raise])} transform-job-routes
"`/api/ee/transform-job` routes."
(api.macros/ns-handler 'metabase-enterprise.transforms.api.transform-job +auth))

View File

@@ -0,0 +1,156 @@
(ns metabase-enterprise.transforms.api.transform-job
(:require
[metabase-enterprise.transforms.jobs :as jobs]
[metabase-enterprise.transforms.models.transform-job :as transform-job]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2])
(:import
(java.util.concurrent
Executors
ExecutorService)
(org.quartz CronExpression)))
(set! *warn-on-reflection* true)
(comment transform-job/keep-me)
(defonce ^:private ^ExecutorService executor (Executors/newVirtualThreadPerTaskExecutor))
(api.macros/defendpoint :post "/"
"Create a new transform job."
[_route-params
_query-params
{:keys [name description schedule tag_ids]} :- [:map
[:name ms/NonBlankString]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:schedule ms/NonBlankString]
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]]]
(log/info "Creating transform job:" name "with schedule:" schedule)
(api/check-superuser)
;; Validate cron expression
(api/check-400 (try
(CronExpression/validateExpression schedule)
true
(catch Exception e
false))
(deferred-tru "Invalid cron expression: {0}" schedule))
;; Validate tag IDs exist if provided
(when (seq tag_ids)
(let [existing-tags (set (t2/select-pks-vec :model/TransformTag :id [:in tag_ids]))]
(api/check-400 (= (set tag_ids) existing-tags)
(deferred-tru "Some tag IDs do not exist"))))
(let [job (t2/insert-returning-instance! :model/TransformJob
{:name name
:description description
:schedule schedule})]
;; Add tag associations if provided
(when (seq tag_ids)
(t2/insert! :transform_job_tags
(for [tag-id tag_ids]
{:job_id (:id job)
:tag_id tag-id})))
;; Return with hydrated tag_ids
(t2/hydrate job :tag_ids)))
(api.macros/defendpoint :put "/:job-id"
"Update a transform job."
[{:keys [job-id]} :- [:map
[:job-id ms/PositiveInt]]
_query-params
{:keys [name description schedule tag_ids]} :- [:map
[:name {:optional true} ms/NonBlankString]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:schedule {:optional true} ms/NonBlankString]
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]]]
(log/info "Updating transform job" job-id)
(api/check-superuser)
;; Check job exists
(api/check-404 (t2/select-one :model/TransformJob :id job-id))
;; Validate cron expression if provided
(when schedule
(api/check-400 (try
(CronExpression/validateExpression schedule)
true
(catch Exception e
false))
(deferred-tru "Invalid cron expression: {0}" schedule)))
;; Validate tag IDs if provided
(when tag_ids
(let [existing-tags (set (t2/select-pks-vec :model/TransformTag :id [:in tag_ids]))]
(api/check-400 (= (set tag_ids) existing-tags)
(deferred-tru "Some tag IDs do not exist"))))
;; Update the job
(let [updates (cond-> {}
name (assoc :name name)
(some? description) (assoc :description description)
schedule (assoc :schedule schedule))]
(when (seq updates)
(t2/update! :model/TransformJob job-id updates)))
;; Update tag associations if provided
(when (some? tag_ids)
;; Delete existing associations
(t2/delete! :transform_job_tags :job_id job-id)
;; Add new associations
(when (seq tag_ids)
(t2/insert! :transform_job_tags
(for [tag-id tag_ids]
{:job_id job-id
:tag_id tag-id}))))
;; Return updated job with hydration
(-> (t2/select-one :model/TransformJob :id job-id)
(t2/hydrate :tag_ids)
(assoc :last_execution nil)))
(api.macros/defendpoint :delete "/:job-id"
"Delete a transform job."
[{:keys [job-id]} :- [:map [:job-id ms/PositiveInt]]]
(log/info "Deleting transform job" job-id)
(api/check-superuser)
(api/check-404 (t2/select-one :model/TransformJob :id job-id))
(t2/delete! :model/TransformJob :id job-id)
api/generic-204-no-content)
(api.macros/defendpoint :post "/:job-id/execute"
"Execute a transform job manually."
[{:keys [job-id]} :- [:map [:job-id ms/PositiveInt]]]
(log/info "Manual execution of transform job" job-id "(stub)")
(api/check-superuser)
(let [job (api/check-404 (t2/select-one :model/TransformJob :id job-id))]
(.submit executor
^Runnable #(jobs/execute-jobs! [job])))
{:message "Job execution started"
:job_run_id (str "stub-" job-id "-" (System/currentTimeMillis))})
(api.macros/defendpoint :get "/:job-id"
"Get a transform job by ID."
[{:keys [job-id]} :- [:map
[:job-id ms/PositiveInt]]]
(log/info "Getting transform job" job-id)
(api/check-superuser)
(let [job (api/check-404 (t2/select-one :model/TransformJob :id job-id))]
;; Hydrate tag_ids and add last_execution (null for now)
(-> job
(t2/hydrate :tag_ids)
(assoc :last_execution nil))))
(api.macros/defendpoint :get "/"
"Get all transform jobs."
[_route-params
_query-params]
(log/info "Getting all transform jobs")
(api/check-superuser)
(let [jobs (t2/select :model/TransformJob {:order-by [[:created_at :desc]]})]
;; Hydrate tag_ids for all jobs
;; Note: last_execution will be null until execution tracking is implemented
(map #(assoc % :last_execution nil)
(t2/hydrate jobs :tag_ids))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/transform-job` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,62 @@
(ns metabase-enterprise.transforms.api.transform-tag
(:require
[metabase-enterprise.transforms.models.transform-tag :as transform-tag]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(api.macros/defendpoint :post "/"
"Create a new transform tag."
[_route-params
_query-params
{:keys [name]} :- [:map
[:name ms/NonBlankString]]]
(log/info "Creating transform tag:" name)
(api/check-superuser)
(api/check-400 (not (transform-tag/tag-name-exists? name))
(deferred-tru "A tag with the name ''{0}'' already exists." name))
(t2/insert-returning-instance! :model/TransformTag {:name name}))
(api.macros/defendpoint :put "/:tag-id"
"Update a transform tag."
[{:keys [tag-id]} :- [:map
[:tag-id ms/PositiveInt]]
_query-params
{:keys [name]} :- [:map
[:name ms/NonBlankString]]]
(log/info "Updating transform tag" tag-id "with name:" name)
(api/check-superuser)
(api/check-404 (t2/select-one :model/TransformTag :id tag-id))
(api/check-400 (not (transform-tag/tag-name-exists-excluding? name tag-id))
(deferred-tru "A tag with the name ''{0}'' already exists." name))
(t2/update! :model/TransformTag tag-id {:name name})
(t2/select-one :model/TransformTag :id tag-id))
(api.macros/defendpoint :delete "/:tag-id"
"Delete a transform tag. Removes it from all transforms and jobs."
[{:keys [tag-id]} :- [:map
[:tag-id ms/PositiveInt]]]
(log/info "Deleting transform tag" tag-id)
(api/check-superuser)
(api/check-404 (t2/select-one :model/TransformTag :id tag-id))
(t2/delete! :model/TransformTag :id tag-id)
api/generic-204-no-content)
(api.macros/defendpoint :get "/"
"Get a list of all transform tags."
[_route-params
_query-params]
(log/info "Getting all transform tags")
(api/check-superuser)
(t2/select :model/TransformTag {:order-by [[:name :asc]]}))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/transform-tag` 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,205 @@
(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.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.util :as u]
[metabase.util.log :as log]
[metabase.util.malli.registry :as mr]
[toucan2.core :as t2])
(:import
(java.util.concurrent Executors ExecutorService)))
(set! *warn-on-reflection* true)
(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)))
;; should be run in virtual thread (please :)
(defn- execute-mbql-transform-remote! [run-id driver transform-details opts]
(try
(log/trace "starting remote transform run" (pr-str run-id))
(worker/execute-transform! run-id driver transform-details opts)
(catch Throwable t
(log/error t "Remote execution request failed; still syncing")))
;; poll the server in a loop and sync to database
;; TODO (eric): timeout this loop
(loop []
(Thread/sleep 2000)
(when (= "running" (:status (worker/sync-single-run! run-id)))
(recur))))
(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)))
(defn- execute-mbql-transform-local!
[run-id driver transform-details opts]
(worker/chan-start-timeout-vthread! run-id)
;; local run is responsible for status
(try
(binding [qp.pipeline/*canceled-chan* (a/promise-chan)]
(worker/chan-start-run! run-id qp.pipeline/*canceled-chan*)
(driver/execute-transform! driver transform-details opts))
(worker/succeed-started-run! run-id)
(catch Throwable t
(worker/fail-started-run! run-id {:message (.getMessage t)})
(throw t))
(finally
(worker/chan-end-run! run-id))))
(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))
;; TODO (eric): execute-transform!
(if (worker/run-remote?)
(execute-mbql-transform-remote! run-id driver transform-details opts)
(execute-mbql-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}))))))))

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,56 @@
(ns metabase-enterprise.transforms.jobs
(:require
[metabase-enterprise.transforms.execute :as execute]
[metabase-enterprise.transforms.ordering :as ordering]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(defn- get-deps [ordering transform-ids]
(loop [found #{}
[current-transform & more-transforms] transform-ids]
(if current-transform
(recur (conj found current-transform)
(if (found current-transform)
more-transforms
(into more-transforms (get ordering current-transform))))
found)))
(defn- next-transform [{:keys [ordering transforms-by-id]} complete]
(-> (ordering/available-transforms ordering #{} complete)
first
transforms-by-id))
(defn- get-plan [transform-ids]
(let [all-transforms (t2/select :model/Transform)
global-ordering (ordering/transform-ordering all-transforms)
relevant-ids (get-deps global-ordering transform-ids)]
{:transforms-by-id (into {}
(keep (fn [{:keys [id] :as transform}]
(when (relevant-ids id)
[id transform])))
all-transforms)
:ordering (select-keys global-ordering relevant-ids)}))
(defn execute-transforms! [transform-ids-to-run {:keys [run-method start-promise]}]
(let [plan (get-plan transform-ids-to-run)]
(when start-promise
(deliver start-promise :started))
(loop [complete #{}]
(when-let [current-transform (next-transform plan complete)]
(log/info "Executing job transform" (pr-str (:id current-transform)))
(execute/execute-mbql-transform! current-transform {:run-method run-method})
(recur (conj complete (:id current-transform)))))))
(defn execute-jobs!
[job-ids opts]
(let [transforms (t2/select-fn-set :transform_id
:transform_job_tags
{:select :transform_tags.transform_id
:from :transform_job_tags
:left-join [:transform_tags [:=
:transform_tags.tag_id
:transform_job_tags.tag_id]]
:where [:in :transform_job_tags.job_id job-ids]})]
(log/info "Executing transform jobs" (pr-str job-ids) "with transforms" (pr-str transforms))
(execute-transforms! transforms opts)))

View File

@@ -0,0 +1,76 @@
(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)))))))
(mi/define-batched-hydration-method transform-tag-ids
:transform_tag_ids
"Add tag_ids to a transform"
[transforms]
(if-not (seq transforms)
transforms
(let [transform-ids (into #{} (map :id) transforms)
tag-associations (when (seq transform-ids)
(t2/select [:transform_tags :transform_id :tag_id]
:transform_id [:in transform-ids]))
transform-id->tag-ids (reduce (fn [acc {:keys [transform_id tag_id]}]
(update acc transform_id (fnil conj []) tag_id))
{}
tag-associations)]
(for [transform transforms]
(assoc transform :tag_ids (vec (sort (get transform-id->tag-ids (:id transform) []))))))))
(defn update-transform-tags!
"Update the tags associated with a transform. Replaces all existing associations."
[transform-id tag-ids]
(when transform-id
(t2/with-transaction [_conn]
;; Delete existing associations
(t2/delete! :transform_tags :transform_id transform-id)
;; Add new associations
(when (seq tag-ids)
(let [valid-tag-ids (into #{} (t2/select-fn-set :id :model/TransformTag :id [:in tag-ids]))]
(when (seq valid-tag-ids)
(t2/insert! :transform_tags
(for [tag-id valid-tag-ids]
{:transform_id transform-id
:tag_id tag-id}))))))))
(defmethod worker/model->work-type :model/Transform
[_]
:transform)

View File

@@ -0,0 +1,25 @@
(ns metabase-enterprise.transforms.models.transform-job
(:require
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/TransformJob [_model] :transform_job)
(doto :model/TransformJob
(derive :metabase/model)
(derive :hook/entity-id)
(derive :hook/timestamped?))
(mi/define-batched-hydration-method tag-ids
:tag_ids
"Hydrate tag IDs for jobs"
[jobs]
(when (seq jobs)
(let [job-ids (map :id jobs)
tag-mappings (group-by :job_id
(t2/select [:transform_job_tags :job_id :tag_id]
:job_id [:in job-ids]))]
(for [job jobs]
(assoc job :tag_ids
(mapv :tag_id (get tag-mappings (:id job) [])))))))

View File

@@ -0,0 +1,20 @@
(ns metabase-enterprise.transforms.models.transform-tag
(:require
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/TransformTag [_model] :transform_tag)
(doto :model/TransformTag
(derive :metabase/model)
(derive :hook/timestamped?))
(defn tag-name-exists?
"Check if a tag with the given name already exists"
[tag-name]
(t2/exists? :model/TransformTag :name tag-name))
(defn tag-name-exists-excluding?
"Check if a tag with the given name exists, excluding the specified ID"
[tag-name tag-id]
(t2/exists? :model/TransformTag :name tag-name :id [:not= tag-id]))

View File

@@ -0,0 +1,101 @@
(ns metabase-enterprise.transforms.ordering
(:require
[clojure.core.match]
[clojure.set :as set]
[flatland.ordered.set :refer [ordered-set]]
[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- transform-deps [transform]
(let [query (-> (get-in transform [:source :query])
transforms.util/massage-sql-query
qp.preprocess/preprocess)]
(case (:type query)
:native (driver/native-query-deps (-> (qp.store/metadata-provider)
lib.metadata/database
:engine)
(get-in query [:native :query]))
: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,87 @@
(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)
{driver :engine :as database} (t2/select-one :model/Database db-id)]
(driver/table-exists? driver database target)))
(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,64 @@
(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]
(assert (worker-uri) "Env var MB_TRANSFORM_WORKER_URI must be set.")
(-> (worker-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
"Get the status of a run."
[run-id]
(json-body (http/get (worker-route (str "/status/" run-id "?mb-source=" (mb-id)))
{:content-type :json})))
(defn cancel!
"cancel on the remote worker."
[run-id]
(:body (http/post (worker-route (str "/cancel/" run-id "?mb-source=" (mb-id))))))
(defn health-check []
(:body (http/get (worker-route "/api/health"))))
(comment
(health-check)
(alter-var-root #'environ.core/env assoc
:mb-transform-worker-uri "http://localhost:3030")
(get-status "jjj")
(:status (ex-data *e)))

View File

@@ -0,0 +1,88 @@
(ns metabase-enterprise.worker.canceling
(:require
[clojure.core.async :as a]
[metabase-enterprise.worker.models.worker-run :as worker-run]
[metabase-enterprise.worker.models.worker-run-cancelation :as wr.cancelation]
[metabase-enterprise.worker.tracking :as tracking]
[metabase.task.core :as task]
[metabase.util.log :as log])
(: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))
(defonce ^:private connections (atom {}))
(defn chan-start-run! [run-id cancel-chan]
(swap! connections assoc run-id cancel-chan)
nil)
(defn chan-end-run! [run-id]
(-> (swap-vals! connections dissoc run-id)
first ;; old value
(get run-id)))
(defn chan-signal-cancel! [run-id]
(when-some [cancel-chan (chan-end-run! run-id)]
(a/put! cancel-chan :cancel!)
true))
(defn chan-start-timeout-vthread! [run-id]
(.submit executor ^Runnable (fn []
(Thread/sleep (* 4 60 60 1000)) ;; 4 hours
(chan-signal-cancel! run-id)
(worker-run/timeout-run! run-id))))
;; please do not export from module
(defn chan-start-timeout-vthread-worker-instance! [run-id]
(.submit executor ^Runnable (fn []
(Thread/sleep (* 4 60 60 1000)) ;; 4 hours
(chan-signal-cancel! run-id)
;; we don't store timeouts in worker db
)))
(defn- cancel-run-mb-instance! [run-id]
(when (chan-signal-cancel! run-id)
(worker-run/cancel-run! run-id)))
(defn- cancel-run-worker-instance! [run-id]
(when (chan-signal-cancel! run-id)
(tracking/track-cancel! run-id "Canceled by user")))
(defn schedule-cancel-runs!
"Start a scheduled task (not quartzite) that cancels tasks marked in worker_db. Should run only in the worker server."
[]
(.scheduleAtFixedRate scheduler
#(do
(try
(log/trace "Checking for canceling items.")
(run! (fn [{:keys [run_id]}]
(try
(cancel-run-worker-instance! run_id)
(catch Throwable t
(log/error t (str "Error canceling " run_id)))))
(wr.cancelation/reducible-canceled-local-runs))
(catch Throwable t
(log/error t "Error while canceling on worker.")))
(try
(tracking/cancel-old-cancelations!)
(catch Throwable t
(log/error t "Error canceling old runs."))))
0 20 TimeUnit/SECONDS))
(defmethod task/init! ::CancelRuns [_]
;; does not use the Quartz scheduler, should only run on the mb instance
(.scheduleAtFixedRate scheduler
#(try
(log/trace "Checking for canceling items.")
(run! (fn [{:keys [run_id]}]
(try
(cancel-run-mb-instance! run_id)
(catch Throwable t
(log/error t (str "Error canceling " run_id)))))
(wr.cancelation/reducible-canceled-local-runs))
(catch Throwable t
(log/error t "Error while canceling on worker."))) 0 20 TimeUnit/SECONDS))

View File

@@ -0,0 +1,46 @@
(ns metabase-enterprise.worker.core
(:require
[metabase-enterprise.worker.api]
[metabase-enterprise.worker.canceling]
[metabase-enterprise.worker.models.worker-run]
[metabase-enterprise.worker.models.worker-run-cancelation]
[metabase-enterprise.worker.server]
[metabase-enterprise.worker.sync]
[metabase-enterprise.worker.tracking]
[potemkin :as p]))
(p/import-vars
[metabase-enterprise.worker.api
cancel!
execute-transform!
get-status
run-remote?]
[metabase-enterprise.worker.canceling
chan-end-run!
chan-signal-cancel!
chan-start-run!
chan-start-timeout-vthread!]
[metabase-enterprise.worker.models.worker-run
cancel-old-canceling-runs!
cancel-run!
fail-started-run!
inactive-runs
latest-runs
model->work-type
paged-executions
reducible-active-remote-runs
running-execution-for-work-id
start-run!
succeed-started-run!
timeout-old-runs!
timeout-run!]
[metabase-enterprise.worker.models.worker-run-cancelation
mark-cancel-started-run!
reducible-canceled-local-runs
delete-cancelation!
delete-old-canceling-runs!]
[metabase-enterprise.worker.server
start!
stop!]
[metabase-enterprise.worker.sync
sync-single-run!])

View File

@@ -0,0 +1,3 @@
(ns metabase-enterprise.worker.init
(:require
[metabase-enterprise.worker.canceling]))

View File

@@ -0,0 +1,239 @@
(ns metabase-enterprise.worker.models.worker-run
(:require
[metabase-enterprise.worker.models.worker-run-cancelation :as cancel]
[metabase.app-db.core :as mdb]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.models.interface :as mi]
[metabase.util :as u]
[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})
(defn- check-is-active [{:keys [status is_active] :as run}]
(when (= (keyword status) :started)
(when (not is_active)
(throw (ex-info "If WorkerRun has started status, is_active should be true"
{:run run}))))
(when is_active
(when (not= (keyword status) :started)
(throw (ex-info "If WorkerRun is_active = true, should have started status"
{:run run})))))
#_(t2/define-before-insert :model/WorkerRun
[run]
(check-is-active run)
run)
#_(t2/define-before-update :model/WorkerRun
[run]
(check-is-active run)
run)
(defmulti model->work-type
"Convert the toucan model name to a keyword representing the type of work."
{:arglists '([model-name])}
identity)
(defmethod model->work-type :default
[model-name]
(throw (ex-info "No implementation of model->work-type for this model name."
{:model-name model-name})))
(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]
(u/prog1 (t2/update! :model/WorkerRun
:run_id run-id
:is_active true
(merge {:end_time :%now}
properties
{:status :succeeded
:is_active nil}))
(cancel/delete-old-canceling-runs!))))
(defn fail-started-run!
"Mark the started active run as failed and inactive."
[run-id properties]
(u/prog1 (t2/update! :model/WorkerRun
:run_id run-id
:is_active true
(merge {:end_time :%now}
properties
{:status :failed
:is_active nil}))
(cancel/delete-old-canceling-runs!)))
(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 run."
([run-id]
(cancel-run! run-id {:message "Canceled by user"}))
([run-id properties]
(u/prog1 (t2/update! :model/WorkerRun
:run_id run-id
:is_active true
(merge {:end_time :%now}
properties
{:status :canceled
:is_active nil}))
(cancel/delete-old-canceling-runs!))))
(defn timeout-run!
"Mark a started run as timed out."
([run-id]
(timeout-run! run-id {}))
([run-id properties]
(u/prog1 (t2/update! :model/WorkerRun
:run_id run-id
:is_active true
(merge {:end_time :%now
:message "Timed out"}
properties
{:status :timeout
:is_active nil}))
(cancel/delete-old-canceling-runs!))))
(defn timeout-old-runs!
"Time out all active runs older than the specified age."
[age unit]
(u/prog1 (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"})
(cancel/delete-old-canceling-runs!)))
(defn cancel-old-canceling-runs!
"Cancel all canceling runs older than the specified age."
[age unit]
(u/prog1 (t2/update! :model/WorkerRun
:is_active true
:run_id [:in {:select :run_id
:from :worker_run_cancelation
:where [:<
:worker_run_cancelation.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."})
(cancel/delete-old-canceling-runs!)))
(defn running-execution-for-work-id
"Return a single active work run or nil."
[id work-type]
(t2/select-one :model/WorkerRun
:work_id id
:work_type work-type
:is_active true))
(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,46 @@
(ns metabase-enterprise.worker.models.worker-run-cancelation
(: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/WorkerRunCancelation [_model] :worker_run_cancelation)
(derive :model/WorkerRunCancelation :metabase/model)
(defn mark-cancel-started-run!
"Mark a started run for cancelation."
([run-id]
(mark-cancel-started-run! run-id {}))
([run-id properties]
(t2/insert! :model/WorkerRunCancelation
(assoc properties
:run_id run-id)
{:where [:exists {:select [1]
:from [:worker_run]
:where [:and
[:= :worker_run.run_id run-id]
:worker_run.is_active]}]})))
(defn reducible-canceled-local-runs
"Return a reducible sequence of local canceled runs."
[]
(t2/reducible-select :model/WorkerRunCancelation))
(defn delete-cancelation!
"Delete a cancelation once it has been handled."
[run-id]
(t2/delete! :model/WorkerRunCancelation
:run-id run-id))
(defn delete-old-canceling-runs!
"Delete cancelations that are no longer running."
[]
(t2/delete! :model/WorkerRunCancelation
:run_id [:not-in {:select :wrc.run_id
:from [[:worker_run_cancelation :wrc]]
:join [[:worker_run :wr] [:= :wr.run_id :wrc.run_id]]
:where :wr.is_active}]))

View File

@@ -0,0 +1,152 @@
(ns metabase-enterprise.worker.server
(:require
[clojure.core.async :as a]
[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.canceling :as canceling]
[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.query-processor.pipeline :as qp.pipeline]
[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 for run" (pr-str (:run-id params)))
(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
(canceling/chan-start-timeout-vthread-worker-instance! run-id)
(binding [qp.pipeline/*canceled-chan* (a/promise-chan)]
(canceling/chan-start-run! run-id qp.pipeline/*canceled-chan*)
(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
(canceling/chan-end-run! run-id)
(.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 for run" (pr-str run-id))
(if-let [resp (tracking/get-status run-id mb-source)]
(response/response resp)
(-> (response/response "Not found")
(response/status 404))))
(defn- handle-cancel-post
[run-id mb-source]
(log/info "Handling cancel POST request for run" (pr-str run-id))
(if (tracking/mark-cancel-started-run! run-id mb-source)
(-> (response/response "Canceling")
(response/status 202))
(-> (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))
(compojure/POST "/cancel/:run-id" [run-id mb-source] (handle-cancel-post 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))
(defonce ^:private cancel-runs (atom nil))
(defn stop! []
(when @instance
(log/info "Stopping worker server")
(.stop ^QueuedThreadPool @instance)
(reset! instance nil))
(when @cancel-runs
(.cancel ^Future @cancel-runs true)
(reset! cancel-runs 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))})))
(reset! cancel-runs (canceling/schedule-cancel-runs!))
nil))
(comment
(start! {:dev true})
(stop!))

View File

@@ -0,0 +1,86 @@
(ns metabase-enterprise.worker.sync
(:require
[clojurewerkz.quartzite.jobs :as jobs]
[clojurewerkz.quartzite.schedule.calendar-interval :as calendar-interval]
[clojurewerkz.quartzite.triggers :as triggers]
[java-time.api :as t]
[metabase-enterprise.worker.api :as api]
[metabase-enterprise.worker.models.worker-run :as worker-run]
[metabase.task.core :as task]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(def ^:private job-key "metabase-enterprise.worker.sync")
(defn sync-single-run!
"Fetch status from worker and sync to mb db"
[run-id]
(let [resp (api/get-status run-id)]
(case (:status resp)
"running"
:do-nothing
"canceled"
(worker-run/cancel-run! run-id {:end_time (t/instant (:end-time resp))
:message (:note resp)})
"success"
(worker-run/succeed-started-run! run-id {:end_time (t/instant (:end-time resp))})
"error"
(worker-run/fail-started-run! run-id {:end_time (t/instant (:end-time resp))
:message (:note resp)})
"timeout"
(worker-run/timeout-run! run-id {:end_time (t/instant (:end-time resp))
:message (:note resp)}))
resp))
(defn- wrap-log-errors [f msg]
(fn [& args]
(try
(apply f args)
(catch Throwable t
(log/error t msg)
nil))))
(defn- sync-worker-runs! [_ctx]
(log/trace "Syncing worker runs.")
(try
(run! (wrap-log-errors (comp sync-single-run! :run_id) "Error syncing task")
(worker-run/reducible-active-remote-runs))
(catch Throwable t
(log/error t "Error syncing worker runs.")))
(log/trace "Timing out old runs.")
(try
(worker-run/timeout-old-runs! 4 :hour)
(catch Throwable t
(log/error t "Error timing out old runs.")))
(log/trace "Canceling items that haven't been marked canceled.")
(try
(worker-run/cancel-old-canceling-runs! 1 :minute)
(catch Throwable t
(log/error t "Error canceling items not marked canceled."))))
(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-minutes 10)
(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,192 @@
(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- worker-db []
(assert (config/config-str :mb-worker-db) "Must have env var MB_WORKER_DB set to run this function.")
(config/config-str :mb-worker-db))
(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 (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 (worker-db)
"UPDATE worker_runs
SET status = ?, end_time = now(), note = ?
WHERE run_id = ? AND status = 'running'"
[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))
(defn track-cancel!
[run-id msg]
(set-status! run-id "canceled" msg))
(defn mark-cancel-started-run! [run-id mb-source]
(-> (run-update! :postgres (worker-db)
"
INSERT INTO worker_runs_cancelation (run_id)
SELECT ?
WHERE EXISTS (
SELECT 1 FROM worker_runs WHERE run_id = ? AND status = 'running' AND source = ?
) AND NOT EXISTS (
SELECT 1 FROM worker_runs_cancelation WHERE run_id = ?
)
"
[run-id run-id mb-source run-id])
(= 1)))
(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 (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 (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"]))
(defn cancel-old-cancelations!
[]
;; update the status of canceled runs > 1 minute old
(run-update! :postgres (worker-db)
"UPDATE worker_runs
SET status = 'canceled', end_time = NOW(), note = ?
WHERE status = 'running' AND
run_id IN (
SELECT run_id
FROM worker_runs_cancelation
WHERE time < (NOW() - INTERVAL '1 minute')
)"
["Canceled by user but could not guarantee run stopped"])
;; delete cancelations of all runs that are not running
(run-update! :postgres (config/config-str :mb-worker-db)
"DELETE FROM worker_runs_cancelation
WHERE run_id NOT IN (
SELECT wrc.run_id
FROM worker_runs_cancelation AS wrc
JOIN worker_runs AS wr ON wr.run_id = wrc.run_id
WHERE wr.status = 'running'
)"
[]))
(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 (metabase.util/generate-nano-id)))
(track-start! id "mb-1")
(mark-cancel-started-run! id "mb-1")
(cancel-old-cancelations!)
(track-finish! id)
(track-error! id "oops")
(get-status id "mb-1"))

View File

@@ -0,0 +1,101 @@
(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."
[]
(assert (config/config-str :mb-worker-db) "Cannot run worker migrations with MB_WORKER_DB env var.")
(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,79 @@
(ns metabase-enterprise.bookmarks.api-test
(:require
[clojure.test :refer :all]
[metabase.models.interface :as mi]
[metabase.test :as mt]
[metabase.util :as u]
[toucan2.core :as t2]))
(defn bookmark-models [user-id & models]
(doseq [model models]
(cond
(mi/instance-of? :model/Card model)
(t2/insert! :model/CardBookmark
{:user_id user-id
:card_id (u/the-id model)})
(mi/instance-of? :model/Document model)
(t2/insert! :model/DocumentBookmark
{:user_id user-id
:document_id (u/the-id model)})
:else
(throw (ex-info "Unknown type" {:user-id user-id :model model})))))
(deftest document-bookmarks-test
(testing "Document bookmarks with enterprise features"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
:model/Document document {:name "Test Document" :collection_id coll-id}]
(testing "can bookmark a document when enterprise enabled"
(is (= (u/the-id document)
(->> (mt/user-http-request :rasta :post 200 (str "bookmark/document/" (u/the-id document)))
:document_id))))
(testing "document appears in bookmark list"
(let [result (mt/user-http-request :rasta :get 200 "bookmark")
document-bookmark (first (filter #(= (:type %) "document") result))]
(is (some? document-bookmark))
(is (= "Test Document" (:name document-bookmark)))
(is (= (u/the-id document) (:item_id document-bookmark)))))
(testing "can delete document bookmark"
(mt/user-http-request :rasta :delete 204 (str "bookmark/document/" (u/the-id document)))
(is (empty? (filter #(= (:type %) "document")
(mt/user-http-request :rasta :get 200 "bookmark")))))
(testing "document bookmarks are included in ordering"
(mt/with-temp [:model/Card card {:name "Test Card"}]
(mt/with-model-cleanup [:model/BookmarkOrdering]
(bookmark-models (mt/user->id :rasta) document card)
(mt/user-http-request :rasta :put 204 "bookmark/ordering"
{:orderings [{:type "document" :item_id (u/the-id document)}
{:type "card" :item_id (u/the-id card)}]})
(is (= ["document" "card"]
(map :type (mt/user-http-request :rasta :get 200 "bookmark"))))))))))
(testing "Document bookmarks without enterprise features"
(mt/with-premium-features #{}
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
:model/Document document {:name "Test Document" :collection_id coll-id}]
(testing "cannot bookmark documents when enterprise disabled"
(mt/user-http-request :rasta :post 402 (str "bookmark/document/" (u/the-id document))))
(testing "document model not included in allowed models"
(let [response (mt/user-http-request :rasta :post 402 (str "bookmark/document/" (u/the-id document)))]
(is (=? {:message #"Documents is a paid feature.*"}
response))))))))
(deftest document-bookmarks-archived-test
(testing "Document bookmarks on archived documents"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
:model/Document archived-document {:name "Archived Document"
:collection_id coll-id
:archived true}]
(bookmark-models (mt/user->id :rasta) archived-document)
(testing "archived documents don't appear in bookmark list"
(is (empty? (filter #(= (:type %) "document")
(mt/user-http-request :rasta :get 200 "bookmark")))))))))

View File

@@ -0,0 +1,185 @@
(ns metabase-enterprise.documents.api.collection-test
(:require
[clojure.test :refer :all]
[metabase.collections.models.collection :as collection]
[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-premium-features #{:documents}
(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-premium-features #{:documents}
(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 document-cards-do-not-appear-in-root-items-without-premium
(testing "GET /api/collection/root/items excludes cards with document_id when documents feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {normal-card-id :id} {:collection_id nil
:name "Normal Root Card"}
:model/Dashboard {dash-id :id} {:collection_id nil
:name "Root Dashboard"}]
(testing "Normal cards and dashboards in root appear, no documents"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
root-test-items (filter #(#{normal-card-id dash-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-premium-features #{: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-do-not-appear-in-collection-items-without-premium
(testing "GET /api/collection/:id/items excludes documents when documents feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Card {card-id :id} {:collection_id coll-id}
:model/Document _ {:collection_id coll-id
:name "Test Document"}
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
(testing "Only cards and dashboards appear, no documents"
(is (= #{[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-premium-features #{: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))))))))))
(deftest documents-do-not-appear-in-root-items-without-premium
(testing "GET /api/collection/root/items excludes documents when documents feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {card-id :id} {:collection_id nil
:name "Root Card"}
:model/Document _ {:collection_id nil
:name "Test Document"}
:model/Dashboard {dash-id :id} {:collection_id nil
:name "Root Dashboard"}]
(testing "Only cards and dashboards appear in root, no documents"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
root-test-items (filter #(#{card-id dash-id} (:id %))
(:data items))]
(is (= #{[card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items))))))))))
(deftest archived-documents-appear-in-trash-items
(testing "GET /api/collection/trash/items includes documents with archived_directly true"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {archived-doc-id :id} {:collection_id nil
:name "Archived Document"
:archived true
:archived_directly true}
:model/Document {normal-doc-id :id} {:collection_id nil
:name "Normal Document"
:archived_directly false}
:model/Card {archived-card-id :id} {:collection_id nil
:name "Archived Card"
:archived true
:archived_directly true}
:model/Dashboard {archived-dash-id :id} {:collection_id nil
:name "Archived Dashboard"
:archived true
:archived_directly true}]
(testing "Archived documents appear alongside other archived items in trash"
(let [items (mt/user-http-request :rasta :get 200 (format "collection/%d/items" (collection/trash-collection-id)))
trash-test-items (filter #(#{archived-doc-id normal-doc-id archived-card-id archived-dash-id} (:id %))
(:data items))]
(is (= #{[archived-doc-id "document" true true]
[archived-card-id "card" true true]
[archived-dash-id "dashboard" true true]}
(set (map (juxt :id :model :can_delete :can_restore) trash-test-items))))
(testing "Non-archived documents do not appear in trash"
(is (not (some #(= normal-doc-id (:id %)) trash-test-items))))))))))
(deftest archived-documents-do-not-appear-in-trash-items-without-premium
(testing "GET /api/collection/trash/items excludes documents when documents feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Document _ {:collection_id nil
:name "Archived Document"
:archived true
:archived_directly true}
:model/Card {archived-card-id :id} {:collection_id nil
:name "Archived Card"
:archived true
:archived_directly true}
:model/Dashboard {archived-dash-id :id} {:collection_id nil
:name "Archived Dashboard"
:archived true
:archived_directly true}]
(testing "Only archived cards and dashboards appear in trash, no documents"
(let [items (mt/user-http-request :rasta :get 200 (format "collection/%d/items" (collection/trash-collection-id)))
trash-test-items (:data items)]
(is (= #{[archived-card-id "card"]
[archived-dash-id "dashboard"]}
(set (map (juxt :id :model) trash-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,340 @@
(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-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 (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

@@ -4,6 +4,7 @@
[malli.core :as mc]
[malli.transform :as mtx]
[medley.core :as m]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
@@ -37,7 +38,7 @@
([] (ai-session-token :rasta (str (random-uuid))))
([metabot-id] (ai-session-token :rasta metabot-id))
([user metabot-id]
(-> user mt/user->id (#'metabot-v3.tools.api/get-ai-service-token metabot-id))))
(-> user mt/user->id (#'metabot-v3.client/get-ai-service-token metabot-id))))
(deftest create-dashboard-subscription-test
(mt/with-premium-features #{:metabot-v3}

View File

@@ -45,6 +45,7 @@
:model/DashboardBookmark
:model/DataPermissions
:model/DatabaseRouter
:model/DocumentBookmark
:model/CollectionPermissionGraphRevision
:model/DashboardCardSeries
:model/LoginHistory

View File

@@ -0,0 +1,126 @@
(ns metabase-enterprise.transforms.api.transform-job-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.transforms.models.transform-job]
[metabase-enterprise.transforms.models.transform-tag]
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest create-job-test
(testing "POST /api/ee/transform-job"
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/TransformTag tag1 {:name "test-tag-1"}
:model/TransformTag tag2 {:name "test-tag-2"}]
(testing "Creates job with valid data"
(let [response (mt/user-http-request :crowberto :post 200 "ee/transform-job"
{:name "Test Job"
:description "Test Description"
:schedule "0 0 0 * * ?"
:tag_ids [(:id tag1) (:id tag2)]})]
(is (some? (:id response)))
(is (= "Test Job" (:name response)))
(is (= "Test Description" (:description response)))
(is (= "0 0 0 * * ?" (:schedule response)))
(is (= (set [(:id tag1) (:id tag2)]) (set (:tag_ids response))))))
(testing "Validates cron expression"
(let [response (mt/user-http-request :crowberto :post 400 "ee/transform-job"
{:name "Bad Cron Job"
:schedule "invalid cron"})]
(is (string? response))
(is (re-find #"Invalid cron expression" response))))
(testing "Validates tag IDs exist"
(let [response (mt/user-http-request :crowberto :post 400 "ee/transform-job"
{:name "Job with bad tags"
:schedule "0 0 0 * * ?"
:tag_ids [999999]})]
(is (string? response))
(is (re-find #"tag IDs do not exist" response))))))))
(deftest get-job-test
(testing "GET /api/ee/transform-job/:id"
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/TransformTag tag {:name "test-tag"}
:model/TransformJob job {:name "Test Job"
:schedule "0 0 0 * * ?"}]
;; Add tag association
(t2/insert! :transform_job_tags {:job_id (:id job) :tag_id (:id tag)})
(testing "Returns job with hydrated fields"
(let [response (mt/user-http-request :crowberto :get 200 (str "ee/transform-job/" (:id job)))]
(is (= (:id job) (:id response)))
(is (= "Test Job" (:name response)))
(is (= [(:id tag)] (:tag_ids response)))
(is (nil? (:last_execution response)))))
(testing "Returns 404 for non-existent job"
(mt/user-http-request :crowberto :get 404 "ee/transform-job/999999"))))))
(deftest list-jobs-test
(testing "GET /api/ee/transform-job"
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/TransformJob job1 {:name "Job 1" :schedule "0 0 0 * * ?"}
:model/TransformJob job2 {:name "Job 2" :schedule "0 0 */4 * * ?"}]
(let [response (mt/user-http-request :crowberto :get 200 "ee/transform-job")
job-ids (set (map :id response))]
(is (contains? job-ids (:id job1)))
(is (contains? job-ids (:id job2)))
(is (every? #(nil? (:last_execution %)) response)))))))
(deftest update-job-test
(testing "PUT /api/ee/transform-job/:id"
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/TransformTag tag1 {:name "tag-1"}
:model/TransformTag tag2 {:name "tag-2"}
:model/TransformJob job {:name "Original" :schedule "0 0 0 * * ?"}]
(testing "Updates job fields"
(let [response (mt/user-http-request :crowberto :put 200 (str "ee/transform-job/" (:id job))
{:name "Updated"
:description "New Description"
:schedule "0 0 */2 * * ?"
:tag_ids [(:id tag1) (:id tag2)]})]
(is (= "Updated" (:name response)))
(is (= "New Description" (:description response)))
(is (= "0 0 */2 * * ?" (:schedule response)))
(is (= (set [(:id tag1) (:id tag2)]) (set (:tag_ids response))))))
(testing "Validates cron expression"
(let [response (mt/user-http-request :crowberto :put 400 (str "ee/transform-job/" (:id job))
{:schedule "invalid"})]
(is (string? response))
(is (re-find #"Invalid cron expression" response))))))))
(deftest delete-job-test
(testing "DELETE /api/ee/transform-job/:id"
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/TransformJob job {:name "To Delete" :schedule "0 0 0 * * ?"}]
(testing "Deletes job"
(mt/user-http-request :crowberto :delete 204 (str "ee/transform-job/" (:id job)))
(is (nil? (t2/select-one :model/TransformJob :id (:id job)))))
(testing "Returns 404 for non-existent job"
(mt/user-http-request :crowberto :delete 404 "ee/transform-job/999999"))))))
(deftest execute-job-test
(testing "POST /api/ee/transform-job/:id/execute"
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/TransformJob job {:name "To Execute" :schedule "0 0 0 * * ?"}]
(testing "Returns stub execution response"
(let [response (mt/user-http-request :crowberto :post 200 (str "ee/transform-job/" (:id job) "/execute"))]
(is (= "Job execution started" (:message response)))
(is (string? (:job_run_id response)))
(is (re-matches #"stub-\d+-\d+" (:job_run_id response)))))))))
(deftest permissions-test
(testing "All endpoints require superuser permissions"
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/TransformJob job {:name "Test" :schedule "0 0 0 * * ?"}]
(mt/user-http-request :rasta :post 403 "ee/transform-job"
{:name "New" :schedule "0 0 0 * * ?"})
(mt/user-http-request :rasta :get 403 "ee/transform-job")
(mt/user-http-request :rasta :get 403 (str "ee/transform-job/" (:id job)))
(mt/user-http-request :rasta :put 403 (str "ee/transform-job/" (:id job))
{:name "Updated"})
(mt/user-http-request :rasta :delete 403 (str "ee/transform-job/" (:id job)))
(mt/user-http-request :rasta :post 403 (str "ee/transform-job/" (:id job) "/execute"))))))

View File

@@ -0,0 +1,133 @@
(ns metabase-enterprise.transforms.api.transform-tag-test
"Tests for transform tag CRUD API endpoints."
(:require
[clojure.test :refer :all]
[metabase-enterprise.transforms.models.transform-tag]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util :as u]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db :test-users))
(deftest create-tag-test
(testing "POST /api/ee/transform-tag"
(mt/with-premium-features #{:transforms}
(testing "Creates a new tag with valid name"
(let [tag-name (str "test-tag-" (u/generate-nano-id))
response (mt/user-http-request :crowberto :post 200 "ee/transform-tag"
{:name tag-name})]
(is (some? (:id response)))
(is (= tag-name (:name response)))
(is (some? (:created_at response)))
(is (some? (:updated_at response)))
;; Clean up
(t2/delete! :model/TransformTag :id (:id response))))
(testing "Returns 400 for duplicate tag name"
(let [tag-name (str "duplicate-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :model/TransformTag {:name tag-name})]
(try
(is (string? (mt/user-http-request :crowberto :post 400 "ee/transform-tag"
{:name tag-name}))
"Should return 400 with error message for duplicate name")
(finally
(t2/delete! :model/TransformTag :id (:id tag))))))
(testing "Returns validation error for empty name"
(let [response (mt/user-http-request :crowberto :post "ee/transform-tag"
{:name ""})]
(is (:errors response) "Should return validation errors for empty name"))
(let [response (mt/user-http-request :crowberto :post "ee/transform-tag"
{:name " "})]
(is (:errors response) "Should return validation errors for blank name"))))))
(deftest update-tag-test
(testing "PUT /api/ee/transform-tag/:tag-id"
(mt/with-premium-features #{:transforms}
(testing "Updates tag name successfully"
(let [original-name (str "original-" (u/generate-nano-id))
updated-name (str "updated-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :model/TransformTag {:name original-name})
response (mt/user-http-request :crowberto :put 200
(str "ee/transform-tag/" (:id tag))
{:name updated-name})]
(try
(is (= (:id tag) (:id response)))
(is (= updated-name (:name response)))
(finally
(t2/delete! :model/TransformTag :id (:id tag))))))
(testing "Returns 404 for non-existent tag"
(is (= "Not found."
(mt/user-http-request :crowberto :put 404
"ee/transform-tag/999999"
{:name "new-name"}))))
(testing "Returns 400 when updating to duplicate name"
(let [existing-name (str "existing-" (u/generate-nano-id))
existing-tag (t2/insert-returning-instance! :model/TransformTag {:name existing-name})
tag-to-update (t2/insert-returning-instance! :model/TransformTag
{:name (str "update-me-" (u/generate-nano-id))})]
(try
(is (string? (mt/user-http-request :crowberto :put 400
(str "ee/transform-tag/" (:id tag-to-update))
{:name existing-name}))
"Should return 400 with error message for duplicate name")
(finally
(t2/delete! :model/TransformTag :id (:id existing-tag))
(t2/delete! :model/TransformTag :id (:id tag-to-update)))))))))
(deftest delete-tag-test
(testing "DELETE /api/ee/transform-tag/:tag-id"
(mt/with-premium-features #{:transforms}
(testing "Deletes tag successfully"
(let [tag-name (str "delete-me-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :model/TransformTag {:name tag-name})]
(is (t2/exists? :model/TransformTag :id (:id tag)))
(mt/user-http-request :crowberto :delete 204 (str "ee/transform-tag/" (:id tag)))
(is (not (t2/exists? :model/TransformTag :id (:id tag))))))
(testing "Returns 404 for non-existent tag"
(is (= "Not found."
(mt/user-http-request :crowberto :delete 404
"ee/transform-tag/999999")))))))
(deftest list-tags-test
(testing "GET /api/ee/transform-tag"
(mt/with-premium-features #{:transforms}
(testing "Returns all tags ordered by name"
;; Create test tags
(let [tag1 (t2/insert-returning-instance! :model/TransformTag {:name (str "aaa-" (u/generate-nano-id))})
tag2 (t2/insert-returning-instance! :model/TransformTag {:name (str "zzz-" (u/generate-nano-id))})
tag3 (t2/insert-returning-instance! :model/TransformTag {:name (str "mmm-" (u/generate-nano-id))})
response (mt/user-http-request :crowberto :get 200 "ee/transform-tag")
tag-names (map :name response)]
(try
;; Should include our test tags
(is (some #(= (:name tag1) %) tag-names))
(is (some #(= (:name tag2) %) tag-names))
(is (some #(= (:name tag3) %) tag-names))
;; Should be ordered alphabetically
(is (= (sort tag-names) tag-names))
(finally
(t2/delete! :model/TransformTag :id (:id tag1))
(t2/delete! :model/TransformTag :id (:id tag2))
(t2/delete! :model/TransformTag :id (:id tag3)))))))))
(deftest permissions-test
(testing "Transform tag endpoints require superuser permissions"
(mt/with-premium-features #{:transforms}
(testing "POST /api/ee/transform-tag"
(is (string? (mt/user-http-request :rasta :post 403 "ee/transform-tag"
{:name "test"}))))
(testing "GET /api/ee/transform-tag"
(is (string? (mt/user-http-request :rasta :get 403 "ee/transform-tag"))))
(testing "PUT /api/ee/transform-tag/:tag-id"
(is (string? (mt/user-http-request :rasta :put 403 "ee/transform-tag/1"
{:name "test"}))))
(testing "DELETE /api/ee/transform-tag/:tag-id"
(is (string? (mt/user-http-request :rasta :delete 403 "ee/transform-tag/1")))))))

View File

@@ -0,0 +1,166 @@
(ns ^:mb/driver-tests metabase-enterprise.transforms.api.transform-tags-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.transforms.models.transform :as transform.model]
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest create-transform-with-tags-test
(testing "POST /api/ee/transform with tag_ids"
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(testing "Can create transform with tags"
;; Create tags via API since we're testing transform creation with existing tags
(let [tag1 (mt/user-http-request :crowberto :post 200 "ee/transform-tag"
{:name (str "test-tag-1-" (random-uuid))})
tag2 (mt/user-http-request :crowberto :post 200 "ee/transform-tag"
{:name (str "test-tag-2-" (random-uuid))})]
(try
(let [transform (mt/user-http-request :crowberto :post 200 "ee/transform"
{:name "Test Transform"
:description "Test with tags"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query "SELECT 1"}}}
:target {:type "table"
:name "test_table"}
:tag_ids [(:id tag1) (:id tag2)]})]
(is (= "Test Transform" (:name transform)))
(is (= [(:id tag1) (:id tag2)] (sort (:tag_ids transform)))))
(finally
;; Clean up the tags we created
(t2/delete! :model/TransformTag :id [:in [(:id tag1) (:id tag2)]])))))
(testing "Can create transform without tags"
(let [transform (mt/user-http-request :crowberto :post 200 "ee/transform"
{:name "Transform Without Tags"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query "SELECT 1"}}}
:target {:type "table"
:name "another_table"}})]
(is (= "Transform Without Tags" (:name transform)))
(is (= [] (:tag_ids transform)))))))))
(deftest update-transform-tags-test
(testing "PUT /api/ee/transform/:id with tag_ids"
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/Transform transform {:name "Test Transform"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query "SELECT 1"}}}
:target {:type "table"
:name "test_table"}}
:model/TransformTag tag1 {:name "update-tag-1"}
:model/TransformTag tag2 {:name "update-tag-2"}
:model/TransformTag tag3 {:name "update-tag-3"}]
(testing "Can add tags to transform"
(let [updated (mt/user-http-request :crowberto :put 200 (str "ee/transform/" (:id transform))
{:tag_ids [(:id tag1) (:id tag2)]})]
(is (= [(:id tag1) (:id tag2)] (sort (:tag_ids updated))))))
(testing "Can update tags on transform"
(let [updated (mt/user-http-request :crowberto :put 200 (str "ee/transform/" (:id transform))
{:tag_ids [(:id tag2) (:id tag3)]})]
(is (= [(:id tag2) (:id tag3)] (sort (:tag_ids updated))))))
(testing "Can remove all tags from transform"
(let [updated (mt/user-http-request :crowberto :put 200 (str "ee/transform/" (:id transform))
{:tag_ids []})]
(is (= [] (:tag_ids updated))))))))))
(deftest get-transform-with-tags-test
(testing "GET /api/ee/transform/:id returns tag_ids"
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/Transform transform {:name "Transform With Tags"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query "SELECT 1"}}}
:target {:type "table"
:name "tagged_table"}}
:model/TransformTag tag1 {:name "get-tag-1"}
:model/TransformTag tag2 {:name "get-tag-2"}]
;; Add tags to transform
(transform.model/update-transform-tags! (:id transform) [(:id tag1) (:id tag2)])
(testing "Single transform returns tag_ids"
(let [fetched (mt/user-http-request :crowberto :get 200 (str "ee/transform/" (:id transform)))]
(is (= [(:id tag1) (:id tag2)] (sort (:tag_ids fetched))))))
(testing "Transform without tags returns empty array"
(mt/with-temp [:model/Transform transform2 {:name "Transform Without Tags"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query "SELECT 1"}}}
:target {:type "table"
:name "untagged_table"}}]
(let [fetched (mt/user-http-request :crowberto :get 200 (str "ee/transform/" (:id transform2)))]
(is (= [] (:tag_ids fetched)))))))))))
(deftest list-transforms-with-tags-test
(testing "GET /api/ee/transform returns transforms with tag_ids"
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/Transform transform1 {:name "Transform 1"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query "SELECT 1"}}}
:target {:type "table"
:name "list_table_1"}}
:model/Transform transform2 {:name "Transform 2"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query "SELECT 2"}}}
:target {:type "table"
:name "list_table_2"}}
:model/TransformTag tag1 {:name "list-tag-1"}
:model/TransformTag tag2 {:name "list-tag-2"}]
;; Add tags to transforms
(transform.model/update-transform-tags! (:id transform1) [(:id tag1)])
(transform.model/update-transform-tags! (:id transform2) [(:id tag1) (:id tag2)])
(testing "List endpoint returns all transforms with their tag_ids"
(let [transforms (mt/user-http-request :crowberto :get 200 "ee/transform")
t1 (some #(when (= (:id %) (:id transform1)) %) transforms)
t2 (some #(when (= (:id %) (:id transform2)) %) transforms)]
(is (= [(:id tag1)] (:tag_ids t1)))
(is (= [(:id tag1) (:id tag2)] (sort (:tag_ids t2)))))))))))
(deftest delete-tag-removes-associations-test
(testing "Deleting a tag removes it from all transforms"
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/Transform transform {:name "Transform for Delete Test"
:source {:type "query"
:query {:database (mt/id)
:type "native"
:native {:query "SELECT 1"}}}
:target {:type "table"
:name "delete_test_table"}}
:model/TransformTag tag2 {:name "tag-to-keep"}]
;; Create tag1 via API since we're testing its deletion
(let [tag1 (mt/user-http-request :crowberto :post 200 "ee/transform-tag"
{:name "tag-to-delete"})]
;; Add both tags to transform
(transform.model/update-transform-tags! (:id transform) [(:id tag1) (:id tag2)])
;; Verify tags are associated
(let [fetched (mt/user-http-request :crowberto :get 200 (str "ee/transform/" (:id transform)))]
(is (= (set [(:id tag1) (:id tag2)]) (set (:tag_ids fetched)))))
;; Delete tag1
(mt/user-http-request :crowberto :delete 204 (str "ee/transform-tag/" (:id tag1)))
;; Verify tag1 is removed but tag2 remains
(let [fetched (mt/user-http-request :crowberto :get 200 (str "ee/transform/" (:id transform)))]
(is (= [(:id tag2)] (vec (:tag_ids fetched)))))))))))

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,59 @@
(ns metabase-enterprise.transforms.jobs-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.transforms.jobs :as jobs]
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest basic-deps-test
(let [ordering {1 #{2 3}
2 #{3 4}
3 #{}
4 #{5}
5 #{}
6 #{7 8}
7 #{}
8 #{}}]
(is (= #{1 2 3 4 5}
(#'jobs/get-deps ordering [1])))
(is (= #{1 2 3 4 5 6 7 8}
(#'jobs/get-deps ordering [1 6])))
(is (= #{2 3 4 5 6 7 8}
(#'jobs/get-deps ordering [2 6])))
(is (= #{1 2 3 4 5}
(#'jobs/get-deps ordering [1 2 3])))))
(deftest cycle-deps-test
(let [ordering {1 #{2}
2 #{3}
3 #{1}}]
(is (= #{1 2 3}
(#'jobs/get-deps ordering [1])))))
(deftest next-transform-test
(let [plan
{:ordering {1 #{2 3}
2 #{3 4}
3 #{}
4 #{5}
5 #{}
6 #{7 8}
7 #{}
8 #{}}
:transforms-by-id {1 {:id 1}
2 {:id 2}
3 {:id 3}
4 {:id 4}
5 {:id 5}
6 {:id 6}
7 {:id 7}
8 {:id 8}}}]
(is (#{3 5 7 8}
(-> (#'jobs/next-transform plan #{})
:id)))
(is (#{3 4 7 8}
(-> (#'jobs/next-transform plan #{5})
:id)))
(is (= 1
(-> (#'jobs/next-transform plan #{2 3 4 5 6 7 8})
:id)))))

View File

@@ -0,0 +1,271 @@
(ns metabase-enterprise.transforms.models-test
"Tests for transform jobs and tags database tables and basic CRUD operations."
(:require
[clojure.test :refer :all]
[metabase-enterprise.transforms.models.transform-job]
[metabase-enterprise.transforms.models.transform-tag :as transform-tag]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util :as u]
[toucan2.core :as t2]))
;; Use the default test fixture which sets up the test database
(use-fixtures :once (fixtures/initialize :db :test-users))
(deftest default-tags-test
(testing "Default tags were inserted by migration"
(doseq [tag-name ["hourly" "daily" "weekly" "monthly"]]
(testing (str "Tag '" tag-name "' exists")
(is (t2/exists? :transform_tag :name tag-name)
(str "Default tag '" tag-name "' should exist"))))))
(deftest tag-crud-test
(testing "Transform tag CRUD operations"
(testing "Create and read tag"
(let [tag-name (str "test-tag-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :transform_tag {:name tag-name})]
(is (some? (:id tag)) "Tag should have an ID")
(is (= tag-name (:name tag)) "Tag name should match")
(is (some? (:created_at tag)) "Tag should have created_at timestamp")
(is (some? (:updated_at tag)) "Tag should have updated_at timestamp")
;; Read back
(let [read-tag (t2/select-one :transform_tag :id (:id tag))]
(is (= tag-name (:name read-tag)) "Retrieved tag name should match"))
;; Clean up
(t2/delete! :transform_tag :id (:id tag))))
(testing "Update tag"
(let [original-name (str "original-" (u/generate-nano-id))
updated-name (str "updated-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :transform_tag {:name original-name})]
(t2/update! :transform_tag (:id tag) {:name updated-name})
(let [updated-tag (t2/select-one :transform_tag :id (:id tag))]
(is (= updated-name (:name updated-tag)) "Tag name should be updated"))
;; Clean up
(t2/delete! :transform_tag :id (:id tag))))
(testing "Delete tag"
(let [tag-name (str "delete-me-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :transform_tag {:name tag-name})]
(is (t2/exists? :transform_tag :id (:id tag)) "Tag should exist before deletion")
(t2/delete! :transform_tag :id (:id tag))
(is (not (t2/exists? :transform_tag :id (:id tag))) "Tag should not exist after deletion")))))
(deftest job-crud-test
(testing "Transform job CRUD operations"
(testing "Create and read job"
(let [job-name (str "test-job-" (u/generate-nano-id))
job-description "Test job description"
job-schedule "0 0 * * * ?"
entity-id (u/generate-nano-id)
job (t2/insert-returning-instance! :transform_job
{:name job-name
:description job-description
:schedule job-schedule
:entity_id entity-id})]
(is (some? (:id job)) "Job should have an ID")
(is (= job-name (:name job)) "Job name should match")
(is (= job-description (:description job)) "Job description should match")
(is (= job-schedule (:schedule job)) "Job schedule should match")
(is (= entity-id (:entity_id job)) "Job entity_id should match")
(is (some? (:created_at job)) "Job should have created_at timestamp")
(is (some? (:updated_at job)) "Job should have updated_at timestamp")
;; Read back
(let [read-job (t2/select-one :transform_job :id (:id job))]
(is (= job-name (:name read-job)) "Retrieved job name should match"))
;; Clean up
(t2/delete! :transform_job :id (:id job))))
(testing "Update job"
(let [original-name (str "original-job-" (u/generate-nano-id))
original-schedule "0 0 * * * ?"
updated-name (str "updated-job-" (u/generate-nano-id))
updated-description "Updated description"
updated-schedule "0 0 */2 * * ?"
job (t2/insert-returning-instance! :transform_job
{:name original-name
:schedule original-schedule
:entity_id (u/generate-nano-id)})]
(t2/update! :transform_job (:id job)
{:name updated-name
:description updated-description
:schedule updated-schedule})
(let [updated-job (t2/select-one :transform_job :id (:id job))]
(is (= updated-name (:name updated-job)) "Job name should be updated")
(is (= updated-description (:description updated-job)) "Job description should be updated")
(is (= updated-schedule (:schedule updated-job)) "Job schedule should be updated"))
;; Clean up
(t2/delete! :transform_job :id (:id job))))
(testing "Delete job"
(let [job-name (str "delete-me-" (u/generate-nano-id))
job-schedule "0 0 * * * ?"
job (t2/insert-returning-instance! :transform_job
{:name job-name
:schedule job-schedule
:entity_id (u/generate-nano-id)})]
(is (t2/exists? :transform_job :id (:id job)) "Job should exist before deletion")
(t2/delete! :transform_job :id (:id job))
(is (not (t2/exists? :transform_job :id (:id job))) "Job should not exist after deletion")))))
(deftest job-tags-association-test
(testing "Job-tag associations via join table"
(let [job-name (str "job-" (u/generate-nano-id))
job-schedule "0 0 * * * ?"
job (t2/insert-returning-instance! :transform_job
{:name job-name
:schedule job-schedule
:entity_id (u/generate-nano-id)})
tag1 (t2/insert-returning-instance! :transform_tag {:name (str "tag1-" (u/generate-nano-id))})
tag2 (t2/insert-returning-instance! :transform_tag {:name (str "tag2-" (u/generate-nano-id))})]
(try
(testing "Can associate job with tags"
(t2/insert! :transform_job_tags {:job_id (:id job) :tag_id (:id tag1)})
(t2/insert! :transform_job_tags {:job_id (:id job) :tag_id (:id tag2)})
(is (t2/exists? :transform_job_tags :job_id (:id job) :tag_id (:id tag1))
"First association should exist")
(is (t2/exists? :transform_job_tags :job_id (:id job) :tag_id (:id tag2))
"Second association should exist"))
(testing "Can retrieve tags for job"
(let [tag-ids (t2/select-fn-set :tag_id :transform_job_tags :job_id (:id job))]
(is (contains? tag-ids (:id tag1)) "Should include first tag")
(is (contains? tag-ids (:id tag2)) "Should include second tag")
(is (= 2 (count tag-ids)) "Should have exactly 2 tags")))
(testing "Can retrieve jobs for tag"
(let [job-ids (t2/select-fn-set :job_id :transform_job_tags :tag_id (:id tag1))]
(is (contains? job-ids (:id job)) "Should include the job")))
(finally
;; Clean up
(t2/delete! :transform_job_tags :job_id (:id job))
(t2/delete! :transform_job :id (:id job))
(t2/delete! :transform_tag :id (:id tag1))
(t2/delete! :transform_tag :id (:id tag2)))))))
(deftest cascade-delete-tag-test
(testing "Deleting a tag cascades to associations"
(let [tag-name (str "cascade-tag-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :transform_tag {:name tag-name})
job-name (str "cascade-job-" (u/generate-nano-id))
job-schedule "0 0 * * * ?"
job (t2/insert-returning-instance! :transform_job
{:name job-name
:schedule job-schedule
:entity_id (u/generate-nano-id)})]
(try
;; Create association
(t2/insert! :transform_job_tags {:job_id (:id job) :tag_id (:id tag)})
;; Verify association exists
(is (t2/exists? :transform_job_tags :tag_id (:id tag)))
;; Delete the tag
(t2/delete! :transform_tag :id (:id tag))
;; Verify cascade deletion
(testing "Tag associations are deleted"
(is (not (t2/exists? :transform_job_tags :tag_id (:id tag)))
"Job-tag association should be deleted"))
;; Verify job still exists
(is (t2/exists? :transform_job :id (:id job))
"Job should still exist after tag deletion")
(finally
(t2/delete! :transform_job :id (:id job)))))))
(deftest cascade-delete-job-test
(testing "Deleting a job cascades to associations"
(let [job-name (str "cascade-job-" (u/generate-nano-id))
job-schedule "0 0 * * * ?"
job (t2/insert-returning-instance! :transform_job
{:name job-name
:schedule job-schedule
:entity_id (u/generate-nano-id)})
tag-name (str "cascade-tag-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :transform_tag {:name tag-name})]
(try
;; Create association
(t2/insert! :transform_job_tags {:job_id (:id job) :tag_id (:id tag)})
;; Verify association exists
(is (t2/exists? :transform_job_tags :job_id (:id job)))
;; Delete the job
(t2/delete! :transform_job :id (:id job))
;; Verify cascade deletion
(testing "Job associations are deleted"
(is (not (t2/exists? :transform_job_tags :job_id (:id job)))
"Job-tag association should be deleted"))
;; Verify tag still exists
(is (t2/exists? :transform_tag :id (:id tag))
"Tag should still exist after job deletion")
(finally
(t2/delete! :transform_tag :id (:id tag)))))))
(deftest model-table-name-test
(testing "Model table name resolution"
(is (= :transform_tag (t2/table-name :model/TransformTag))
"TransformTag model should resolve to transform_tag table")
(is (= :transform_job (t2/table-name :model/TransformJob))
"TransformJob model should resolve to transform_job table")))
(deftest transform-tag-helper-functions-test
(testing "TransformTag helper functions"
(let [unique-name (str "unique-tag-" (u/generate-nano-id))
tag (t2/insert-returning-instance! :model/TransformTag {:name unique-name})]
(try
(testing "tag-name-exists?"
(is (transform-tag/tag-name-exists? unique-name)
"Should return true for existing tag name")
(is (not (transform-tag/tag-name-exists? (str "nonexistent-" (u/generate-nano-id))))
"Should return false for non-existing tag name"))
(testing "tag-name-exists-excluding?"
(is (not (transform-tag/tag-name-exists-excluding? unique-name (:id tag)))
"Should return false when checking same tag's name")
(is (transform-tag/tag-name-exists-excluding? unique-name (inc (:id tag)))
"Should return true when name exists but with different ID"))
(finally
(t2/delete! :model/TransformTag :id (:id tag)))))))
(deftest hydrate-tag-ids-test
(testing "TransformJob tag_ids hydration"
(mt/with-temp [:model/TransformJob job1 {:name "job1" :schedule "0 0 * * * ?"}
:model/TransformJob job2 {:name "job2" :schedule "0 0 * * * ?"}
:model/TransformTag tag1 {:name "tag1"}
:model/TransformTag tag2 {:name "tag2"}]
;; Set up associations
(t2/insert! :transform_job_tags {:job_id (:id job1) :tag_id (:id tag1)})
(t2/insert! :transform_job_tags {:job_id (:id job1) :tag_id (:id tag2)})
(t2/insert! :transform_job_tags {:job_id (:id job2) :tag_id (:id tag2)})
(testing "Hydration adds tag_ids to jobs"
(let [hydrated-jobs (t2/hydrate [job1 job2] :tag_ids)]
(is (= 2 (count hydrated-jobs)) "Should hydrate all jobs")
(let [hydrated-job1 (first (filter #(= (:id job1) (:id %)) hydrated-jobs))
hydrated-job2 (first (filter #(= (:id job2) (:id %)) hydrated-jobs))]
(is (= #{(:id tag1) (:id tag2)} (set (:tag_ids hydrated-job1)))
"Job1 should have both tags")
(is (= #{(:id tag2)} (set (:tag_ids hydrated-job2)))
"Job2 should have only tag2"))))
(testing "Jobs with no tags have empty tag_ids"
(mt/with-temp [:model/TransformJob job3 {:name "job3" :schedule "0 0 * * * ?"}]
(let [hydrated-job (first (t2/hydrate [job3] :tag_ids))]
(is (= [] (:tag_ids hydrated-job))
"Job with no tags should have empty tag_ids array")))))))

View File

@@ -0,0 +1,178 @@
(ns ^:mb/driver-tests metabase-enterprise.transforms.ordering-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.transforms.ordering :as ordering]
[metabase.driver :as driver]
[metabase.query-processor.compile :as qp.compile]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- make-transform [{:keys [query name]}]
{:source {:type "query",
:query query}
:name "transform1"
:target {:schema "public"
:name (or name "orders_2")
:type "table"}})
(deftest basic-ordering-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(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 :model/Transform :id t1)))))))
(deftest dependency-ordering-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-temp [:model/Table {table :id} {:schema "public"
:name "orders_2"}
:model/Field _ {:table_id table
:name "foo"}
:model/Transform {parent :id} (make-transform
{:query {:database (mt/id),
:type "query",
:query {:source-table (mt/id :orders)}}})
:model/Transform {child :id} (make-transform
{:query {:database (mt/id)
:type "query"
:query {:source-table table}}
:name "orders_3"})]
(is (= {parent #{}
child #{parent}}
(ordering/transform-ordering (t2/select :model/Transform :id [:in [parent child]])))))))
(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 (= {t1 #{(mt/id :orders)}}
(#'ordering/transform-deps-for-db (mt/id) (t2/select :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 (= {t1 #{(mt/id :orders)
(mt/id :products)}}
(#'ordering/transform-deps-for-db (mt/id) (t2/select :model/Transform :id t1))))))
(deftest card-dependencies-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(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 (= {t1 #{(mt/id :orders)
(mt/id :products)}}
(#'ordering/transform-deps-for-db (mt/id) (t2/select :model/Transform :id t1)))))))
(deftest native-dependencies-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-temp [:model/Transform {t1 :id} (make-transform
{:query {:database (mt/id),
:type :native
:native (qp.compile/compile
{:database (mt/id),
:type "query",
:query {:source-table (mt/id :orders)}})}})]
(is (= {t1 #{(mt/id :orders)}}
(#'ordering/transform-deps-for-db (mt/id) (t2/select :model/Transform :id t1)))))))
(deftest native-card-dependencies-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table :native-parameter-card-reference)
(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 :native,
:native {:query
(mt/native-query-with-card-template-tag
(:engine (mt/db))
(str "#" card)),
:template-tags
{(keyword (str "#" card))
{:type "card"
:name "card"
:display-name "card"
:card-id card}}}}})]
(is (= {t1 #{(mt/id :orders)
(mt/id :products)}}
(#'ordering/transform-deps-for-db (mt/id) (t2/select :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

@@ -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, listTag } 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 ? [] : [listTag("document")]), // 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 ? [listTag("document"), idTag("document", id)] : [],
}),
}),
});
export const {
useGetDocumentQuery,
useCreateDocumentMutation,
useUpdateDocumentMutation,
} = documentApi;

View File

@@ -3,11 +3,15 @@ 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 "./transform-job";
export * from "./transform-tag";
export * from "./upload-management";

View File

@@ -1,6 +1,8 @@
import type {
DeleteSuggestedMetabotPromptRequest,
MetabotApiEntity,
MetabotDocumentNodeRequest,
MetabotDocumentNodeResponse,
MetabotEntity,
MetabotId,
MetabotInfo,
@@ -111,6 +113,16 @@ export const metabotApi = EnterpriseApi.injectEndpoints({
invalidatesTags: (_, error, metabot_id) =>
!error ? [idTag("metabot-prompt-suggestions", metabot_id)] : [],
}),
metabotDocumentNode: builder.query<
MetabotDocumentNodeResponse,
MetabotDocumentNodeRequest
>({
query: (params) => ({
method: "POST",
url: "/api/ee/metabot-v3/document/generate-content",
params,
}),
}),
}),
});
@@ -122,4 +134,5 @@ export const {
useGetSuggestedMetabotPromptsQuery,
useDeleteSuggestedMetabotPromptMutation,
useRegenerateSuggestedMetabotPromptsMutation,
useLazyMetabotDocumentNodeQuery,
} = metabotApi;

View File

@@ -1,11 +1,24 @@
import type { TagDescription } from "@reduxjs/toolkit/query";
import { TAG_TYPES } from "metabase/api/tags";
import type {
Transform,
TransformExecution,
TransformJob,
TransformTag,
} from "metabase-types/api";
export const ENTERPRISE_TAG_TYPES = [
...TAG_TYPES,
"scim",
"metabot",
"metabot-entities-list",
"metabot-prompt-suggestions",
"gsheets-status",
"transform",
"transform-tag",
"transform-job",
"transform-execution",
] as const;
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];
@@ -35,3 +48,57 @@ 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 provideTransformExecutionTags(
execution: TransformExecution,
): TagDescription<EnterpriseTagType>[] {
return [
idTag("transform-execution", execution.id),
...(execution.transform ? provideTransformTags(execution.transform) : []),
];
}
export function provideTransformExecutionListTags(
executions: TransformExecution[],
): TagDescription<EnterpriseTagType>[] {
return [
listTag("transform-execution"),
...executions.flatMap(provideTransformExecutionTags),
];
}
export function provideTransformTagTags(
tag: TransformTag,
): TagDescription<EnterpriseTagType>[] {
return [idTag("transform-tag", tag.id)];
}
export function provideTransformTagListTags(
tags: TransformTag[],
): TagDescription<EnterpriseTagType>[] {
return [listTag("transform-tag"), ...tags.flatMap(provideTransformTagTags)];
}
export function provideTransformJobTags(
job: TransformJob,
): TagDescription<EnterpriseTagType>[] {
return [idTag("transform-job", job.id)];
}
export function provideTransformJobListTags(
jobs: TransformJob[],
): TagDescription<EnterpriseTagType>[] {
return [listTag("transform-job"), ...jobs.flatMap(provideTransformJobTags)];
}

View File

@@ -0,0 +1,90 @@
import type {
CreateTransformJobRequest,
TransformJob,
TransformJobId,
UpdateTransformJobRequest,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
import {
idTag,
invalidateTags,
listTag,
provideTransformJobListTags,
provideTransformJobTags,
tag,
} from "./tags";
export const transformJobApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
listTransformJobs: builder.query<TransformJob[], void>({
query: (params) => ({
method: "GET",
url: "/api/ee/transform-job",
params,
}),
providesTags: (jobs = []) => provideTransformJobListTags(jobs),
}),
getTransformJob: builder.query<TransformJob, TransformJobId>({
query: (id) => ({
method: "GET",
url: `/api/ee/transform-job/${id}`,
}),
providesTags: (job) => (job ? provideTransformJobTags(job) : []),
}),
executeTransformJob: builder.mutation<void, TransformJobId>({
query: (id) => ({
method: "POST",
url: `/api/ee/transform-job/${id}/execute`,
}),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [
idTag("transform-job", id),
tag("transform"),
tag("table"),
]),
}),
createTransformJob: builder.mutation<
TransformJob,
CreateTransformJobRequest
>({
query: (body) => ({
method: "POST",
url: "/api/ee/transform-job",
body,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("transform-job")]),
}),
updateTransformJob: builder.mutation<
TransformJob,
UpdateTransformJobRequest
>({
query: ({ id, ...body }) => ({
method: "PUT",
url: `/api/ee/transform-job/${id}`,
body,
}),
invalidatesTags: (_, error, { id }) =>
invalidateTags(error, [idTag("transform-job", id)]),
}),
deleteTransformJob: builder.mutation<void, TransformJobId>({
query: (id) => ({
method: "DELETE",
url: `/api/ee/transform-job/${id}`,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("transform-job")]),
}),
}),
});
export const {
useListTransformJobsQuery,
useGetTransformJobQuery,
useLazyGetTransformJobQuery,
useExecuteTransformJobMutation,
useCreateTransformJobMutation,
useUpdateTransformJobMutation,
useDeleteTransformJobMutation,
} = transformJobApi;

View File

@@ -0,0 +1,66 @@
import type {
CreateTransformTagRequest,
TransformTag,
TransformTagId,
UpdateTransformTagRequest,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
import {
idTag,
invalidateTags,
listTag,
provideTransformTagListTags,
} from "./tags";
export const transformTagApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
listTransformTags: builder.query<TransformTag[], void>({
query: (params) => ({
method: "GET",
url: "/api/ee/transform-tag",
params,
}),
providesTags: (tags = []) => provideTransformTagListTags(tags),
}),
createTransformTag: builder.mutation<
TransformTag,
CreateTransformTagRequest
>({
query: (body) => ({
method: "POST",
url: "/api/ee/transform-tag",
body,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("transform-tag")]),
}),
updateTransformTag: builder.mutation<
TransformTag,
UpdateTransformTagRequest
>({
query: ({ id, ...body }) => ({
method: "PUT",
url: `/api/ee/transform-tag/${id}`,
body,
}),
invalidatesTags: (_, error, { id }) =>
invalidateTags(error, [idTag("transform-tag", id)]),
}),
deleteTransformTag: builder.mutation<void, TransformTagId>({
query: (id) => ({
method: "DELETE",
url: `/api/ee/transform-tag/${id}`,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("transform-tag")]),
}),
}),
});
export const {
useListTransformTagsQuery,
useCreateTransformTagMutation,
useUpdateTransformTagMutation,
useDeleteTransformTagMutation,
} = transformTagApi;

View File

@@ -0,0 +1,106 @@
import type {
CreateTransformRequest,
ListTransformExecutionsRequest,
ListTransformExecutionsResponse,
Transform,
TransformId,
UpdateTransformRequest,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
import {
idTag,
invalidateTags,
listTag,
provideTransformExecutionListTags,
provideTransformListTags,
provideTransformTags,
tag,
} 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),
}),
listTransformExecutions: builder.query<
ListTransformExecutionsResponse,
ListTransformExecutionsRequest
>({
query: (params) => ({
method: "GET",
url: "/api/ee/transform/execution",
params,
}),
providesTags: (response) =>
response ? provideTransformExecutionListTags(response.data) : [],
}),
getTransform: builder.query<Transform, TransformId>({
query: (id) => ({
method: "GET",
url: `/api/ee/transform/${id}`,
}),
providesTags: (transform) =>
transform ? provideTransformTags(transform) : [],
}),
executeTransform: builder.mutation<void, TransformId>({
query: (id) => ({
method: "POST",
url: `/api/ee/transform/${id}/execute`,
}),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [idTag("transform", id), tag("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, [idTag("transform", id)]),
}),
deleteTransform: builder.mutation<void, TransformId>({
query: (id) => ({
method: "DELETE",
url: `/api/ee/transform/${id}`,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("transform")]),
}),
deleteTransformTarget: builder.mutation<void, TransformId>({
query: (id) => ({
method: "DELETE",
url: `/api/ee/transform/${id}/table`,
}),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [idTag("transform", id), listTag("table")]),
}),
}),
});
export const {
useListTransformsQuery,
useListTransformExecutionsQuery,
useGetTransformQuery,
useLazyGetTransformQuery,
useExecuteTransformMutation,
useCreateTransformMutation,
useUpdateTransformMutation,
useDeleteTransformMutation,
useDeleteTransformTargetMutation,
} = transformApi;

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,148 @@
.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);
}
.hidable {
visibility: hidden;
width: 0;
opacity: 0;
transition: opacity 200ms;
&.visible {
visibility: visible;
width: auto;
opacity: 1;
}
}

View File

@@ -0,0 +1,455 @@
import { useDisclosure } from "@mantine/hooks";
import cx from "classnames";
import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";
import type { Route } from "react-router";
import { push, replace } from "react-router-redux";
import { usePrevious, useUnmount } 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, {
getFormattedTime,
} 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 { extractEntityId } from "metabase/lib/urls";
import { setErrorPage } from "metabase/redux/app";
import {
ActionIcon,
Box,
Button,
Flex,
Icon,
Menu,
Text,
TextInput,
Tooltip,
} 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";
export const DocumentPage = ({
params: { entityId },
route,
}: {
params: { entityId?: string };
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 [currentContent, setCurrentContent] = useState<string>("");
const [createDocument] = useCreateDocumentMutation();
const [updateDocument] = useUpdateDocumentMutation();
const [
isShowingCollectionPicker,
{ open: showCollectionPicker, close: hideCollectionPicker },
] = useDisclosure(false);
const [sendToast] = useToast();
const documentId = entityId === "new" ? "new" : extractEntityId(entityId);
const previousDocumentId = usePrevious(documentId);
const [isNavigationScheduled, scheduleNavigation] = useCallbackEffect();
const isNewDocument = documentId === "new";
let {
data: documentData,
isLoading: isDocumentLoading,
error,
} = useGetDocumentQuery(
documentId && !isNewDocument ? { id: documentId } : skipToken,
);
if (documentId !== documentData?.id) {
documentData = undefined;
}
const canWrite = isNewDocument ? true : documentData?.can_write;
useEffect(() => {
if (error) {
dispatch(setErrorPage(error));
}
}, [dispatch, error]);
const {
documentTitle,
setDocumentTitle,
documentContent,
setDocumentContent,
updateCardEmbeds,
} = useDocumentState(documentData);
// This is important as it will affect collection breadcrumbs in the appbar
useUnmount(() => {
dispatch(resetDocuments());
});
// Reset current content when document changes
useEffect(() => {
if (documentId !== previousDocumentId) {
setCurrentContent(documentContent || "");
}
}, [documentId, previousDocumentId, 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 = '{"type":"doc","content":[{"type":"paragraph"}]}';
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 = (isNewDocument || 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 name =
documentTitle ||
t`Untitled document - ${dayjs().local().format("MMMM D, YYYY")}`;
const newDocumentData: any = {
name,
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 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 direction="column" w="100%">
<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>
<Tooltip
label={getFormattedTime(
documentData.updated_at,
"default",
{ local: true },
)}
>
<Text className={styles.metadataItem}>
<Icon name="clock" />
<DateTime value={documentData.updated_at} unit="day" />
</Text>
</Tooltip>
</Flex>
)}
</Flex>
<Flex gap="md" align="center">
<Box
className={cx(styles.hidable, {
[styles.visible]: showSaveButton,
})}
>
<Button
onClick={() => {
isNewDocument ? showCollectionPicker() : handleSave();
}}
variant="filled"
data-hide-on-print
>
{t`Save`}
</Button>
</Box>
<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={<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
// `key` remounts this modal when navigating between different documents or to a new document.
// The `route` doesn't change in that scenario which prevents the modal from closing when you confirm you want to discard your changes.
key={documentId}
isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
route={route}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,228 @@
.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);
}
}
.suggestionPopup {
width: 320px;
min-height: 50px;
max-height: 400px;
overflow: hidden;
background: var(--mb-color-bg-white);
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-md);
box-shadow: 0 2px 8px var(--mb-color-shadow);
}
.suggestionScroll {
max-height: 400px;
overflow-y: auto;
}
.suggestionMenuItem {
cursor: pointer;
background-color: transparent;
}
.suggestionMenuItem[data-selected] {
background-color: var(--mb-color-bg-light);
}
.suggestionMenuFooter {
border-top: 1px solid var(--mb-color-border);
}

View File

@@ -0,0 +1,221 @@
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 styles from "./Editor.module.css";
import { EditorBubbleMenu } from "./EditorBubbleMenu";
import { CardEmbed } from "./extensions/CardEmbed";
import { CommandExtension } from "./extensions/Command/CommandExtension";
import { CommandSuggestion } from "./extensions/Command/CommandSuggestion";
import { DisableMetabotSidebar } from "./extensions/DisableMetabotSidebar";
import { MentionExtension } from "./extensions/Mention/MentionExtension";
import { MentionSuggestion } from "./extensions/Mention/MentionSuggestion";
import { MetabotNode } from "./extensions/MetabotEmbed";
import { SmartLinkEmbed } from "./extensions/SmartLink";
import { Markdown } from "./extensions/markdown/index";
import { createSuggestionRenderer } from "./extensions/suggestionRenderer";
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 "/" for formatting, or "@" to insert a chart...`,
}),
Markdown,
CardEmbed.configure({
HTMLAttributes: {
class: "card-embed",
},
}),
MetabotNode,
DisableMetabotSidebar,
MentionExtension.configure({
suggestion: {
render: createSuggestionRenderer(MentionSuggestion),
},
}),
CommandExtension.configure({
suggestion: {
render: createSuggestionRenderer(CommandSuggestion),
},
}),
],
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} />
<EditorBubbleMenu editor={editor} />
</Box>
</Box>
);
};

View File

@@ -0,0 +1,154 @@
import type { EditorState } from "@tiptap/pm/state";
import type { Editor as TiptapEditor } from "@tiptap/react";
// eslint-disable-next-line import/no-unresolved
import { BubbleMenu } from "@tiptap/react/menus";
import type React from "react";
import { t } from "ttag";
import { Flex } from "metabase/ui";
import { FormatButton } from "./FormatButton";
interface EditorBubbleMenuProps {
editor: TiptapEditor;
}
export const EditorBubbleMenu: React.FC<EditorBubbleMenuProps> = ({
editor,
}) => {
return (
<BubbleMenu
editor={editor}
shouldShow={({
editor,
state,
from,
to,
}: {
editor: TiptapEditor;
state: EditorState;
from: number;
to: number;
}) => {
// Don't show bubble menu if nothing is selected
const { empty } = state.selection;
if (empty) {
return false;
}
// Don't show for code blocks
if (editor.isActive("codeBlock")) {
return false;
}
// Don't show bubble menu for certain node types
const disallowedNodes = ["cardEmbed", "metabot", "smartLink", "image"];
// Check if any disallowed node is active
for (const nodeName of disallowedNodes) {
if (editor.isActive(nodeName)) {
return false;
}
}
// Check if selection contains any disallowed nodes
let hasDisallowedNode = false;
state.doc.nodesBetween(from, to, (node) => {
if (
disallowedNodes.includes(node.type.name) ||
node.type.name === "codeBlock"
) {
hasDisallowedNode = true;
return false; // Stop traversing
}
});
return !hasDisallowedNode;
}}
>
<Flex
gap={4}
bg="white"
p="2px"
style={{
border: "1px solid var(--mb-color-border)",
borderRadius: "6px",
boxShadow: "0 2px 12px var(--mb-color-shadow)",
}}
>
<FormatButton
isActive={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
tooltip={t`Bold`}
icon="text_bold"
/>
<FormatButton
isActive={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
tooltip={t`Italic`}
icon="text_italic"
/>
<FormatButton
isActive={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
tooltip={t`Strikethrough`}
icon="text_strike"
/>
<FormatButton
isActive={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
tooltip={t`Inline code`}
icon="format_code"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 1 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
tooltip={t`Heading 1`}
text="H1"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 2 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
tooltip={t`Heading 2`}
text="H2"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 3 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
tooltip={t`Heading 3`}
text="H3"
/>
<FormatButton
isActive={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
tooltip={t`Bullet list`}
icon="list"
/>
<FormatButton
isActive={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
tooltip={t`Numbered list`}
icon="ordered_list"
/>
<FormatButton
isActive={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
tooltip={t`Quote`}
icon="quote"
/>
<FormatButton
isActive={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
tooltip={t`Code block`}
icon="code_block"
/>
</Flex>
</BubbleMenu>
);
};

View File

@@ -0,0 +1,49 @@
import type React from "react";
import type { IconName } from "metabase/ui";
import { Icon, Tooltip, UnstyledButton } from "metabase/ui";
interface FormatButtonProps {
isActive: boolean;
onClick: () => void;
tooltip: string;
icon?: IconName;
text?: string;
}
export const FormatButton: React.FC<FormatButtonProps> = ({
isActive,
onClick,
tooltip,
icon,
text,
}) => {
return (
<Tooltip label={tooltip} position="top">
<UnstyledButton
style={{
width: "32px",
height: "32px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: isActive
? "var(--mb-color-brand)"
: "var(--mb-color-text-dark)",
backgroundColor: isActive
? "var(--mb-color-bg-light)"
: "transparent",
fontSize: text ? "14px" : undefined,
fontWeight: text ? "bold" : undefined,
border: "none",
cursor: "pointer",
transition: "all 0.15s ease",
}}
onClick={onClick}
>
{icon ? <Icon name={icon} size={16} /> : text}
</UnstyledButton>
</Tooltip>
);
};

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,526 @@
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 { useGetAdhocQueryQuery } from "metabase/api/dataset";
import { useDispatch } from "metabase/lib/redux";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Flex, 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";
import { useCardMetadata } from "./useCardMetadata";
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
useCardMetadata(cardToUse);
const handleEditVisualizationSettings = () => {
if (embedIndex !== -1) {
dispatch(openVizSettingsSidebar({ embedIndex }));
}
};
const handleTitleClick = () => {
if (cardToUse && metadata) {
try {
dispatch(setShowNavigateBackToDocumentButton(true));
const isDraftCard = cardToUse.id < 0;
const question = new Question(
isDraftCard ? { ...cardToUse, id: null } : cardToUse,
metadata,
);
const url = getUrl(question, { includeDisplayIsLocked: true });
dispatch(push(url));
} catch (error) {
console.error("Failed to navigate to question:", error);
}
}
};
const handleReplaceQuestion = () => {
const pos = getPos();
if (typeof pos === "number") {
editor.commands.insertContentAt(
{ from: pos, to: pos + node.nodeSize },
"@",
);
editor.commands.focus();
}
};
// 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}>
<Flex align="center" justify="space-between" gap="0.5rem">
<Box className={styles.titleContainer}>
<Text size="md" color="text-dark" fw={700}>
{t`Loading question...`}
</Text>
</Box>
</Flex>
</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}>
<Flex align="center" justify="space-between" gap="0.5rem">
{isEditingTitle ? (
<TextInput
ref={titleInputRef}
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
onBlur={handleTitleSave}
onKeyDown={handleTitleKeyDown}
size="md"
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}
c="pointer"
>
{displayName}
</Text>
{canWrite && (
<Icon
name="pencil"
size={14}
color="var(--mb-color-text-medium)"
className={styles.titleEditIcon}
c="pointer"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setEditedTitle(displayName);
setIsEditingTitle(true);
}}
/>
)}
</Box>
)}
{!isEditingTitle && (
<Menu withinPortal position="bottom-end">
<Menu.Target>
<Flex
component="button"
bg="transparent"
c="pointer"
p="0.25rem"
align="center"
justify="center"
style={{
border: "none",
borderRadius: "4px",
}}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Icon
name="ellipsis"
size={16}
color="var(--mb-color-text-medium)"
/>
</Flex>
</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>
)}
</Flex>
</Box>
)}
{rawSeries ? (
<>
<Box className={styles.questionResults}>
<Visualization
rawSeries={rawSeries}
metadata={metadata}
onChangeCardAndRun={handleChangeCardAndRun}
getExtraDataForClick={() => ({})}
isEditing={false}
isDashboard={false}
isDocument={true}
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,160 @@
import type { Editor } from "@tiptap/react";
import { useMemo, useState } from "react";
import { t } from "ttag";
import _ from "underscore";
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, Flex, 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";
import { useCardMetadata } from "./useCardMetadata";
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,
);
// Load metadata for card
useCardMetadata(card, isOpen);
const question = useMemo(() => {
if (!card || !metadata || !isOpen) {
return null;
}
const baseQuestion = new Question(card, metadata);
if (!modifiedQuestion) {
setModifiedQuestion(baseQuestion);
}
return baseQuestion;
}, [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)) {
await dispatch(loadMetadataForCard(newQuestion.card()));
const freshMetadata = getMetadata(store.getState());
const questionWithFreshMetadata = new Question(
newQuestion.card(),
freshMetadata,
);
setModifiedQuestion(questionWithFreshMetadata);
} 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"
>
{question && modifiedQuestion ? (
<>
<Box h="70vh" style={{ overflow: "auto" }}>
<Notebook
question={modifiedQuestion}
isDirty={true}
isRunnable={true}
isResultDirty={true}
reportTimezone="UTC"
hasVisualizeButton={false}
updateQuestion={handleUpdateQuestion}
runQuestionQuery={async () => {}}
/>
</Box>
<Flex mt="lg" justify="flex-end" gap="0.5rem">
<Button variant="subtle" onClick={onClose}>
{t`Cancel`}
</Button>
<Button variant="filled" onClick={handleSave}>
{t`Save and use`}
</Button>
</Flex>
</>
) : (
<Flex h="70vh" align="center" justify="center">
<Text>{t`Failed to load question data`}</Text>
</Flex>
)}
</Modal>
);
};

View File

@@ -0,0 +1,568 @@
import type { Editor } from "@tiptap/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { c, t } from "ttag";
import EmptyCodeResult from "assets/img/empty-states/code.svg";
import { skipToken } from "metabase/api";
import { datasetApi } from "metabase/api/dataset";
import { ErrorMessage } from "metabase/common/components/ErrorMessage";
import { isMac } from "metabase/lib/browser";
import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
import DataReference from "metabase/query_builder/components/dataref/DataReference";
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";
import { useCardMetadata } from "./useCardMetadata";
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>
);
};
export const NativeQueryModal = ({
card,
isOpen,
onClose,
onSave,
editor,
initialDataset,
}: NativeQueryModalProps) => {
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;
// Load metadata for card
useCardMetadata(card, isOpen);
// Question initialization
const question = useMemo(() => {
if (!card || !metadata || !isOpen) {
return null;
}
const baseQuestion = new Question(card, metadata);
if (!modifiedQuestion) {
setModifiedQuestion(baseQuestion);
}
return baseQuestion;
}, [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,
},
};
if (!question || !modifiedQuestion) {
return (
<Modal
opened={isOpen}
onClose={onClose}
size="95%"
title={t`Edit SQL Query`}
>
<Flex h="400px" align="center" justify="center">
<Loader size="lg" />
</Flex>
</Modal>
);
}
return (
<Modal
opened={isOpen}
onClose={onClose}
size="95%"
title={t`Edit SQL Query`}
padding="lg"
styles={modalStyles}
>
<Flex h="100%" direction="column">
{/* Main content area with horizontal layout */}
<Flex flex={1} direction="row" mih={0} style={{ overflow: "hidden" }}>
{/* Left side - Editor and Results */}
<Flex
flex={1}
direction="column"
mih={0}
miw={0}
style={{ overflow: "hidden" }}
>
<Box
pos="relative"
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mb-color-border)",
overflow: "hidden",
}}
>
{/* 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",
width: "100%",
minWidth: 0,
overflow: "hidden",
},
}}
/>
)}
</Box>
{/* Results area */}
<Flex
id="results-container"
flex={1}
direction="column"
mih={0}
pt="0.25rem"
pos="relative"
style={{ overflow: "hidden" }}
>
{!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 flex={1} mih="300px">
<Visualization
rawSeries={rawSeries}
metadata={metadata}
onChangeCardAndRun={() => {}}
getExtraDataForClick={() => ({})}
isEditing={false}
isDashboard={false}
showTitle={false}
/>
</Box>
) : !isQueryRunning ? (
<QueryExecutionEmptyState hasInitialData={false} />
) : !datasetToUse ? (
<Flex h="100%" align="center" justify="center">
<Loader size="lg" />
</Flex>
) : null}
{/* Loading overlay for when rerunning queries with existing results */}
{isQueryRunning && datasetToUse && (
<Flex
pos="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="var(--mb-color-bg-white)"
align="center"
justify="center"
style={{ zIndex: 10 }}
>
<Loader size="lg" />
</Flex>
)}
</Flex>
</Flex>
{/* Data reference sidebar */}
{isShowingDataReference && modifiedQuestion && (
<Box
w="350px"
miw="350px"
bg="var(--mb-color-bg-white)"
style={{
flexShrink: 0,
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>
)}
</Flex>
{/* Footer with actions */}
<Flex
pos="sticky"
bottom={0}
justify="flex-end"
gap="0.5rem"
p="1rem"
bg="var(--mb-color-bg-white)"
style={{
borderTop: "1px solid var(--mb-color-border)",
flexShrink: 0,
zIndex: 10,
}}
>
<Button variant="subtle" onClick={onClose}>
{t`Cancel`}
</Button>
<Button
variant="filled"
onClick={handleSave}
disabled={!modifiedQuestion}
>
{t`Save and use`}
</Button>
</Flex>
</Flex>
</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 @@
import { useEffect } from "react";
import _ from "underscore";
import { useDispatch } from "metabase/lib/redux";
import { loadMetadataForCard } from "metabase/questions/actions";
import type { Card } from "metabase-types/api";
/**
* Custom hook to load metadata for a card.
* Handles both regular cards (id > 0) and draft cards (id < 0).
* For draft cards, removes the id so loadMetadataForCard uses the adhoc metadata path.
*/
export const useCardMetadata = (card: Card | undefined, isOpen = true) => {
const dispatch = useDispatch();
useEffect(() => {
if (isOpen && card) {
// For draft cards (id < 0), omit the id so isSavedCard returns false
const cardForMetadata = card.id < 0 ? _.omit(card, "id") : card;
dispatch(loadMetadataForCard(cardForMetadata));
}
}, [isOpen, card, dispatch]);
};

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,157 @@
import { type Editor, Extension, type Range } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
export interface CommandOptions {
suggestion: Partial<SuggestionOptions>;
}
interface CommandProps {
command?: string;
clearQuery?: boolean;
switchToLinkMode?: boolean;
selectItem?: boolean;
entityId?: number;
model?: string;
}
export const CommandPluginKey = new PluginKey("command");
export const CommandExtension = Extension.create<CommandOptions>({
name: "command",
addOptions() {
return {
suggestion: {
char: "/",
pluginKey: CommandPluginKey,
allowSpaces: true,
items: ({ query: _query }: { query: string }) => [],
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: CommandProps;
}) => {
if (props.clearQuery && props.switchToLinkMode) {
const startPos = range.from + 1;
editor
.chain()
.focus()
.deleteRange({ from: startPos, to: range.to })
.run();
return;
}
if (props.selectItem && props.entityId && props.model) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "smartLink",
attrs: {
entityId: props.entityId,
model: props.model,
},
})
.run();
return;
}
if (props.command === "linkTo") {
return;
}
switch (props.command) {
case "heading1":
editor
.chain()
.focus()
.deleteRange(range)
.toggleHeading({ level: 1 })
.run();
break;
case "heading2":
editor
.chain()
.focus()
.deleteRange(range)
.toggleHeading({ level: 2 })
.run();
break;
case "heading3":
editor
.chain()
.focus()
.deleteRange(range)
.toggleHeading({ level: 3 })
.run();
break;
case "bulletList":
editor
.chain()
.focus()
.deleteRange(range)
.toggleBulletList()
.run();
break;
case "orderedList":
editor
.chain()
.focus()
.deleteRange(range)
.toggleOrderedList()
.run();
break;
case "blockquote":
editor
.chain()
.focus()
.deleteRange(range)
.toggleBlockquote()
.run();
break;
case "codeBlock":
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
break;
}
},
selectItem: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: CommandProps;
}) => {
if (props.entityId && props.model) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "smartLink",
attrs: {
entityId: props.entityId,
model: props.model,
},
})
.run();
}
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

View File

@@ -0,0 +1,436 @@
import type { Editor, Range } from "@tiptap/core";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react";
import { t } from "ttag";
import { useListRecentsQuery, useSearchQuery } from "metabase/api";
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
import { Box, Group, Icon, type IconName, Loader, Text } from "metabase/ui";
import type { RecentItem, SearchModel, SearchResult } from "metabase-types/api";
import styles from "../../Editor.module.css";
import type { MenuItem } from "../../shared/MenuComponents";
import {
MenuItemComponent,
SearchResultsFooter,
} from "../../shared/MenuComponents";
import {
buildRecentsMenuItems,
buildSearchMenuItems,
isRecentQuestion,
} from "../shared/suggestionUtils";
interface CommandSuggestionProps {
items: SearchResult[];
command: (item: CommandItem) => void;
editor: Editor;
range: Range;
query: string;
}
interface CommandItem {
command?: string;
clearQuery?: boolean;
switchToLinkMode?: boolean;
selectItem?: boolean;
entityId?: number | string;
model?: string;
}
interface SuggestionRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
interface CommandOption {
icon?: IconName;
text?: string;
label: string;
description?: string;
command: string;
}
interface LinkMenuItem {
icon: IconName;
label: string;
id: number | string;
model: SearchModel;
action: () => void;
}
const MODELS_TO_SEARCH: SearchModel[] = [
"card",
"dataset",
"dashboard",
"collection",
"table",
"database",
];
const CommandMenuItem = ({
option,
isSelected,
onClick,
}: {
option: CommandOption;
isSelected?: boolean;
onClick?: () => void;
}) => (
<Box
p="sm"
className={styles.suggestionMenuItem}
data-selected={isSelected || undefined}
onClick={onClick}
>
<Group gap="sm" wrap="nowrap" align="center">
{option.icon ? (
<Icon
name={option.icon}
size={16}
color="var(--mb-color-text-medium)"
/>
) : option.text ? (
<Group w={16} h={16} gap={0} align="center" justify="center">
<Text size="xs" fw={700} c="text-medium">
{option.text}
</Text>
</Group>
) : null}
<Box>
<Text size="md" fw={500}>
{option.label}
</Text>
{option.description && (
<Text size="sm" c="text-medium">
{option.description}
</Text>
)}
</Box>
</Group>
</Box>
);
CommandMenuItem.displayName = "CommandMenuItem";
const CommandSuggestionComponent = forwardRef<
SuggestionRef,
CommandSuggestionProps
>(function CommandSuggestionComponent({ command, editor, query }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
const [showLinkSearch, setShowLinkSearch] = useState(false);
const [pendingLinkMode, setPendingLinkMode] = useState(false);
const [modal, setModal] = useState<"question-picker" | null>(null);
const { data: recents = [], isLoading: isRecentsLoading } =
useListRecentsQuery(undefined, { refetchOnMountOrArgChange: true });
const filteredRecents = recents.filter(isRecentQuestion).slice(0, 4);
useEffect(() => {
if (pendingLinkMode) {
setShowLinkSearch(true);
setPendingLinkMode(false);
}
}, [pendingLinkMode]);
const effectiveQuery = query;
const { data: searchResponse, isLoading: isSearchLoading } = useSearchQuery(
{
q: effectiveQuery,
models: MODELS_TO_SEARCH,
limit: 4,
},
{
skip: !showLinkSearch || !effectiveQuery || effectiveQuery.length === 0,
},
);
const searchResults = useMemo(
() => (searchResponse?.data as SearchResult[]) ?? [],
[searchResponse],
);
const allCommandOptions: CommandOption[] = useMemo(
() => [
{
text: "H1",
label: t`Heading 1`,
command: "heading1",
},
{
text: "H2",
label: t`Heading 2`,
command: "heading2",
},
{
text: "H3",
label: t`Heading 3`,
command: "heading3",
},
{
icon: "list",
label: t`Bullet list`,
command: "bulletList",
},
{
icon: "ordered_list",
label: t`Numbered list`,
command: "orderedList",
},
{
icon: "quote",
label: t`Quote`,
command: "blockquote",
},
{
icon: "code_block",
label: t`Code block`,
command: "codeBlock",
},
{
icon: "link",
label: t`Link to...`,
description: t`Insert a reference to a question, dashboard, or collection`,
command: "linkTo",
},
],
[],
);
// Filter command options based on query when not in link search mode
const commandOptions = useMemo(() => {
if (showLinkSearch) {
return allCommandOptions;
}
if (!query) {
return allCommandOptions;
}
const lowerQuery = query.toLowerCase();
return allCommandOptions.filter((option) =>
option.label.toLowerCase().includes(lowerQuery),
);
}, [allCommandOptions, query, showLinkSearch]);
const handleRecentSelect = useCallback(
(item: RecentItem) => {
command({
selectItem: true,
entityId: item.id,
model: item.model,
});
},
[command],
);
const handleSearchResultSelect = useCallback(
(item: SearchResult) => {
command({
selectItem: true,
entityId: item.id,
model: item.model,
});
},
[command],
);
const executeCommand = (commandName: string) => {
if (commandName === "linkTo") {
setPendingLinkMode(true);
command({
clearQuery: true,
switchToLinkMode: true,
});
return;
}
command({
command: commandName,
});
};
const linkMenuItems = useMemo(() => {
const items: Array<LinkMenuItem | MenuItem> = [];
if (effectiveQuery.length > 0) {
if (!isSearchLoading && searchResults.length > 0) {
items.push(
...buildSearchMenuItems(searchResults, handleSearchResultSelect),
);
}
} else {
if (!isRecentsLoading && filteredRecents.length > 0) {
items.push(
...buildRecentsMenuItems(filteredRecents, handleRecentSelect),
);
}
}
return items as LinkMenuItem[];
}, [
effectiveQuery,
searchResults,
isSearchLoading,
filteredRecents,
isRecentsLoading,
handleRecentSelect,
handleSearchResultSelect,
]);
const currentItems = showLinkSearch ? linkMenuItems : commandOptions;
const totalItems = showLinkSearch
? linkMenuItems.length + 1
: commandOptions.length;
const selectItem = (index: number) => {
if (showLinkSearch) {
if (index < linkMenuItems.length) {
linkMenuItems[index].action();
} else {
setModal("question-picker");
}
} else {
if (index < commandOptions.length) {
executeCommand(commandOptions[index].command);
}
}
};
const upHandler = () => {
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);
};
const downHandler = () => {
setSelectedIndex((prev) => (prev + 1) % totalItems);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => {
setSelectedIndex(0);
}, [currentItems.length, showLinkSearch]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
const handleModalSelect = (item: QuestionPickerValueItem) => {
command({
selectItem: true,
entityId: item.id,
model: item.model as SearchModel,
});
setModal(null);
};
const handleModalClose = () => {
setModal(null);
setTimeout(() => {
editor.commands.focus();
}, 0);
};
if (
showLinkSearch &&
((isRecentsLoading && effectiveQuery.length === 0) ||
(isSearchLoading && effectiveQuery.length > 0))
) {
return (
<Group justify="center" p="sm">
<Loader size="sm" />
</Group>
);
}
return (
<Box className={styles.suggestionPopup}>
<Box className={styles.suggestionScroll}>
{showLinkSearch &&
((isRecentsLoading && effectiveQuery.length === 0) ||
(isSearchLoading && effectiveQuery.length > 0)) ? (
<Group justify="center" p="sm">
<Loader size="sm" />
</Group>
) : showLinkSearch ? (
<>
{linkMenuItems.map((item, index) => (
<MenuItemComponent
key={index}
item={item}
isSelected={selectedIndex === index}
onClick={() => selectItem(index)}
/>
))}
{effectiveQuery.length > 0 &&
searchResults.length === 0 &&
!isSearchLoading ? (
<Box p="sm">
<Text size="md" c="text-medium" ta="center">
{t`No results found`}
</Text>
</Box>
) : null}
<SearchResultsFooter
isSelected={selectedIndex === linkMenuItems.length}
onClick={() => setModal("question-picker")}
/>
</>
) : (
<>
{commandOptions.length > 0 ? (
commandOptions.map((option, index) => (
<CommandMenuItem
key={index}
option={option}
isSelected={selectedIndex === index}
onClick={() => selectItem(index)}
/>
))
) : (
<Box p="sm">
<Text size="md" c="text-medium" ta="center">
{t`No commands found`}
</Text>
</Box>
)}
</>
)}
</Box>
{modal === "question-picker" && (
<QuestionPickerModal
onChange={handleModalSelect}
onClose={handleModalClose}
/>
)}
</Box>
);
});
export const CommandSuggestion = CommandSuggestionComponent;

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,82 @@
import { type Editor, Extension, type Range } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
export interface MentionOptions {
HTMLAttributes: Record<string, unknown>;
renderText: (props: {
options: MentionOptions;
node: ProseMirrorNode;
}) => string;
suggestion: Partial<SuggestionOptions>;
}
interface MentionProps {
type?: string;
id?: number | string;
model?: string;
}
export const MentionPluginKey = new PluginKey("mention");
export const MentionExtension = Extension.create<MentionOptions>({
name: "mention",
addOptions() {
return {
HTMLAttributes: {},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
suggestion: {
char: "@",
pluginKey: MentionPluginKey,
allowSpaces: true,
items: ({ query: _query }: { query: string }) => [],
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: MentionProps;
}) => {
if (props.type === "metabot") {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "metabot",
attrs: {},
})
.run();
} else if (props.id) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "cardEmbed",
attrs: {
id: props.id,
},
})
.run();
}
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

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