Compare commits
763 Commits
python-lib
...
poc-worksp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6453bfc560 | ||
|
|
c57cbdebdb | ||
|
|
2b036df2f1 | ||
|
|
ccbce4072d | ||
|
|
4b67d17c2c | ||
|
|
3001bf4c5d | ||
|
|
57cae37e7f | ||
|
|
0cc2bafc6a | ||
|
|
165b098e60 | ||
|
|
c34fbce32d | ||
|
|
2bede1f7d7 | ||
|
|
2e34ba3dbe | ||
|
|
1da6ff58fc | ||
|
|
e87131e2bc | ||
|
|
0ab1d4ccb6 | ||
|
|
ea64672746 | ||
|
|
ca28fd877e | ||
|
|
5e11e1d61b | ||
|
|
07e76075ad | ||
|
|
397828b8b7 | ||
|
|
95f52ef338 | ||
|
|
f9bdd5ee0a | ||
|
|
3a59a91a9f | ||
|
|
334116e05b | ||
|
|
b9a446a933 | ||
|
|
df4dfa576c | ||
|
|
9d6de7cb81 | ||
|
|
7bd57d1d66 | ||
|
|
8d4863d9a3 | ||
|
|
bc9da53ab1 | ||
|
|
d05166c2d4 | ||
|
|
1ec269b115 | ||
|
|
15f80f8a2a | ||
|
|
c993b3035a | ||
|
|
f0c57c07ef | ||
|
|
dfeafc9489 | ||
|
|
9f0c6d1e2e | ||
|
|
e716d1d253 | ||
|
|
395123c21a | ||
|
|
2c8431a93f | ||
|
|
7c8b01bc2a | ||
|
|
1cd4f17d50 | ||
|
|
48fb2b2209 | ||
|
|
8a12220a39 | ||
|
|
57c293888b | ||
|
|
020704ae57 | ||
|
|
662d3108fe | ||
|
|
dd4cb65777 | ||
|
|
06dbbc933b | ||
|
|
8b67a9af32 | ||
|
|
ef33301a24 | ||
|
|
8436f740a3 | ||
|
|
3f1be1386e | ||
|
|
699e763783 | ||
|
|
6ea4eb6a44 | ||
|
|
f136645482 | ||
|
|
f866fecdfe | ||
|
|
c8384764ff | ||
|
|
7f8908dba5 | ||
|
|
7e7670e57e | ||
|
|
fa72a54d9f | ||
|
|
f7c8221468 | ||
|
|
668b2fac9f | ||
|
|
81b69b44df | ||
|
|
bee4ba9eea | ||
|
|
9f5a37f138 | ||
|
|
9dbcefd21b | ||
|
|
098ff0271b | ||
|
|
4135955236 | ||
|
|
45c5d75dd3 | ||
|
|
b2a0c144ba | ||
|
|
480c4b125f | ||
|
|
81e95f8f1e | ||
|
|
9b56f27bf9 | ||
|
|
a3e2c79df6 | ||
|
|
9a56a9e96b | ||
|
|
96beaa2f89 | ||
|
|
9d1cc1cf48 | ||
|
|
436840eac9 | ||
|
|
ed9e470184 | ||
|
|
7691c2e2b6 | ||
|
|
c66c44768c | ||
|
|
efc7f4d468 | ||
|
|
460ab95a57 | ||
|
|
79788a1297 | ||
|
|
7cda572393 | ||
|
|
0431ed8174 | ||
|
|
4962055f41 | ||
|
|
e483ea3163 | ||
|
|
ab0bd1b8ff | ||
|
|
cbf95208b7 | ||
|
|
ad20bb5d6c | ||
|
|
9a50f3d2ab | ||
|
|
bbaf599e92 | ||
|
|
90a22c7a8d | ||
|
|
7c7293b2be | ||
|
|
c800fb73d0 | ||
|
|
0463d74f76 | ||
|
|
66959e5d16 | ||
|
|
69d648c26d | ||
|
|
93d5d7f87b | ||
|
|
8ca79acb03 | ||
|
|
9996b90fed | ||
|
|
fd76d041e6 | ||
|
|
f8924beba5 | ||
|
|
d3d47d423e | ||
|
|
f511b1d271 | ||
|
|
c33c1980e1 | ||
|
|
3515dff0e5 | ||
|
|
92eb7e62a1 | ||
|
|
6351f1c4ef | ||
|
|
fc5d2d764a | ||
|
|
55b2dc2b8e | ||
|
|
79e78c00a8 | ||
|
|
4adaef819b | ||
|
|
eb8f2d91d0 | ||
|
|
8c5adc5f85 | ||
|
|
a792b1706e | ||
|
|
268352d02b | ||
|
|
acafad1b6a | ||
|
|
4f95b0ebb7 | ||
|
|
92017cfe7c | ||
|
|
9ee59951ae | ||
|
|
403bf4eafd | ||
|
|
010af9dc10 | ||
|
|
95ef0b3aea | ||
|
|
67df0b5946 | ||
|
|
13049dab5d | ||
|
|
563dafbfd7 | ||
|
|
8c1e179da7 | ||
|
|
961097c64f | ||
|
|
9bea09d575 | ||
|
|
949903738d | ||
|
|
569be3656e | ||
|
|
ee4716edb1 | ||
|
|
0697911466 | ||
|
|
2421008499 | ||
|
|
94dc7d1003 | ||
|
|
e3ef53bc14 | ||
|
|
cc407adf92 | ||
|
|
20abdf0c68 | ||
|
|
04c5a5def6 | ||
|
|
81291b0432 | ||
|
|
5343a095ff | ||
|
|
02c1bb9fff | ||
|
|
9ced87a83a | ||
|
|
0d436b0fb5 | ||
|
|
937494ff3b | ||
|
|
40368f85fb | ||
|
|
978e12c2d6 | ||
|
|
e82e59a698 | ||
|
|
36088d2f62 | ||
|
|
4bd1a0ddee | ||
|
|
ea4c347006 | ||
|
|
5556654506 | ||
|
|
6dad74e2f6 | ||
|
|
4ea6c16f19 | ||
|
|
6620040235 | ||
|
|
f809d7747c | ||
|
|
181e8f31d2 | ||
|
|
32295f47b9 | ||
|
|
83bd4ba534 | ||
|
|
f770d21cdf | ||
|
|
f6f6b2d3a9 | ||
|
|
745ab32bde | ||
|
|
6760658aec | ||
|
|
3d1b366ecc | ||
|
|
bad6bccac2 | ||
|
|
7448009633 | ||
|
|
7418a137c8 | ||
|
|
40269ca9c0 | ||
|
|
f2f7857cae | ||
|
|
123793d2be | ||
|
|
42bee66fba | ||
|
|
76ee0b6f8c | ||
|
|
15e8dfa0d2 | ||
|
|
2da623bf82 | ||
|
|
1cb4ccc689 | ||
|
|
912aca8811 | ||
|
|
2e9f07ede2 | ||
|
|
9568b9956d | ||
|
|
80867e1335 | ||
|
|
ac6a903e3f | ||
|
|
28459254bc | ||
|
|
7894f1e45e | ||
|
|
fd4a621603 | ||
|
|
93557a26a2 | ||
|
|
bdd10e1248 | ||
|
|
0486d547d3 | ||
|
|
e46d720734 | ||
|
|
ec7163091a | ||
|
|
eba58e0951 | ||
|
|
9ece66c0ca | ||
|
|
12d177f857 | ||
|
|
6fe7b7e379 | ||
|
|
65de0ad883 | ||
|
|
dfc252996e | ||
|
|
fd173e472e | ||
|
|
287e496a1b | ||
|
|
b57ba12432 | ||
|
|
2ac44861b5 | ||
|
|
b09dd8f7de | ||
|
|
8924c126d2 | ||
|
|
47d994182c | ||
|
|
994d797a5e | ||
|
|
7699bfc8d9 | ||
|
|
17435642ef | ||
|
|
e6be8f5579 | ||
|
|
a917ade608 | ||
|
|
feb995d4c2 | ||
|
|
f8664fb617 | ||
|
|
eb154a11fb | ||
|
|
531e83d13e | ||
|
|
0bb9f85db6 | ||
|
|
02ba5c3c6e | ||
|
|
cc76cccad4 | ||
|
|
1799892d0b | ||
|
|
fa13ad9eba | ||
|
|
a61da38453 | ||
|
|
8353437442 | ||
|
|
6439ca2712 | ||
|
|
23a519e0f2 | ||
|
|
1060963f98 | ||
|
|
7e7b7e4829 | ||
|
|
9559623942 | ||
|
|
43a035c59a | ||
|
|
392b86e04e | ||
|
|
4f7c22ed53 | ||
|
|
ba806688b6 | ||
|
|
18898a5fdf | ||
|
|
73bc9f35af | ||
|
|
225264716a | ||
|
|
8c434cd2ac | ||
|
|
74407c341c | ||
|
|
47ef0aeaa3 | ||
|
|
bd0e83f217 | ||
|
|
5e4efad52a | ||
|
|
6f139518f5 | ||
|
|
a75c4e16f5 | ||
|
|
857b465224 | ||
|
|
59a4e89cef | ||
|
|
dfe7138ac4 | ||
|
|
1d2d50aca2 | ||
|
|
1ebe336a00 | ||
|
|
c6bf4a6549 | ||
|
|
c17c189c2a | ||
|
|
36790affcf | ||
|
|
a15154cb84 | ||
|
|
a10c221505 | ||
|
|
8d465a2854 | ||
|
|
c06902d967 | ||
|
|
a885281433 | ||
|
|
095cae88af | ||
|
|
5314f9f976 | ||
|
|
7189344bfd | ||
|
|
d26969ec1d | ||
|
|
11b74e0e4b | ||
|
|
c5862c278d | ||
|
|
eb3698c076 | ||
|
|
0af4b1c0de | ||
|
|
518dafaddc | ||
|
|
51041d6fca | ||
|
|
e4f962b92d | ||
|
|
0f33f4ae5a | ||
|
|
826259a206 | ||
|
|
2e2e874386 | ||
|
|
354821c166 | ||
|
|
4cd96fa6a2 | ||
|
|
f69374afa7 | ||
|
|
b47ccb6123 | ||
|
|
a77f40809b | ||
|
|
fb49882cae | ||
|
|
424c570fdf | ||
|
|
5271f4092d | ||
|
|
7764bb7ced | ||
|
|
266e5d99f5 | ||
|
|
c06ea7d391 | ||
|
|
8beea297c1 | ||
|
|
6aa63fbf89 | ||
|
|
59010b95cc | ||
|
|
3d393413b3 | ||
|
|
47df083512 | ||
|
|
53705b4e0e | ||
|
|
8c9d6b2a6d | ||
|
|
c2278512c5 | ||
|
|
e2e2e5fdf3 | ||
|
|
c844ac4211 | ||
|
|
958e0a7e3c | ||
|
|
1ae17a92e4 | ||
|
|
bb4ed09e80 | ||
|
|
892031c6f6 | ||
|
|
7765ddc263 | ||
|
|
329b903a8b | ||
|
|
a75ad79643 | ||
|
|
eb1f0ae78f | ||
|
|
a601d3bfc5 | ||
|
|
25f6c3142b | ||
|
|
77a96f847a | ||
|
|
c2278a5818 | ||
|
|
8040f2cc12 | ||
|
|
019a375bd6 | ||
|
|
31641fad7d | ||
|
|
5bbf5906cd | ||
|
|
e165f7ce9e | ||
|
|
058dad267c | ||
|
|
c7cf63fede | ||
|
|
61063dec9d | ||
|
|
a9bdd199e4 | ||
|
|
486a45e724 | ||
|
|
45576d45fd | ||
|
|
02cde50808 | ||
|
|
56e5d52791 | ||
|
|
fb4a4e85e5 | ||
|
|
a1508f49ad | ||
|
|
32256e3463 | ||
|
|
0be8ef8dc4 | ||
|
|
da8d743b7b | ||
|
|
9887170517 | ||
|
|
ef6fdda187 | ||
|
|
9eddb96417 | ||
|
|
4dbc959b5d | ||
|
|
903e9f9754 | ||
|
|
b04d9b9973 | ||
|
|
72d89ea81f | ||
|
|
cedab9b146 | ||
|
|
eacb72c1c2 | ||
|
|
4bd8baf4b1 | ||
|
|
a33f07a5f2 | ||
|
|
91e9161c09 | ||
|
|
507b219897 | ||
|
|
82acc79f82 | ||
|
|
10e55dedd9 | ||
|
|
db67174292 | ||
|
|
d2b79d570a | ||
|
|
2ee9f49b04 | ||
|
|
240862340a | ||
|
|
bc1870fc1d | ||
|
|
40d2ec1d49 | ||
|
|
2df2f7c38b | ||
|
|
7849e0394c | ||
|
|
574af1d732 | ||
|
|
364624bd9d | ||
|
|
8fa264cb17 | ||
|
|
9e06858b86 | ||
|
|
6660169b07 | ||
|
|
ca4744dc08 | ||
|
|
78e9dda8dd | ||
|
|
b36f00cf80 | ||
|
|
e28323011f | ||
|
|
3f24ba5687 | ||
|
|
d7dc44feb2 | ||
|
|
0450ba6a67 | ||
|
|
b30ecea5ca | ||
|
|
f95451e821 | ||
|
|
188ac08c97 | ||
|
|
fc8ca9b602 | ||
|
|
c3f2dee553 | ||
|
|
980edd8134 | ||
|
|
c6514d724f | ||
|
|
b288270e19 | ||
|
|
85ac70539a | ||
|
|
68366d7d37 | ||
|
|
73ecaff97b | ||
|
|
63a94c8788 | ||
|
|
10bd9d68ce | ||
|
|
e8a9827594 | ||
|
|
937d79aa45 | ||
|
|
f14049c3f7 | ||
|
|
3eb5c58e4f | ||
|
|
ab76b6ec5d | ||
|
|
7221915503 | ||
|
|
d808b10d42 | ||
|
|
4b57f82089 | ||
|
|
b8d2ac9ebc | ||
|
|
00e3499af8 | ||
|
|
d81a470a57 | ||
|
|
c329272406 | ||
|
|
75ec895750 | ||
|
|
60ca78c873 | ||
|
|
4e8471b305 | ||
|
|
1247a64fd3 | ||
|
|
43a13caa9d | ||
|
|
ad14c66b40 | ||
|
|
fa3ff7f21f | ||
|
|
45ee83044e | ||
|
|
1a83f3b2a6 | ||
|
|
c43a1c5ca5 | ||
|
|
3fc49ad4cf | ||
|
|
45a6dfde65 | ||
|
|
f7cddd1d08 | ||
|
|
1a0b75cdf9 | ||
|
|
3150aa88a8 | ||
|
|
4cb0b20b6d | ||
|
|
2c1f2a7ff3 | ||
|
|
5bb0bc8f4c | ||
|
|
0609af8aa8 | ||
|
|
2c17fc4262 | ||
|
|
69f11c8ebf | ||
|
|
7bba2731e8 | ||
|
|
84fc383ee5 | ||
|
|
b2e8e0e966 | ||
|
|
36f8088cdd | ||
|
|
9f264b57a1 | ||
|
|
c7aae620a7 | ||
|
|
2ff9e7d6d3 | ||
|
|
ebbe2e9987 | ||
|
|
71905fca4c | ||
|
|
3de578818a | ||
|
|
a7930e7395 | ||
|
|
5c0fcd368d | ||
|
|
93aa5c4247 | ||
|
|
4e1d08dd1e | ||
|
|
5122bbf99c | ||
|
|
f64b29b62b | ||
|
|
e3469a4443 | ||
|
|
ad58a25fc6 | ||
|
|
7c5f3f11db | ||
|
|
c24c38ffb5 | ||
|
|
8dbd232b94 | ||
|
|
fc67ea2c67 | ||
|
|
3d23c54c79 | ||
|
|
d22e6b3af6 | ||
|
|
2cff4b355b | ||
|
|
188f2b7aea | ||
|
|
b2ca21d29d | ||
|
|
5934f16d16 | ||
|
|
128a811f65 | ||
|
|
cfa69c2a42 | ||
|
|
e3aa5749e1 | ||
|
|
4dc79d954a | ||
|
|
a615cc4cd1 | ||
|
|
99a52fc794 | ||
|
|
105b0c43f5 | ||
|
|
02e268dee4 | ||
|
|
274acf55a3 | ||
|
|
5fda0f5233 | ||
|
|
2bbd79f443 | ||
|
|
d3fe209827 | ||
|
|
2dd560196d | ||
|
|
c206e276b8 | ||
|
|
9e63e9a14e | ||
|
|
ca01bb83d0 | ||
|
|
1e5315670f | ||
|
|
b51bc9834a | ||
|
|
960c2ad927 | ||
|
|
96f0a64d5e | ||
|
|
351127f13e | ||
|
|
d5900f0ac9 | ||
|
|
2590dfc694 | ||
|
|
fce642921a | ||
|
|
475e5396df | ||
|
|
1bfbdae0e1 | ||
|
|
720f9912e8 | ||
|
|
7edd0c374f | ||
|
|
9b708648d1 | ||
|
|
1ad9812411 | ||
|
|
cff5e706f9 | ||
|
|
af7bbb4df6 | ||
|
|
d4e0ba10e2 | ||
|
|
ca2d37115a | ||
|
|
f889695914 | ||
|
|
329bf084db | ||
|
|
db61438bd8 | ||
|
|
6191275795 | ||
|
|
de503b936e | ||
|
|
b608d502be | ||
|
|
319cf701dc | ||
|
|
70eb6821ba | ||
|
|
779cab4b12 | ||
|
|
f795a73c79 | ||
|
|
42b2c620f1 | ||
|
|
b18ef50c42 | ||
|
|
336beeac82 | ||
|
|
4d232ae2a3 | ||
|
|
daab0d5c47 | ||
|
|
5c5b133c2d | ||
|
|
c59c17134a | ||
|
|
a3fe7cf37c | ||
|
|
c32bdd5190 | ||
|
|
2240f0421a | ||
|
|
2bc92b0f24 | ||
|
|
2588c8c45c | ||
|
|
b8418c7ca0 | ||
|
|
330f1db962 | ||
|
|
bfc1acf7cf | ||
|
|
0995bf3526 | ||
|
|
db14555683 | ||
|
|
c930bb6bf7 | ||
|
|
f0bf109b71 | ||
|
|
1e2bc4a81b | ||
|
|
2a64da4037 | ||
|
|
1a5e648f4f | ||
|
|
fa64759ed3 | ||
|
|
00d8e50082 | ||
|
|
606416ba23 | ||
|
|
f0e3873b50 | ||
|
|
4ff2e80092 | ||
|
|
b7f05e0712 | ||
|
|
407ffb01c4 | ||
|
|
86a55c97d9 | ||
|
|
850afa8366 | ||
|
|
0aaa3861d5 | ||
|
|
0f1eb823d3 | ||
|
|
e437887fb6 | ||
|
|
c1b1137b59 | ||
|
|
64448e927d | ||
|
|
614f574f4b | ||
|
|
c83231abaa | ||
|
|
f2a3859e32 | ||
|
|
d690b9b3d1 | ||
|
|
16fa5c09ae | ||
|
|
0aa0fc2f39 | ||
|
|
4d86edd990 | ||
|
|
29a500c0e2 | ||
|
|
7544897ab7 | ||
|
|
2c85824963 | ||
|
|
27420b5cb9 | ||
|
|
d409ac181e | ||
|
|
c3e67fd73e | ||
|
|
a7ab60060f | ||
|
|
6143a75f9e | ||
|
|
18ab9af893 | ||
|
|
d3469efb09 | ||
|
|
e00d09cc37 | ||
|
|
ef602ae2d0 | ||
|
|
0eb562cf1c | ||
|
|
65c5af5155 | ||
|
|
eb0e823d1e | ||
|
|
02e1014a6b | ||
|
|
23189653d8 | ||
|
|
c6eeb8e6ba | ||
|
|
22d1f30e24 | ||
|
|
af496e167d | ||
|
|
48199ff2f7 | ||
|
|
83ca395ccf | ||
|
|
fa59b190e8 | ||
|
|
bd83d0691b | ||
|
|
068fed491b | ||
|
|
9368abef4d | ||
|
|
8bfed17f4d | ||
|
|
6f2dff895a | ||
|
|
cbfd98f44f | ||
|
|
5b2fa90fdb | ||
|
|
b4bba9efd6 | ||
|
|
eb087c4192 | ||
|
|
f686175537 | ||
|
|
5a1ebe95df | ||
|
|
c1ea464137 | ||
|
|
f6e00a40f5 | ||
|
|
776c225467 | ||
|
|
0ee16ea060 | ||
|
|
6b28bce4ba | ||
|
|
7949fe641a | ||
|
|
2b07b83ca5 | ||
|
|
8b46adb273 | ||
|
|
436341ab76 | ||
|
|
cd681616bf | ||
|
|
aabc1d165f | ||
|
|
a4747ed263 | ||
|
|
cb5f72cad4 | ||
|
|
6c0b2d81ad | ||
|
|
504b1836ee | ||
|
|
ed65b849ef | ||
|
|
f060565489 | ||
|
|
4306da592c | ||
|
|
a39502c482 | ||
|
|
e0f1ecee2d | ||
|
|
6b8124421a | ||
|
|
9814449153 | ||
|
|
aa014299af | ||
|
|
b8ba1b4997 | ||
|
|
071489ca25 | ||
|
|
ef1fe0df28 | ||
|
|
f956bca44e | ||
|
|
caa0810c77 | ||
|
|
c29b56e71e | ||
|
|
6ae23161ec | ||
|
|
e284745696 | ||
|
|
df462d07d5 | ||
|
|
4f908179ec | ||
|
|
fb159d257d | ||
|
|
1cb1106954 | ||
|
|
9cbdac4de4 | ||
|
|
0b4b4dfced | ||
|
|
19f4980a7e | ||
|
|
d53506d41e | ||
|
|
f7380aa927 | ||
|
|
38e9f21dfb | ||
|
|
0a4c544aa9 | ||
|
|
38d0b3e2dd | ||
|
|
2ed5047d90 | ||
|
|
2775a7e069 | ||
|
|
d92773df06 | ||
|
|
dc093d4722 | ||
|
|
e1494a1442 | ||
|
|
03b9b74699 | ||
|
|
023864e72f | ||
|
|
9c479154a5 | ||
|
|
c3edf1d082 | ||
|
|
1079267144 | ||
|
|
611d221b12 | ||
|
|
747781ee29 | ||
|
|
7337c26226 | ||
|
|
10d49abef3 | ||
|
|
31503487b3 | ||
|
|
9c7f08c2ae | ||
|
|
8693547be1 | ||
|
|
b59e4aa1e1 | ||
|
|
29786d318c | ||
|
|
8e83fbbfe7 | ||
|
|
721394d2bc | ||
|
|
6f78bc4107 | ||
|
|
a2b0c4e9e6 | ||
|
|
cff26c60ed | ||
|
|
a92f7f4d51 | ||
|
|
4dfa96be17 | ||
|
|
90c0d5ad4a | ||
|
|
3bbe285a33 | ||
|
|
caaa2e8da5 | ||
|
|
d89c57cd70 | ||
|
|
e42d8420f0 | ||
|
|
aa05a564da | ||
|
|
0416b84c60 | ||
|
|
c3b6f4e1ad | ||
|
|
9307c56d7b | ||
|
|
b806d8e2ca | ||
|
|
6f29ee6f0d | ||
|
|
75994b3ca9 | ||
|
|
a4e8364b22 | ||
|
|
416f44ddcd | ||
|
|
a2aed5ce1b | ||
|
|
f52fce74d2 | ||
|
|
d1f0e0e6e1 | ||
|
|
0504b74133 | ||
|
|
6c74aa8158 | ||
|
|
ff738679f2 | ||
|
|
bbe8bc760a | ||
|
|
bd2cc27a5f | ||
|
|
5803b4f68e | ||
|
|
7dd7c25365 | ||
|
|
8f57302804 | ||
|
|
2383c6da0a | ||
|
|
c8b01f0336 | ||
|
|
f711c11b67 | ||
|
|
e01545767e | ||
|
|
ff1f1272ab | ||
|
|
9beb14ab41 | ||
|
|
fd48958f63 | ||
|
|
bbe476774e | ||
|
|
b9291ceef2 | ||
|
|
5b2fc3dbae | ||
|
|
be2337d27f | ||
|
|
ea07fdf2f9 | ||
|
|
29a6635dac | ||
|
|
29d46589ed | ||
|
|
d22050e590 | ||
|
|
ac3bfcd534 | ||
|
|
380fe7cd08 | ||
|
|
5f576eac43 | ||
|
|
b8a263a0e0 | ||
|
|
2583528cb3 | ||
|
|
0ade82a77a | ||
|
|
41390fd132 | ||
|
|
13f74c882a | ||
|
|
33edd890a3 | ||
|
|
1e8dbe446b | ||
|
|
663d0d235e | ||
|
|
4e242895da | ||
|
|
99cbfacba2 | ||
|
|
042ae3a26a | ||
|
|
05005f9117 | ||
|
|
658c2f101d | ||
|
|
e02d4d0fbc | ||
|
|
6bdeb7f383 | ||
|
|
3815358411 | ||
|
|
c85ec06431 | ||
|
|
5322f761c7 | ||
|
|
cbaa883075 | ||
|
|
259ca021b8 | ||
|
|
a753f2ed0a | ||
|
|
a254fc1462 | ||
|
|
397e369a2c | ||
|
|
f41c0b171f | ||
|
|
f941af8a14 | ||
|
|
b340f8ed7d | ||
|
|
461519495c | ||
|
|
f7d947ff72 | ||
|
|
ce0e349f21 | ||
|
|
fc5a6469ba | ||
|
|
8523bf0412 | ||
|
|
13d11d33cd | ||
|
|
bb15f10b44 | ||
|
|
f9241dd461 | ||
|
|
a4a135903b | ||
|
|
67d31cea38 | ||
|
|
2585f352a1 | ||
|
|
7443a2d8bc | ||
|
|
d2f02f7846 | ||
|
|
1e498de06b | ||
|
|
b7e77ac43c | ||
|
|
89fd0b5c2b | ||
|
|
5339aaa1ca | ||
|
|
1d9c6c71f3 | ||
|
|
9e4bca97ce | ||
|
|
809896ee0a | ||
|
|
aae3788116 | ||
|
|
821df47bac | ||
|
|
d743daaf20 | ||
|
|
853d9b0cf0 | ||
|
|
d49b886527 | ||
|
|
bb438cee88 | ||
|
|
c54de85f0a | ||
|
|
d87da10b7e | ||
|
|
4e88f438e9 | ||
|
|
5b6d472c96 | ||
|
|
9967f4fa07 | ||
|
|
9bf91fea0e | ||
|
|
d989b5b8c3 | ||
|
|
706d7f5dc4 | ||
|
|
bb85233eff | ||
|
|
0e1450f72d | ||
|
|
e64ecbbcad | ||
|
|
60373ead57 | ||
|
|
febfce8260 | ||
|
|
a4a927799d | ||
|
|
38c5fa5892 | ||
|
|
b7247b23e8 | ||
|
|
8e75cd74f4 | ||
|
|
a550e694b8 | ||
|
|
aaee0896e6 | ||
|
|
d495f23ea5 | ||
|
|
0648c78e1a | ||
|
|
1964438bbe | ||
|
|
87c5dd88d1 | ||
|
|
a8d1dcc496 | ||
|
|
9b8f61def1 | ||
|
|
27edaf23e6 | ||
|
|
fccfa9460b | ||
|
|
eba97140f7 | ||
|
|
9320a8fcc2 | ||
|
|
f276dabad9 | ||
|
|
f1fdf99562 | ||
|
|
eb4e329cd2 | ||
|
|
ec44b108ff | ||
|
|
d37fdca122 | ||
|
|
717b6bda5a | ||
|
|
7a9c5ba1ee | ||
|
|
7620abe8f7 | ||
|
|
d87a7ba751 | ||
|
|
29ebc2e48b | ||
|
|
fa0c36e0c8 | ||
|
|
316c16f333 | ||
|
|
5f60c16f30 | ||
|
|
9c30b51ec0 | ||
|
|
09ec30117f | ||
|
|
64f799a046 | ||
|
|
117e923663 | ||
|
|
87ed3b5965 | ||
|
|
2f13cb033d | ||
|
|
1434d05ebd | ||
|
|
b5e258f80d | ||
|
|
8252df95f2 | ||
|
|
1cd6a43417 |
@@ -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
2
.github/actions/test-driver/action.yml
vendored
2
.github/actions/test-driver/action.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ↑↑↑
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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]))
|
||||
|
||||
10
enterprise/backend/src/metabase_enterprise/documents/api.clj
Normal file
10
enterprise/backend/src/metabase_enterprise/documents/api.clj
Normal 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))
|
||||
@@ -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))
|
||||
@@ -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])
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.documents.init
|
||||
(:require
|
||||
[metabase-enterprise.documents.revisions.impl]))
|
||||
@@ -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])
|
||||
@@ -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)))
|
||||
@@ -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))
|
||||
@@ -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})))
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"Card"
|
||||
"Collection"
|
||||
"Dashboard"
|
||||
"Document"
|
||||
"NativeQuerySnippet"
|
||||
"Timeline"])
|
||||
|
||||
@@ -57,6 +58,8 @@
|
||||
"DatabaseRouter"
|
||||
"DashboardBookmark"
|
||||
"DataPermissions"
|
||||
"Document"
|
||||
"DocumentBookmark"
|
||||
"GroupTableAccessPolicy"
|
||||
"HTTPAction"
|
||||
"ImplicitAction"
|
||||
|
||||
219
enterprise/backend/src/metabase_enterprise/transforms/api.clj
Normal file
219
enterprise/backend/src/metabase_enterprise/transforms/api.clj
Normal 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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,2 @@
|
||||
(ns metabase-enterprise.transforms.core
|
||||
"API namespace for the `metabase-enterprise.transform` module.")
|
||||
@@ -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}))))))))
|
||||
@@ -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]))
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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) [])))))))
|
||||
@@ -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]))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
64
enterprise/backend/src/metabase_enterprise/worker/api.clj
Normal file
64
enterprise/backend/src/metabase_enterprise/worker/api.clj
Normal 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)))
|
||||
@@ -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))
|
||||
46
enterprise/backend/src/metabase_enterprise/worker/core.clj
Normal file
46
enterprise/backend/src/metabase_enterprise/worker/core.clj
Normal 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!])
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.worker.init
|
||||
(:require
|
||||
[metabase-enterprise.worker.canceling]))
|
||||
@@ -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")
|
||||
-)
|
||||
@@ -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}]))
|
||||
152
enterprise/backend/src/metabase_enterprise/worker/server.clj
Normal file
152
enterprise/backend/src/metabase_enterprise/worker/server.clj
Normal 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!))
|
||||
86
enterprise/backend/src/metabase_enterprise/worker/sync.clj
Normal file
86
enterprise/backend/src/metabase_enterprise/worker/sync.clj
Normal 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!)))
|
||||
192
enterprise/backend/src/metabase_enterprise/worker/tracking.clj
Normal file
192
enterprise/backend/src/metabase_enterprise/worker/tracking.clj
Normal 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"))
|
||||
@@ -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"))
|
||||
@@ -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")))))))))
|
||||
@@ -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
@@ -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))))))))
|
||||
@@ -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])))))))))
|
||||
@@ -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))))))
|
||||
@@ -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")))))))
|
||||
@@ -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"))))
|
||||
@@ -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])))))))))
|
||||
@@ -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"))))))))
|
||||
@@ -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}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
:model/DashboardBookmark
|
||||
:model/DataPermissions
|
||||
:model/DatabaseRouter
|
||||
:model/DocumentBookmark
|
||||
:model/CollectionPermissionGraphRevision
|
||||
:model/DashboardCardSeries
|
||||
:model/LoginHistory
|
||||
|
||||
@@ -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"))))))
|
||||
@@ -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")))))))
|
||||
@@ -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)))))))))))
|
||||
@@ -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")))))))))
|
||||
@@ -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))))))))))))
|
||||
@@ -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)))))
|
||||
@@ -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")))))))
|
||||
@@ -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)))))
|
||||
@@ -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)))
|
||||
@@ -33,6 +33,7 @@ export type MetabaseCollectionItem = {
|
||||
| "model"
|
||||
| "question"
|
||||
| "metric"
|
||||
| "in_document"
|
||||
| null;
|
||||
"last-edit-info"?: {
|
||||
email: string;
|
||||
|
||||
52
enterprise/frontend/src/metabase-enterprise/api/document.ts
Normal file
52
enterprise/frontend/src/metabase-enterprise/api/document.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
106
enterprise/frontend/src/metabase-enterprise/api/transform.ts
Normal file
106
enterprise/frontend/src/metabase-enterprise/api/transform.ts
Normal 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;
|
||||
@@ -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}`));
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DocumentBackButton } from "./DocumentBackButton";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./EmojiExtension";
|
||||
@@ -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
Reference in New Issue
Block a user