Compare commits
615 Commits
python-sec
...
workspace-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
891847254b | ||
|
|
605778eb41 | ||
|
|
c01d20cedc | ||
|
|
d244b51eb9 | ||
|
|
8c99830d22 | ||
|
|
ddea16883f | ||
|
|
b1c8896fa1 | ||
|
|
ecda02cbd1 | ||
|
|
3acd6d08a6 | ||
|
|
c92e4a9cc4 | ||
|
|
d9ab296660 | ||
|
|
c943c2b9fa | ||
|
|
ac97197f90 | ||
|
|
a0b4a5a6df | ||
|
|
fec9db182c | ||
|
|
d29351c314 | ||
|
|
884793c2d3 | ||
|
|
df3b36bbab | ||
|
|
57e47c49a4 | ||
|
|
9ae8b86c3a | ||
|
|
ec776f91b7 | ||
|
|
9dd5d35b21 | ||
|
|
4c439abd41 | ||
|
|
317be73022 | ||
|
|
22c688ce0b | ||
|
|
cbbc1b9cdf | ||
|
|
e1c49ee7f1 | ||
|
|
829aa5fa34 | ||
|
|
30b78cf81d | ||
|
|
96f8df736b | ||
|
|
44a816c0e7 | ||
|
|
0b973ee925 | ||
|
|
a3b7926074 | ||
|
|
b15deb0ea2 | ||
|
|
a2992ca98b | ||
|
|
90c83cbb6f | ||
|
|
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
|
||||
|
||||
@@ -1852,6 +1852,25 @@
|
||||
settings
|
||||
util}}
|
||||
|
||||
enterprise/transforms
|
||||
{:team "Querying"
|
||||
:api #{metabase-enterprise.transforms.api
|
||||
metabase-enterprise.transforms.init}
|
||||
:uses #{api
|
||||
config
|
||||
driver
|
||||
events
|
||||
lib
|
||||
models
|
||||
permissions
|
||||
query-processor
|
||||
settings
|
||||
sync
|
||||
task
|
||||
task-history
|
||||
util
|
||||
enterprise/worker}}
|
||||
|
||||
enterprise/upload-management
|
||||
{:team "Admin Webapp"
|
||||
:api #{metabase-enterprise.upload-management.api}
|
||||
@@ -1859,6 +1878,14 @@
|
||||
models
|
||||
premium-features
|
||||
upload
|
||||
util}}
|
||||
|
||||
enterprise/worker
|
||||
{:team "Querying"
|
||||
:api #{metabase-enterprise.transforms.api
|
||||
metabase-enterprise.transforms.core}
|
||||
:uses #{config
|
||||
system
|
||||
util}}}
|
||||
|
||||
;; namespaces matching these patterns (with `re-find`) are excluded from the module linter. Since regex literals
|
||||
|
||||
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.
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
[toucan2.tools.before-update :as t2.before-update]
|
||||
[toucan2.util :as t2.util]))
|
||||
|
||||
|
||||
(def changes*
|
||||
"An atom to store all the changes of models that we currently track."
|
||||
(atom {}))
|
||||
@@ -79,7 +78,7 @@
|
||||
:update
|
||||
(into {} (t2/changes row-or-instance))
|
||||
(into {} row-or-instance))
|
||||
clean-change)
|
||||
clean-change)
|
||||
path [(t2/table-name model) action]]
|
||||
;; ideally this should be debug, but for some reasons this doesn't get logged
|
||||
(on-change path change-info)
|
||||
@@ -117,6 +116,9 @@
|
||||
(track-one! model)
|
||||
(swap! tracked-models conj model)))
|
||||
|
||||
(comment
|
||||
(track! :model/Collection :model/Workspace))
|
||||
|
||||
(defn- untrack-one!
|
||||
[model]
|
||||
(doseq [[hook aux-method _action] hook+aux-method+action+deriveable]
|
||||
|
||||
@@ -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,7 +29,9 @@
|
||||
[metabase-enterprise.scim.routes]
|
||||
[metabase-enterprise.serialization.api]
|
||||
[metabase-enterprise.stale.api]
|
||||
[metabase-enterprise.transforms.api]
|
||||
[metabase-enterprise.upload-management.api]
|
||||
[metabase-enterprise.workspaces.api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
@@ -49,12 +52,15 @@
|
||||
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
|
||||
:llm-autodescription (deferred-tru "LLM Auto-description")
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
:documents (deferred-tru "Documents")
|
||||
:scim (deferred-tru "SCIM configuration")
|
||||
:serialization (deferred-tru "Serialization")
|
||||
:table-data-editing (deferred-tru "Table Data Editing")
|
||||
:transforms (deferred-tru "Transforms")
|
||||
:upload-management (deferred-tru "Upload Management")
|
||||
:database-routing (deferred-tru "Database Routing")
|
||||
:cloud-custom-smtp (deferred-tru "Custom SMTP")})
|
||||
:cloud-custom-smtp (deferred-tru "Custom SMTP")
|
||||
:workspaces (deferred-tru "Workspaces")})
|
||||
|
||||
(defn- premium-handler [handler required-feature]
|
||||
(let [handler (cond-> handler
|
||||
@@ -98,10 +104,13 @@
|
||||
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
|
||||
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
|
||||
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
|
||||
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
|
||||
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
|
||||
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
|
||||
"/stale" (premium-handler metabase-enterprise.stale.api/routes :collection-cleanup)
|
||||
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)})
|
||||
"/transform" (premium-handler metabase-enterprise.transforms.api/routes :transforms)
|
||||
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)
|
||||
"/workspace" (premium-handler metabase-enterprise.workspaces.api/routes :workspaces)})
|
||||
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑
|
||||
|
||||
(def ^:private routes-map
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
[metabase-enterprise.audit-app.init]
|
||||
[metabase-enterprise.cache.init]
|
||||
[metabase-enterprise.database-replication.init]
|
||||
[metabase-enterprise.documents.init]
|
||||
[metabase-enterprise.gsheets.init]
|
||||
[metabase-enterprise.llm.init]
|
||||
[metabase-enterprise.metabot-v3.init]
|
||||
[metabase-enterprise.scim.init]
|
||||
[metabase-enterprise.sso.init]
|
||||
[metabase-enterprise.stale.init]))
|
||||
[metabase-enterprise.stale.init]
|
||||
[metabase-enterprise.transforms.init]))
|
||||
|
||||
13
enterprise/backend/src/metabase_enterprise/documents/api.clj
Normal file
13
enterprise/backend/src/metabase_enterprise/documents/api.clj
Normal file
@@ -0,0 +1,13 @@
|
||||
(ns metabase-enterprise.documents.api
|
||||
"`/api/ee/document/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.documents.api.document]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/document` routes."
|
||||
(handlers/routes
|
||||
(api.macros/ns-handler *ns* +auth)
|
||||
metabase-enterprise.documents.api.document/routes))
|
||||
@@ -0,0 +1,201 @@
|
||||
(ns metabase-enterprise.documents.api.document
|
||||
(:require
|
||||
[metabase-enterprise.documents.models.document :as m.document]
|
||||
[metabase-enterprise.documents.prose-mirror :as prose-mirror]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.queries.models.card :as card]
|
||||
[metabase.query-permissions.core :as query-perms]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private CardCreateSchema
|
||||
"Schema for creating a new card - simplified version to avoid circular dependencies"
|
||||
[:map
|
||||
[:name ms/NonBlankString]
|
||||
[:type {:optional true} [:maybe [:= "question"]]]
|
||||
[:dataset_query ms/Map]
|
||||
[:entity_id {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:parameters {:optional true} [:maybe [:sequential ms/Map]]]
|
||||
[:parameter_mappings {:optional true} [:maybe [:sequential ms/Map]]]
|
||||
[:description {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:display ms/NonBlankString]
|
||||
[:visualization_settings ms/Map]
|
||||
[:result_metadata {:optional true} [:maybe [:sequential ms/Map]]]
|
||||
[:cache_ttl {:optional true} [:maybe ms/PositiveInt]]])
|
||||
|
||||
(defn- create-card!
|
||||
"Checks that the query is runnable by the current user then saves"
|
||||
[{query :dataset_query :as card} creator]
|
||||
(query-perms/check-run-permissions-for-query query)
|
||||
(card/create-card! card creator))
|
||||
|
||||
(mu/defn- update-cards-in-ast :- [:map [:document :any]
|
||||
[:content_type :string]]
|
||||
|
||||
[document :- [:map
|
||||
[:document :any]
|
||||
[:content_type :string]]
|
||||
card-id-map :- [:maybe [:map-of :int ms/PositiveInt]]]
|
||||
(cond-> document
|
||||
(map? document)
|
||||
(prose-mirror/update-ast (fn match-card-to-update [{:keys [type attrs]}]
|
||||
(and (= type prose-mirror/card-embed-type)
|
||||
(contains? card-id-map (:id attrs))))
|
||||
(fn update-card-id [embed]
|
||||
(update-in embed [:attrs :id] card-id-map)))))
|
||||
|
||||
(mu/defn- create-cards-for-document! :- [:map-of ms/NegativeInt ms/PositiveInt]
|
||||
"Creates cards for a document from the cards map.
|
||||
Returns a mapping from the original negative integer keys to the newly created card IDs.
|
||||
|
||||
Args:
|
||||
- cards-to-create: Map of negative-int -> CardCreateSchema data
|
||||
- document-id: ID of the document these cards belong to
|
||||
- document-collection-id: Collection ID of the document (for inheritance)
|
||||
- creator: User creating the cards
|
||||
|
||||
Returns:
|
||||
- Map of negative-int -> actual-card-id"
|
||||
[cards-to-create :- [:map-of [:int {:max -1}] CardCreateSchema]
|
||||
document-id :- ms/PositiveInt
|
||||
document-collection-id :- [:or :nil ms/PositiveInt]
|
||||
creator :- [:map [:id ms/PositiveInt]]]
|
||||
(when (seq cards-to-create)
|
||||
(reduce-kv
|
||||
(fn [result-map original-key card-data]
|
||||
(let [;; Merge document info into card data
|
||||
;; Cards inherit document's collection_id if not explicitly specified
|
||||
merged-card-data (-> card-data
|
||||
(assoc :document_id document-id)
|
||||
(cond-> (nil? (:collection_id card-data))
|
||||
(assoc :collection_id document-collection-id)))
|
||||
;; Create the card using the queries core function
|
||||
new-card (create-card! merged-card-data creator)]
|
||||
(assoc result-map original-key (:id new-card))))
|
||||
{}
|
||||
cards-to-create)))
|
||||
|
||||
(mu/defn- clone-cards-in-document! :- [:map-of ms/PositiveInt ms/PositiveInt]
|
||||
"Finds all cards in the document that are not associated with the document and clones the cards.
|
||||
|
||||
Args:
|
||||
- document: the document model to clone cards within
|
||||
|
||||
Returns:
|
||||
- map of old-card-id -> cloned-card-id"
|
||||
[{:keys [id collection_id] :as document}]
|
||||
(let [card-ids (prose-mirror/collect-ast document #(when (and (= prose-mirror/card-embed-type (:type %))
|
||||
(pos? (-> % :attrs :id)))
|
||||
(-> % :attrs :id)))
|
||||
to-clone (when (seq card-ids)
|
||||
(t2/select :model/Card {:where [:and [:in :id card-ids]
|
||||
[:or [:<> :document_id id]
|
||||
[:= :document_id nil]]]}))]
|
||||
(reduce (fn [accum card]
|
||||
(api/read-check card)
|
||||
(assoc accum
|
||||
(:id card)
|
||||
(:id (create-card! (assoc card :document_id id :collection_id collection_id)
|
||||
@api/*current-user*))))
|
||||
{}
|
||||
to-clone)))
|
||||
|
||||
(defn get-document
|
||||
"Get document by id checking if the current user has permission to access and if the document exists."
|
||||
[id]
|
||||
(api/check-404
|
||||
(api/read-check
|
||||
(t2/hydrate (t2/select-one :model/Document :id id) :creator :can_write))))
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
"Gets existing `Documents`."
|
||||
[_route-params
|
||||
_query-params]
|
||||
(t2/hydrate (t2/select :model/Document {:where (collection/visible-collection-filter-clause)}) :creator :can_write))
|
||||
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Create a new `Document`."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [name document collection_id cards]}
|
||||
:- [:map
|
||||
[:name :string]
|
||||
[:document :any]
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:cards {:optional true} [:maybe [:map-of [:int {:max -1}] CardCreateSchema]]]]]
|
||||
(collection/check-write-perms-for-collection collection_id)
|
||||
(let [document-id (t2/with-transaction [_conn]
|
||||
;; Validate existing cards first if provided
|
||||
(let [document-id (t2/insert-returning-pk! :model/Document {:name name
|
||||
:collection_id collection_id
|
||||
:document document
|
||||
:content_type prose-mirror/prose-mirror-content-type
|
||||
:creator_id api/*current-user-id*})
|
||||
cards-to-update-in-ast (merge (clone-cards-in-document! {:id document-id
|
||||
:collection_id collection_id
|
||||
:document document
|
||||
:content_type prose-mirror/prose-mirror-content-type})
|
||||
(when-not (empty? cards)
|
||||
(create-cards-for-document! cards document-id collection_id @api/*current-user*)))]
|
||||
;; Create new cards if provided
|
||||
(when (seq cards-to-update-in-ast)
|
||||
(t2/update! :model/Document :id document-id
|
||||
(update-cards-in-ast
|
||||
{:document document
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
cards-to-update-in-ast)))
|
||||
document-id))
|
||||
created-document (get-document document-id)]
|
||||
;; Publish event after successful creation
|
||||
(events/publish-event! :event/document-create
|
||||
{:object created-document
|
||||
:user-id api/*current-user-id*})
|
||||
created-document))
|
||||
|
||||
(api.macros/defendpoint :get "/:document-id"
|
||||
"Returns an existing Document by ID."
|
||||
[{:keys [document-id]} :- [:map [:document-id ms/PositiveInt]]]
|
||||
(api/read-check (get-document document-id)))
|
||||
|
||||
(api.macros/defendpoint :put "/:document-id"
|
||||
"Updates an existing `Document`."
|
||||
[{:keys [document-id]} :- [:map
|
||||
[:document-id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [name document collection_id cards] :as body} :- [:map
|
||||
[:name {:optional true} :string]
|
||||
[:document {:optional true} :any]
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:cards {:optional true} [:maybe [:map-of :int CardCreateSchema]]]]]
|
||||
(let [existing-document (api/check-404 (get-document document-id))]
|
||||
(api/check-403 (mi/can-write? existing-document))
|
||||
(when (api/column-will-change? :collection_id existing-document body)
|
||||
(m.document/validate-collection-move-permissions (:collection_id existing-document) collection_id))
|
||||
(t2/with-transaction [_conn]
|
||||
(t2/update! :model/Document document-id (cond-> {}
|
||||
document (merge (update-cards-in-ast
|
||||
{:document document
|
||||
:content_type (:content_type existing-document)}
|
||||
(merge
|
||||
(clone-cards-in-document! (assoc existing-document :document document))
|
||||
(when-not (empty? cards) (create-cards-for-document! cards document-id collection_id @api/*current-user*)))))
|
||||
name (assoc :name name)
|
||||
(contains? body :collection_id) (assoc :collection_id collection_id))))
|
||||
(let [updated-document (get-document document-id)]
|
||||
;; Publish event after successful update
|
||||
(events/publish-event! :event/document-update
|
||||
{:object updated-document
|
||||
:user-id api/*current-user-id*})
|
||||
updated-document)))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/document/` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.documents.init
|
||||
(:require
|
||||
[metabase-enterprise.documents.revisions.impl]))
|
||||
@@ -0,0 +1,54 @@
|
||||
(ns metabase-enterprise.documents.models.document
|
||||
(:require
|
||||
[metabase.api.common :as api]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.users.models.user]
|
||||
[metabase.util.log :as log]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Document [_model] :document)
|
||||
|
||||
(t2/deftransforms :model/Document
|
||||
{:document mi/transform-json})
|
||||
|
||||
(doto :model/Document
|
||||
(derive :metabase/model)
|
||||
(derive :perms/use-parent-collection-perms)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(defn validate-collection-move-permissions
|
||||
"Validates that the current user has write permissions for both old and new collections
|
||||
when moving a document. Uses the same permission pattern as check-allowed-to-change-collection.
|
||||
Throws 403 exception if permissions are insufficient."
|
||||
[old-collection-id new-collection-id]
|
||||
(when old-collection-id
|
||||
(collection/check-write-perms-for-collection old-collection-id))
|
||||
(when new-collection-id
|
||||
(collection/check-write-perms-for-collection new-collection-id))
|
||||
(when new-collection-id
|
||||
(api/check-400 (t2/exists? :model/Collection :id new-collection-id :archived false))))
|
||||
|
||||
(methodical/defmethod t2/batched-hydrate [:model/Document :creator]
|
||||
"Hydrate the creator (user) of a document based on the creator_id."
|
||||
[_model k documents]
|
||||
(mi/instances-with-hydrated-data
|
||||
documents k
|
||||
#(-> (t2/select [:model/User :id :email :first_name :last_name] :id (keep :creator_id documents))
|
||||
(map (juxt :id identity))
|
||||
(into {}))
|
||||
:creator_id {:default {}}))
|
||||
|
||||
(defn sync-document-cards-collection!
|
||||
"Updates all cards associated with a document to match the document's collection.
|
||||
Returns the number of cards updated."
|
||||
[document-id collection-id]
|
||||
(t2/update! :model/Card
|
||||
:document_id document-id
|
||||
{:collection_id collection-id}))
|
||||
|
||||
(t2/define-after-update :model/Document
|
||||
[{:keys [id collection_id]}]
|
||||
(sync-document-cards-collection! id collection_id))
|
||||
@@ -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,28 @@
|
||||
(ns metabase-enterprise.documents.revisions.impl
|
||||
"Document revision serialization implementation.
|
||||
|
||||
Implements Document revision serialization following the Card exclusion pattern,
|
||||
excluding metadata columns while preserving content, name, and other Document-specific fields."
|
||||
(:require
|
||||
[metabase.revisions.models.revision :as revision]))
|
||||
|
||||
(def ^:private excluded-columns-for-document-revision
|
||||
"Columns to exclude from Document revision serialization.
|
||||
|
||||
Excludes metadata columns (timestamps, IDs, collection_id, creator_id, etc.)
|
||||
following the same pattern established for Card revisions.
|
||||
|
||||
The :document field contains the actual content and is preserved."
|
||||
#{:id
|
||||
:creator_id
|
||||
:created_at
|
||||
:updated_at
|
||||
:collection_id})
|
||||
|
||||
(defmethod revision/serialize-instance :model/Document
|
||||
[_model _id instance]
|
||||
(apply dissoc instance excluded-columns-for-document-revision))
|
||||
|
||||
(defmethod revision/revert-to-revision! :model/Document
|
||||
[model id user-id serialized-document]
|
||||
((get-method revision/revert-to-revision! :default) model id user-id serialized-document))
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase-enterprise.metabot-v3.dummy-tools
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.documents.api.document :as documents.api]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.legacy-mbql.normalize :as mbql.normalize]
|
||||
@@ -289,6 +290,21 @@
|
||||
{:structured-output details}
|
||||
{:output (or details "report not found")}))))
|
||||
|
||||
(defn get-document-details
|
||||
"Get information about the document with ID `document-id`."
|
||||
[{:keys [document-id]}]
|
||||
(if (int? document-id)
|
||||
(try
|
||||
(if-let [doc (documents.api/get-document document-id nil)]
|
||||
(do
|
||||
{:structured-output {:id (:id doc)
|
||||
:name (:name doc)
|
||||
:document (:document doc)}})
|
||||
{:output "report not found"})
|
||||
(catch Exception e
|
||||
{:output (str "error fetching report: " (.getMessage e))}))
|
||||
{:output "invalid document_id"}))
|
||||
|
||||
(defn- execute-query
|
||||
[query-id legacy-query]
|
||||
(let [legacy-query (mbql.normalize/normalize legacy-query)
|
||||
|
||||
@@ -540,6 +540,24 @@
|
||||
[:result_columns ::columns]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-document-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:document_id :int]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(set/rename-keys % {:document_id :document-id})}]])
|
||||
|
||||
(mr/def ::get-document-details-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:document :string]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-table-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
@@ -777,6 +795,21 @@
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-document-details" :- [:merge ::get-document-details-result ::tool-request]
|
||||
"Get information about a given report."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::get-document-details-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-document-details) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::get-document-details-arguments arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (mc/decode ::get-document-details-result
|
||||
(metabot-v3.dummy-tools/get-document-details arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-table-details" :- [:merge ::get-table-details-result ::tool-request]
|
||||
"Get information about a given table or model."
|
||||
[_route-params
|
||||
|
||||
187
enterprise/backend/src/metabase_enterprise/transforms/api.clj
Normal file
187
enterprise/backend/src/metabase_enterprise/transforms/api.clj
Normal file
@@ -0,0 +1,187 @@
|
||||
(ns metabase-enterprise.transforms.api
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.transforms.execute :as transforms.execute]
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase-enterprise.worker.core :as worker]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[ring.util.response :as response]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mr/def ::transform-source
|
||||
[:map
|
||||
[:type [:= "query"]]
|
||||
[:query [:map [:database :int]]]])
|
||||
|
||||
(mr/def ::transform-target
|
||||
[:map
|
||||
[:type [:enum "table" "view"]]
|
||||
[:schema {:optional true} :string]
|
||||
[:name :string]])
|
||||
|
||||
(mr/def ::execution-trigger
|
||||
[:enum "none" "global-schedule"])
|
||||
|
||||
(comment
|
||||
;; Examples
|
||||
[{:id 1
|
||||
:name "Gadget Products"
|
||||
:source {:type "query"
|
||||
:query {:database 1
|
||||
:type "native",
|
||||
:native {:query "SELECT * FROM PRODUCTS WHERE CATEGORY = 'Gadget'"
|
||||
:template-tags {}}}}
|
||||
:target {:type "table"
|
||||
:schema "transforms"
|
||||
:name "gadget_products"}}]
|
||||
-)
|
||||
|
||||
(defn- source-database-id
|
||||
[transform]
|
||||
(-> transform :source :query :database))
|
||||
|
||||
(defn- check-database-feature
|
||||
[transform]
|
||||
(let [database (api/check-400 (t2/select-one :model/Database (source-database-id transform))
|
||||
(deferred-tru "The source database cannot be found."))
|
||||
feature (transforms.util/required-database-feature transform)]
|
||||
(api/check-400 (driver.u/supports? (:engine database) feature database)
|
||||
(deferred-tru "The database does not support the requested transform target type."))))
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
"Get a list of transforms."
|
||||
[_route-params
|
||||
_query-params]
|
||||
(api/check-superuser)
|
||||
(t2/hydrate (t2/select :model/Transform) :last_execution))
|
||||
|
||||
(api.macros/defendpoint :post "/"
|
||||
[_route-params
|
||||
_query-params
|
||||
body :- [:map
|
||||
[:name :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:source ::transform-source]
|
||||
[:target ::transform-target]
|
||||
[:execution_trigger {:optional true} ::execution-trigger]]]
|
||||
(api/check-superuser)
|
||||
(check-database-feature body)
|
||||
(when (transforms.util/target-table-exists? body)
|
||||
(api/throw-403))
|
||||
(t2/insert-returning-instance!
|
||||
:model/Transform (select-keys body [:name :description :source :target :execution_trigger])))
|
||||
|
||||
(api.macros/defendpoint :get "/:id"
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(log/info "get transform" id)
|
||||
(api/check-superuser)
|
||||
(let [{:keys [target] :as transform} (api/check-404 (t2/select-one :model/Transform id))
|
||||
database-id (source-database-id transform)
|
||||
target-table (transforms.util/target-table database-id target :active true)]
|
||||
(-> transform
|
||||
(t2/hydrate :last_execution)
|
||||
(assoc :table target-table))))
|
||||
|
||||
(api.macros/defendpoint :get "/execution"
|
||||
[params :-
|
||||
[:map
|
||||
[:offset ms/IntGreaterThanOrEqualToZero]
|
||||
[:limit ms/IntGreaterThanOrEqualToZero]
|
||||
[:sort_column {:optional true} [:enum "started_at" "ended_at"]]
|
||||
[:sort_direction {:optional true} [:enum "asc" "desc"]]
|
||||
[:transform_id {:optional true} ms/IntGreaterThanOrEqualToZero]
|
||||
[:status {:optional true} [:enum "started" "succeeded" "failed" "timeout"]]]]
|
||||
(log/info "get executions")
|
||||
(api/check-superuser)
|
||||
(update (worker/paged-executions (-> params
|
||||
(set/rename-keys {:transform_id :work_id})
|
||||
(assoc :work_type "transform")))
|
||||
:data #(mapv (fn [run]
|
||||
(-> run
|
||||
(set/rename-keys {:run_id :id
|
||||
:work_id :transform_id
|
||||
:run_method :trigger
|
||||
:start_time :started_at
|
||||
:end_time :ended_at})))
|
||||
%)))
|
||||
|
||||
(api.macros/defendpoint :put "/:id"
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]
|
||||
_query-params
|
||||
body :- [:map
|
||||
[:name {:optional true} :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:source {:optional true} ::transform-source]
|
||||
[:target {:optional true} ::transform-target]
|
||||
[:execution_trigger {:optional true} ::execution-trigger]]]
|
||||
(log/info "put transform" id)
|
||||
(api/check-superuser)
|
||||
(let [old (t2/select-one :model/Transform id)
|
||||
new (merge old body)
|
||||
target-fields #(-> % :target (select-keys [:schema :name]))]
|
||||
(check-database-feature new)
|
||||
(when (and (not= (target-fields old) (target-fields new))
|
||||
(transforms.util/target-table-exists? new))
|
||||
(api/throw-403)))
|
||||
(t2/update! :model/Transform id body)
|
||||
(t2/select-one :model/Transform id))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id"
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(log/info "delete transform" id)
|
||||
(api/check-superuser)
|
||||
(t2/delete! :model/Transform id)
|
||||
nil)
|
||||
|
||||
(api.macros/defendpoint :delete "/:id/table"
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(log/info "delete transform target table" id)
|
||||
(api/check-superuser)
|
||||
(transforms.util/delete-target-table-by-id! id)
|
||||
nil)
|
||||
|
||||
(api.macros/defendpoint :post "/cancel/:run-id"
|
||||
[{:keys [run-id]} :- [:map
|
||||
[:id :string]]]
|
||||
(log/info "canceling run " run-id)
|
||||
(api/check-superuser)
|
||||
(worker/mark-cancel-started-run! run-id)
|
||||
nil)
|
||||
|
||||
(api.macros/defendpoint :post "/:id/execute"
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(log/info "execute transform" id)
|
||||
(api/check-superuser)
|
||||
(let [transform (api/check-404 (t2/select-one :model/Transform id))
|
||||
start-promise (promise)]
|
||||
(future
|
||||
(transforms.execute/execute-mbql-transform! transform {:start-promise start-promise
|
||||
:run-method :manual}))
|
||||
(when (instance? Throwable @start-promise)
|
||||
(throw @start-promise))
|
||||
(let [result @start-promise
|
||||
run-id (when (and (vector? result) (= (first result) :started))
|
||||
(second result))]
|
||||
(-> (response/response {:message (deferred-tru "Transform execution started")
|
||||
:run_id run-id})
|
||||
(assoc :status 202)))))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/transform` routes."
|
||||
(handlers/routes
|
||||
(api.macros/ns-handler *ns* +auth)))
|
||||
@@ -0,0 +1,2 @@
|
||||
(ns metabase-enterprise.transforms.core
|
||||
"API namespace for the `metabase-enterprise.transform` module.")
|
||||
@@ -0,0 +1,240 @@
|
||||
(ns metabase-enterprise.transforms.execute
|
||||
(:require
|
||||
[clojure.core.async :as a]
|
||||
[clojure.string :as str]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.transforms.ordering :as transforms.ordering]
|
||||
[metabase-enterprise.transforms.sync :as transforms.sync]
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase-enterprise.worker.core :as worker]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.lib.schema.common :as schema.common]
|
||||
[metabase.query-processor.pipeline :as qp.pipeline]
|
||||
[metabase.sync.core :as sync]
|
||||
[metabase.task.core :as task]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.util.concurrent Executors ExecutorService Future ScheduledExecutorService TimeUnit)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defonce ^:private ^ScheduledExecutorService scheduler (Executors/newScheduledThreadPool 1))
|
||||
|
||||
(defonce ^:private ^ExecutorService executor (Executors/newVirtualThreadPerTaskExecutor))
|
||||
|
||||
(mr/def ::transform-details
|
||||
[:map
|
||||
[:transform-type [:enum {:decode/normalize schema.common/normalize-keyword} :view :table]]
|
||||
[:connection-details :any]
|
||||
[:query :string]
|
||||
[:output-table [:keyword {:decode/normalize schema.common/normalize-keyword}]]])
|
||||
|
||||
(mr/def ::transform-opts
|
||||
[:map
|
||||
[:overwrite? :boolean]])
|
||||
|
||||
(defn- sync-table!
|
||||
[database target]
|
||||
(let [table (or (transforms.util/target-table (:id database) target)
|
||||
(sync/create-table! database (select-keys target [:schema :name])))]
|
||||
(sync/sync-table! table)))
|
||||
|
||||
(defn- execute-mbl-transform-remote! [run-id driver transform-details opts]
|
||||
(try
|
||||
(worker/execute-transform! run-id driver transform-details opts)
|
||||
(catch Throwable t
|
||||
(log/error "Remote execution failed.")
|
||||
(worker/fail-started-run! run-id {:message (.getMessage t)})
|
||||
(throw t))))
|
||||
|
||||
(defn- sync-target!
|
||||
([transform-id run-id]
|
||||
(let [{:keys [source target]} (t2/select-one :model/Transform transform-id)
|
||||
db (get-in source [:query :database])
|
||||
database (t2/select-one :model/Database db)]
|
||||
(sync-target! target database run-id)))
|
||||
([target database _run-id]
|
||||
;; sync the new table (note that even a failed sync status means that the execution succeeded)
|
||||
(log/info "Syncing target" (pr-str target) "for transform")
|
||||
(sync-table! database target)))
|
||||
|
||||
;; register that we need to run sync after a transform is finished remotely
|
||||
(defmethod transforms.sync/post-success :transform
|
||||
[{:keys [run_id work_id]}]
|
||||
(sync-target! work_id run_id))
|
||||
|
||||
(defonce ^:private connections (atom {}))
|
||||
|
||||
(defn- cancel-run! [run-id]
|
||||
(when-some [cancel-chan (a/promise-chan) #_(get @connections run-id)]
|
||||
(swap! connections dissoc run-id)
|
||||
(a/put! cancel-chan :cancel!)
|
||||
(worker/cancel-run! run-id)))
|
||||
|
||||
(defn- execute-mbl-transform-local!
|
||||
[run-id driver transform-details opts]
|
||||
;; local run is responsible for status
|
||||
|
||||
;; start a timeout vthread
|
||||
(.submit executor ^Runnable (fn []
|
||||
(Thread/sleep (* 4 60 60 1000)) ;; 4 hours
|
||||
(when-some [cancel-chan (get @connections run-id)]
|
||||
(swap! connections dissoc run-id)
|
||||
(a/put! cancel-chan :cancel!)
|
||||
(worker/timeout-run! run-id))))
|
||||
(try
|
||||
(binding [qp.pipeline/*canceled-chan* (a/promise-chan)]
|
||||
(swap! connections assoc run-id qp.pipeline/*canceled-chan*)
|
||||
(driver/execute-transform! driver transform-details opts))
|
||||
(when (get @connections run-id)
|
||||
(swap! connections dissoc run-id)
|
||||
(worker/succeed-started-run! run-id))
|
||||
(catch Throwable t
|
||||
(worker/fail-started-run! run-id {:message (.getMessage t)})
|
||||
(throw t))))
|
||||
|
||||
(defn execute-mbql-transform!
|
||||
"Execute `transform` and sync its target table.
|
||||
|
||||
This is executing synchronously, but supports being kicked off in the background
|
||||
by delivering the `start-promise` just before the start when the beginning of the execution has been booked
|
||||
in the database."
|
||||
([transform] (execute-mbql-transform! transform nil))
|
||||
([{:keys [id source target] :as transform} {:keys [run-method start-promise]}]
|
||||
(try
|
||||
(let [db (get-in source [:query :database])
|
||||
{driver :engine :as database} (t2/select-one :model/Database db)
|
||||
feature (transforms.util/required-database-feature transform)
|
||||
run-id (str (u/generate-nano-id))
|
||||
transform-details {:transform-type (keyword (:type target))
|
||||
:connection-details (driver/connection-details driver database)
|
||||
:query (transforms.util/compile-source source)
|
||||
:output-table (transforms.util/qualified-table-name driver target)}
|
||||
opts {:overwrite? true}]
|
||||
(when-not (driver.u/supports? driver feature database)
|
||||
(throw (ex-info "The database does not support the requested transform target type."
|
||||
{:driver driver, :database database, :feature feature})))
|
||||
;; mark the execution as started and notify any observers
|
||||
(try
|
||||
(worker/start-run! run-id :transform id
|
||||
{:run_method run-method
|
||||
:is_local (not (worker/run-remote?))})
|
||||
(catch java.sql.SQLException e
|
||||
(if (= (.getSQLState e) "23505")
|
||||
(throw (ex-info "Transform is already running"
|
||||
{:error :already-running
|
||||
:transform-id id}
|
||||
e))
|
||||
(throw e))))
|
||||
(when start-promise
|
||||
(deliver start-promise [:started run-id]))
|
||||
(log/info "Executing transform" id "with target" (pr-str target))
|
||||
(if (worker/run-remote?)
|
||||
(execute-mbl-transform-remote! run-id driver transform-details opts)
|
||||
(do
|
||||
(execute-mbl-transform-local! run-id driver transform-details opts)
|
||||
(sync-target! target database run-id))))
|
||||
(catch Throwable t
|
||||
(log/error t "Error executing transform")
|
||||
(when start-promise
|
||||
;; if the start-promise has been delivered, this is a no-op,
|
||||
;; but we assume nobody would catch the exception anyway
|
||||
(deliver start-promise t))
|
||||
(throw t)))))
|
||||
|
||||
(def ^:private ^Long scheduled-execution-poll-delay-millis 2000)
|
||||
|
||||
(defn- start-transforms!
|
||||
[transforms opts]
|
||||
(let [started-transforms
|
||||
(mapv (fn [transform]
|
||||
(let [start-promise (promise)]
|
||||
(try
|
||||
(let [thunk (^:once fn []
|
||||
(execute-mbql-transform! transform (assoc opts :start-promise start-promise)))]
|
||||
(.submit executor ^Runnable (bound-fn* thunk)))
|
||||
(catch Throwable t
|
||||
(deliver start-promise t)))
|
||||
[transform start-promise]))
|
||||
transforms)]
|
||||
(reduce (fn [done [transform start-promise]]
|
||||
(let [result @start-promise
|
||||
run-id (when (and (vector? result) (= (first result) :started))
|
||||
(second result))]
|
||||
(if run-id
|
||||
(update done :started conj (assoc transform ::run-id run-id))
|
||||
(update done :failed conj transform))))
|
||||
{:started []
|
||||
:failed []}
|
||||
started-transforms)))
|
||||
|
||||
(defn- poll-transforms
|
||||
[transforms]
|
||||
(when-let [run-ids (seq (keep ::run-id transforms))]
|
||||
(loop []
|
||||
(Thread/sleep scheduled-execution-poll-delay-millis)
|
||||
(or (not-empty (into [] (map :work_id) (worker/inactive-runs run-ids)))
|
||||
(recur)))))
|
||||
|
||||
(defn execute-transforms!
|
||||
"Execute `transforms` and sync their target tables in dependency order.
|
||||
The function returns as soon as all transforms have started."
|
||||
[transforms opts]
|
||||
(let [ordering (transforms.ordering/transform-ordering transforms)
|
||||
_ (when-let [cycle (transforms.ordering/find-cycle ordering)]
|
||||
(let [id->name (into {} (map (juxt :id :name)) transforms)]
|
||||
(throw (ex-info (str "Cyclic transform definitions detected: "
|
||||
(str/join " → " (map id->name cycle)))
|
||||
{:cycle cycle}))))]
|
||||
(loop [id->transform (m/index-by :id transforms)
|
||||
to-start (into #{} (map :id) transforms)
|
||||
running #{}
|
||||
complete #{}]
|
||||
(when (seq to-start)
|
||||
(if-let [available-ids (not-empty (transforms.ordering/available-transforms ordering running complete))]
|
||||
;; there are transforms to start
|
||||
(let [{:keys [started failed]} (start-transforms! (map id->transform available-ids) opts)
|
||||
id->transform (reduce (fn [m transform]
|
||||
(assoc m (:id transform) transform))
|
||||
id->transform
|
||||
started)
|
||||
running' (into running (map :id) started)
|
||||
complete' (into complete (map :id) failed)
|
||||
to-start' (reduce disj to-start available-ids)
|
||||
completed (poll-transforms (map id->transform running'))]
|
||||
(recur id->transform
|
||||
to-start'
|
||||
(reduce disj running' completed)
|
||||
(reduce conj complete' completed)))
|
||||
;; nothing to start yet, poll for transforms to finish
|
||||
(if-let [completed (poll-transforms (map id->transform running))]
|
||||
(recur id->transform
|
||||
to-start
|
||||
(reduce disj running completed)
|
||||
(reduce conj complete completed))
|
||||
(throw (ex-info "There are transforms to start, but nothing can be started and nothing is running!"
|
||||
{:to-start to-start
|
||||
:ordering ordering
|
||||
:running running
|
||||
:complete complete}))))))))
|
||||
|
||||
;; TODO (eric)
|
||||
;;
|
||||
;; Can I do this?
|
||||
(defmethod task/init! ::CancelLostRuns [_]
|
||||
(.scheduleAtFixedRate scheduler
|
||||
#(try
|
||||
(log/trace "Checking for canceling items.")
|
||||
(let [runs (worker/reducible-canceled-local-runs)]
|
||||
(reduce (fn [_ run]
|
||||
(try
|
||||
(cancel-run! (:run_id run))
|
||||
(catch Throwable t
|
||||
(log/error t (str "Error canceling " (:run_id run))))))
|
||||
nil runs))
|
||||
(catch Throwable t
|
||||
(log/error t "Error while canceling on worker."))) 0 20 TimeUnit/SECONDS))
|
||||
@@ -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,39 @@
|
||||
(ns metabase-enterprise.transforms.models.transform
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.worker.core :as worker]
|
||||
[metabase.models.interface :as mi]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Transform [_model] :transform)
|
||||
|
||||
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped?]]
|
||||
(derive :model/Transform trait))
|
||||
|
||||
(t2/deftransforms :model/Transform
|
||||
{:source mi/transform-json
|
||||
:target mi/transform-json
|
||||
:execution_trigger mi/transform-keyword})
|
||||
|
||||
(mi/define-batched-hydration-method with-transform
|
||||
:transform
|
||||
"Add transform to a WorkRun"
|
||||
[runs]
|
||||
(if-not (seq runs)
|
||||
runs
|
||||
(let [work-ids (into #{} (map :work_id) runs)
|
||||
id->transform (t2/select-pk->fn identity [:model/Transform :id :name] :id [:in work-ids])]
|
||||
(for [run runs]
|
||||
(assoc run :transform (get id->transform (:work_id run)))))))
|
||||
|
||||
(mi/define-batched-hydration-method with-last-execution
|
||||
:last_execution
|
||||
"Add last_execution to a transform"
|
||||
[transforms]
|
||||
(if-not (seq transforms)
|
||||
transforms
|
||||
(let [transform-ids (into #{} (map :id) transforms)
|
||||
last-executions (m/index-by :work_id (worker/latest-runs :transform transform-ids))]
|
||||
(for [transform transforms]
|
||||
(assoc transform :last_execution (get last-executions (:id transform)))))))
|
||||
@@ -0,0 +1,124 @@
|
||||
(ns metabase-enterprise.transforms.ordering
|
||||
(:require
|
||||
[clojure.core.match]
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[flatland.ordered.set :refer [ordered-set]]
|
||||
[macaw.core :as macaw]
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.util :as lib.util]
|
||||
[metabase.query-processor.preprocess :as qp.preprocess]
|
||||
[metabase.query-processor.store :as qp.store]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(defn- clean-name [str]
|
||||
(-> str
|
||||
(str/replace #"['\"`]" "")
|
||||
str/lower-case))
|
||||
|
||||
(defn- get-table [driver {:keys [table schema]}]
|
||||
(let [normalized-table (driver/normalize-name driver table)
|
||||
normalized-schema (when (seq schema)
|
||||
(driver/normalize-name driver schema))]
|
||||
(->> (qp.store/metadata-provider)
|
||||
lib.metadata/tables
|
||||
(some (fn [{db-table :name db-schema :schema id :id}]
|
||||
(and (= normalized-table db-table)
|
||||
(or (nil? normalized-schema)
|
||||
(= normalized-schema db-schema))
|
||||
id))))))
|
||||
|
||||
(defn- transform-deps [transform]
|
||||
(let [query (-> (get-in transform [:source :query])
|
||||
transforms.util/massage-sql-query
|
||||
qp.preprocess/preprocess)
|
||||
driver (-> (qp.store/metadata-provider)
|
||||
lib.metadata/database
|
||||
:engine)]
|
||||
(case (:type query)
|
||||
:native (->> (get-in query [:native :query])
|
||||
macaw/parsed-query
|
||||
macaw/query->components
|
||||
:tables
|
||||
(into #{} (keep #(->> % :component (get-table driver)))))
|
||||
:query (into #{}
|
||||
(keep #(clojure.core.match/match %
|
||||
[:source-table source] (when (int? source)
|
||||
source)
|
||||
_ nil))
|
||||
(tree-seq coll? seq query)))))
|
||||
|
||||
(defn- transform-deps-for-db [db-id transforms]
|
||||
(qp.store/with-metadata-provider db-id
|
||||
(into {}
|
||||
(map (juxt :id transform-deps))
|
||||
transforms)))
|
||||
|
||||
(defn- dependency-map [transforms]
|
||||
(into {}
|
||||
(map (juxt :id transform-deps))
|
||||
transforms))
|
||||
|
||||
(defn- output-table-map [transforms]
|
||||
(let [table-map (into {}
|
||||
(map (fn [{:keys [schema name id]}]
|
||||
[[schema name] id]))
|
||||
(lib.metadata/tables (qp.store/metadata-provider)))]
|
||||
(into {}
|
||||
(keep (fn [transform]
|
||||
(when-let [output-table (table-map [(get-in transform [:target :schema])
|
||||
(get-in transform [:target :name])])]
|
||||
[output-table (:id transform)])))
|
||||
transforms)))
|
||||
|
||||
(defn transform-ordering
|
||||
"Computes an 'ordering' of a given list of transforms.
|
||||
|
||||
The result is a map of transform id -> #{transform ids the transform depends on}. Dependencies are limited to just
|
||||
the transforms in the original list -- if a transform depends on some transform not in the list, the 'extra'
|
||||
dependency is ignored."
|
||||
[transforms]
|
||||
(let [transforms-by-db (->> transforms
|
||||
(map (fn [transform]
|
||||
{(get-in transform [:source :query :database]) [transform]}))
|
||||
(apply merge-with into))
|
||||
|
||||
{:keys [output-tables dependencies]} (->> transforms-by-db
|
||||
(map (fn [[db-id db-transforms]]
|
||||
(qp.store/with-metadata-provider db-id
|
||||
{:output-tables (output-table-map db-transforms)
|
||||
:dependencies (dependency-map db-transforms)})))
|
||||
(apply merge-with merge))]
|
||||
(update-vals dependencies #(into #{} (keep output-tables) %))))
|
||||
|
||||
(defn find-cycle
|
||||
"Finds a path containing a cycle in the directed graph `node->children`."
|
||||
[node->children]
|
||||
(loop [stack (into [] (map #(vector % (ordered-set))) (keys node->children))
|
||||
visited #{}]
|
||||
(when-let [[node path] (peek stack)]
|
||||
(cond
|
||||
(contains? path node)
|
||||
(into [] (drop-while (complement #{node})) (conj path node))
|
||||
|
||||
(contains? visited node)
|
||||
(recur (pop stack) visited)
|
||||
|
||||
:else
|
||||
(let [path' (conj path node)
|
||||
stack' (into (pop stack)
|
||||
(map #(vector % path'))
|
||||
(node->children node))]
|
||||
(recur stack' (conj visited node)))))))
|
||||
|
||||
(defn available-transforms
|
||||
"Given an ordering (see transform-ordering), a set of running transform ids, and a set of completed transform ids,
|
||||
computes which transforms are currently able to be run."
|
||||
[ordering running complete]
|
||||
(for [[transform-id deps] ordering
|
||||
:when (and (not (or (running transform-id)
|
||||
(complete transform-id)))
|
||||
(empty? (set/difference deps complete)))]
|
||||
transform-id))
|
||||
@@ -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,96 @@
|
||||
(ns metabase-enterprise.transforms.sync
|
||||
(:require
|
||||
[clojurewerkz.quartzite.conversion :as conversion]
|
||||
[clojurewerkz.quartzite.jobs :as jobs]
|
||||
[clojurewerkz.quartzite.schedule.calendar-interval :as calendar-interval]
|
||||
[clojurewerkz.quartzite.schedule.simple :as simple]
|
||||
[clojurewerkz.quartzite.triggers :as triggers]
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.worker.api :as api]
|
||||
[metabase-enterprise.worker.core :as worker]
|
||||
[metabase-enterprise.worker.models.worker-run]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.sql.query-processor :as sql.qp]
|
||||
[metabase.task.core :as task]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.util.concurrent
|
||||
Executors
|
||||
ExecutorService
|
||||
Future
|
||||
ScheduledExecutorService
|
||||
TimeUnit)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private job-key "metabase-enterprise.transforms.sync")
|
||||
|
||||
(defmulti post-success
|
||||
"What to run after successful remote run is synced locally. Dispatches on work_type."
|
||||
{:arglists '([run])}
|
||||
:work_type)
|
||||
|
||||
(defmethod post-success :default
|
||||
[_]
|
||||
#_do-nothing)
|
||||
|
||||
(defn- sync-worker-runs! [_ctx]
|
||||
(log/trace "Syncing worker runs.")
|
||||
(let [runs (worker/reducible-active-remote-runs)]
|
||||
(run! (fn [run]
|
||||
(try
|
||||
(let [resp (api/get-status (:run_id run))]
|
||||
(case (:status resp)
|
||||
"started"
|
||||
:do-nothing
|
||||
"canceling"
|
||||
(worker/mark-cancel-started-run! (:run_id run) {:end_time (t/instant (:end-time resp))
|
||||
:message (:note resp)})
|
||||
"canceled"
|
||||
(worker/cancel-run! (:run_id run) {:end_time (t/instant (:end-time resp))
|
||||
:message (:note resp)})
|
||||
"success"
|
||||
(do
|
||||
(worker/succeed-started-run! (:run_id run) {:end_time (t/instant (:end-time resp))})
|
||||
(post-success run))
|
||||
"error"
|
||||
(worker/fail-started-run! (:run_id run) {:end_time (t/instant (:end-time resp))
|
||||
:message (:note resp)})
|
||||
"timeout"
|
||||
(worker/timeout-run! (:run_id run) {:end_time (t/instant (:end-time resp))
|
||||
:message (:note resp)})))
|
||||
(catch Throwable t
|
||||
(log/error t (str "Error syncing " (:run_id run))))))
|
||||
runs))
|
||||
|
||||
(log/trace "Timing out old runs.")
|
||||
(worker/timeout-old-runs! 4 :hour)
|
||||
|
||||
(log/trace "Canceling items that haven't been marked canceled.")
|
||||
(worker/cancel-old-canceling-runs! 1 :minute))
|
||||
|
||||
(task/defjob ^{:doc "Syncs remote execution information with local table."
|
||||
org.quartz.DisallowConcurrentExecution true}
|
||||
SyncWorkerStatus [ctx]
|
||||
(sync-worker-runs! ctx))
|
||||
|
||||
(defn- start-job! []
|
||||
(when (not (task/job-exists? job-key))
|
||||
(let [job (jobs/build
|
||||
(jobs/of-type SyncWorkerStatus)
|
||||
(jobs/with-identity (jobs/key job-key)))
|
||||
trigger (triggers/build
|
||||
(triggers/with-identity (triggers/key job-key))
|
||||
(triggers/start-now)
|
||||
(triggers/with-schedule
|
||||
(calendar-interval/schedule
|
||||
(calendar-interval/with-interval-in-seconds 2)
|
||||
(calendar-interval/with-misfire-handling-instruction-do-nothing))))]
|
||||
(task/schedule-task! job trigger))))
|
||||
|
||||
(defmethod task/init! ::SyncWorkerStatus [_]
|
||||
(when (api/run-remote?)
|
||||
(log/info "Scheduling worker sync.")
|
||||
(start-job!)))
|
||||
@@ -0,0 +1,94 @@
|
||||
(ns metabase-enterprise.transforms.util
|
||||
(:require
|
||||
[metabase.driver :as driver]
|
||||
[metabase.query-processor.compile :as qp.compile]
|
||||
[metabase.sync.core :as sync]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn qualified-table-name
|
||||
"Return the name of the target table of a transform as a possibly qualified symbol."
|
||||
[_driver {:keys [schema name]}]
|
||||
(if schema
|
||||
(keyword schema name)
|
||||
(keyword name)))
|
||||
|
||||
(defn target-table-exists?
|
||||
"Test if the target table of a transform already exists."
|
||||
[{:keys [source target] :as _transform}]
|
||||
(let [db-id (-> source :query :database)
|
||||
database (t2/select-one :model/Database db-id)
|
||||
driver (:engine database)]
|
||||
(try
|
||||
(-> (driver/describe-table driver database target)
|
||||
:fields
|
||||
seq
|
||||
boolean)
|
||||
(catch Exception e
|
||||
(not (driver/table-known-to-not-exist? driver e))))))
|
||||
|
||||
(defn target-table
|
||||
"Load the `target` table of a transform from the database specified by `database-id`."
|
||||
[database-id target & kv-args]
|
||||
(some-> (apply t2/select-one :model/Table
|
||||
:db_id database-id
|
||||
:schema (:schema target)
|
||||
:name (:name target)
|
||||
kv-args)
|
||||
(t2/hydrate :db)))
|
||||
|
||||
(defn- sync-table!
|
||||
([database target] (sync-table! database target nil))
|
||||
([database target {:keys [create?]}]
|
||||
(when-let [table (or (target-table (:id database) target)
|
||||
(when create?
|
||||
(sync/create-table! database (select-keys target [:schema :name]))))]
|
||||
(sync/sync-table! table)
|
||||
table)))
|
||||
|
||||
(defn activate-table!
|
||||
"Activate table for `target` in `database` in the app db."
|
||||
[database target]
|
||||
(when-let [table (sync-table! database target {:create? true})]
|
||||
;; TODO this should probably be a function in the sync module
|
||||
(t2/update! :model/Table (:id table) {:active true})))
|
||||
|
||||
(defn deactivate-table!
|
||||
"Deactivate table for `target` in `database` in the app db."
|
||||
[database target]
|
||||
(when-let [table (sync-table! database target)]
|
||||
;; TODO this should probably be a function in the sync module
|
||||
(t2/update! :model/Table (:id table) {:active false})))
|
||||
|
||||
(defn delete-target-table!
|
||||
"Delete the target table of a transform and sync it from the app db."
|
||||
[{:keys [id target source], :as _transform}]
|
||||
(when target
|
||||
(let [target (update target :type keyword)
|
||||
database-id (-> source :query :database)
|
||||
{driver :engine :as database} (t2/select-one :model/Database database-id)]
|
||||
(driver/drop-transform-target! driver database target)
|
||||
(log/info "Deactivating target " (pr-str target) "for transform" id)
|
||||
(deactivate-table! database target))))
|
||||
|
||||
(defn delete-target-table-by-id!
|
||||
"Delete the target table of the transform specified by `transform-id`."
|
||||
[transform-id]
|
||||
(delete-target-table! (t2/select-one :model/Transform transform-id)))
|
||||
|
||||
(defn massage-sql-query [query]
|
||||
(assoc-in query [:middleware :disable-remaps?] true))
|
||||
|
||||
(defn compile-source
|
||||
"Compile the source query of a transform."
|
||||
[{query-type :type :as source}]
|
||||
(case query-type
|
||||
"query" (:query (qp.compile/compile-with-inline-parameters (massage-sql-query (:query source))))))
|
||||
|
||||
(defn required-database-feature
|
||||
"Returns the database feature necessary to execute `transform`."
|
||||
[transform]
|
||||
(case (-> transform :target :type)
|
||||
"table" :transforms/table
|
||||
"view" :transforms/view
|
||||
"materialized-view" :transforms/materialized-view))
|
||||
50
enterprise/backend/src/metabase_enterprise/worker/api.clj
Normal file
50
enterprise/backend/src/metabase_enterprise/worker/api.clj
Normal file
@@ -0,0 +1,50 @@
|
||||
(ns metabase-enterprise.worker.api
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- mb-id []
|
||||
(system/site-uuid))
|
||||
|
||||
(defn- json-body [{:keys [body]}]
|
||||
(json/decode+kw body))
|
||||
|
||||
(defn- worker-uri []
|
||||
(config/config-str :mb-transform-worker-uri))
|
||||
|
||||
(defn- worker-route [^String path]
|
||||
(when-let [base-uri (worker-uri)]
|
||||
(-> base-uri
|
||||
java.net.URI.
|
||||
(.resolve path)
|
||||
str)))
|
||||
|
||||
(defn run-remote?
|
||||
"Should this metabase instance run work remotely?"
|
||||
[]
|
||||
(boolean (worker-uri)))
|
||||
|
||||
(defn execute-transform!
|
||||
"Execute a transform on the remote worker."
|
||||
[run-id driver transform-details opts]
|
||||
(json-body (http/put (worker-route (str "/transform/" run-id))
|
||||
{:form-params {:driver driver
|
||||
:transform-details transform-details
|
||||
:opts opts
|
||||
:mb-source (mb-id)}
|
||||
:content-type :json})))
|
||||
|
||||
(defn get-status [run-id]
|
||||
(json-body (http/get (worker-route (str "/status/" run-id "?mb-source=" (mb-id)))
|
||||
{:content-type :json})))
|
||||
|
||||
(defn health-check []
|
||||
(:body (http/get (worker-route "/api/health"))))
|
||||
|
||||
(comment
|
||||
|
||||
(health-check))
|
||||
30
enterprise/backend/src/metabase_enterprise/worker/core.clj
Normal file
30
enterprise/backend/src/metabase_enterprise/worker/core.clj
Normal file
@@ -0,0 +1,30 @@
|
||||
(ns metabase-enterprise.worker.core
|
||||
(:require
|
||||
[metabase-enterprise.worker.api]
|
||||
[metabase-enterprise.worker.models.worker-run]
|
||||
[metabase-enterprise.worker.server]
|
||||
[metabase-enterprise.worker.tracking]
|
||||
[potemkin :as p]))
|
||||
|
||||
(p/import-vars
|
||||
[metabase-enterprise.worker.api
|
||||
execute-transform!
|
||||
get-status
|
||||
run-remote?]
|
||||
[metabase-enterprise.worker.models.worker-run
|
||||
cancel-old-canceling-runs!
|
||||
cancel-run!
|
||||
fail-started-run!
|
||||
inactive-runs
|
||||
latest-runs
|
||||
mark-cancel-started-run!
|
||||
paged-executions
|
||||
reducible-canceled-local-runs
|
||||
reducible-active-remote-runs
|
||||
start-run!
|
||||
succeed-started-run!
|
||||
timeout-old-runs!
|
||||
timeout-run!]
|
||||
[metabase-enterprise.worker.server
|
||||
start!
|
||||
stop!])
|
||||
@@ -0,0 +1,215 @@
|
||||
(ns metabase-enterprise.worker.models.worker-run
|
||||
(:require
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.driver.sql.query-processor :as sql.qp]
|
||||
[metabase.models.interface :as mi]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]
|
||||
[toucan2.realize :as t2.realize]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/WorkerRun [_model] :worker_run)
|
||||
|
||||
(derive :model/WorkerRun :metabase/model)
|
||||
|
||||
(t2/deftransforms :model/WorkerRun
|
||||
{:work_type mi/transform-keyword
|
||||
:status mi/transform-keyword
|
||||
:run_method mi/transform-keyword})
|
||||
|
||||
(def ^:private model->work-type
|
||||
{:model/Transform :transform})
|
||||
|
||||
(mi/define-simple-hydration-method add-worker-runs
|
||||
:worker-runs
|
||||
"Add worker-runs for a transform or other work. Must have :id field."
|
||||
[work]
|
||||
(t2/select :model/WorkerRun
|
||||
:work_id (:id work)
|
||||
:work_type (model->work-type (t2/model work))
|
||||
{:order-by [[:start_time :desc] [:end_time :desc]]}))
|
||||
|
||||
(defn- latest-runs-query [work-type work-ids]
|
||||
{:with [[:ranked_runs
|
||||
{:select [:*
|
||||
[[:over [[:row_number] {:partition-by :work_id, :order-by [[:start_time :desc]]}]] :rn]]
|
||||
:from [:worker_run]
|
||||
:where [:and
|
||||
[:= :work_type work-type]
|
||||
[:in :work_id work-ids]]}]]
|
||||
:select [:*]
|
||||
:from [:ranked_runs]
|
||||
:where [:= :rn [:inline 1]]})
|
||||
|
||||
(defn latest-runs
|
||||
"Return the latest runs for `work-type` and `work-ids`."
|
||||
[work-type work-ids]
|
||||
(when (seq work-ids)
|
||||
(into [] (map (comp t2.realize/realize #(dissoc % :rn)))
|
||||
(t2/reducible-select :model/WorkerRun (latest-runs-query (name work-type) work-ids)))))
|
||||
|
||||
(defn inactive-runs
|
||||
"Return the runs with run IDs in `run-ids` that are not active."
|
||||
[run-ids]
|
||||
(when (seq run-ids)
|
||||
(t2/select :model/WorkerRun
|
||||
:run_id [:in run-ids]
|
||||
:is_active nil)))
|
||||
|
||||
(defn start-run!
|
||||
"Start a run"
|
||||
([run-id work-type work-id]
|
||||
(start-run! run-id work-type work-id {}))
|
||||
([run-id work-type work-id properties]
|
||||
(t2/insert! :model/WorkerRun
|
||||
(assoc properties
|
||||
:run_id run-id
|
||||
:work_type work-type
|
||||
:work_id work-id
|
||||
:status :started
|
||||
:is_active true))))
|
||||
|
||||
(defn succeed-started-run!
|
||||
"Mark a started run as successfully completed."
|
||||
([run-id]
|
||||
(succeed-started-run! run-id {}))
|
||||
([run-id properties]
|
||||
(t2/update! :model/WorkerRun
|
||||
:run_id run-id
|
||||
:is_active true
|
||||
:status "started"
|
||||
(merge {:end_time :%now}
|
||||
properties
|
||||
{:status :succeeded
|
||||
:is_active nil}))))
|
||||
|
||||
(defn fail-started-run!
|
||||
"Mark the started active run as failed and inactive."
|
||||
[run-id properties]
|
||||
(t2/update! :model/WorkerRun
|
||||
:run_id run-id
|
||||
:is_active true
|
||||
:status :started
|
||||
(merge {:end_time :%now}
|
||||
properties
|
||||
{:status :failed
|
||||
:is_active nil})))
|
||||
|
||||
(defn mark-cancel-started-run!
|
||||
"Mark a started run for cancellation."
|
||||
([run-id]
|
||||
(mark-cancel-started-run! run-id
|
||||
{:message "Canceled by user"}))
|
||||
([run-id properties]
|
||||
(t2/update! :model/WorkerRun
|
||||
:run_id run-id
|
||||
:is_active true
|
||||
:status :started
|
||||
(merge {:end_time :%now}
|
||||
properties
|
||||
{:status :canceling}))))
|
||||
|
||||
(defn reducible-canceled-local-runs
|
||||
"Return a reducible sequence of local canceled runs."
|
||||
[]
|
||||
(t2/reducible-select :model/WorkerRun
|
||||
:is_local true
|
||||
:status "canceling"))
|
||||
|
||||
(defn reducible-active-remote-runs
|
||||
"Return a reducible sequence of active remote runs."
|
||||
[]
|
||||
(t2/reducible-select :model/WorkerRun :is_local false :is_active true))
|
||||
|
||||
(defn cancel-run!
|
||||
"Cancel a started or canceling run."
|
||||
([run-id]
|
||||
(cancel-run! run-id {:message "Canceled by user"}))
|
||||
([run-id properties]
|
||||
(t2/update! :model/WorkerRun
|
||||
:run_id run-id
|
||||
:status [:in ["started" "canceling"]]
|
||||
(merge {:end_time :%now}
|
||||
properties
|
||||
{:status :canceled
|
||||
:is_active nil}))))
|
||||
|
||||
(defn timeout-run!
|
||||
"Mark a started run as timed out."
|
||||
([run-id]
|
||||
(timeout-run! run-id {}))
|
||||
([run-id properties]
|
||||
(t2/update! :model/WorkerRun
|
||||
:run_id run-id
|
||||
:status "started"
|
||||
(merge {:end_time :%now
|
||||
:message "Timed out"}
|
||||
properties
|
||||
{:status :timeout
|
||||
:is_active nil}))))
|
||||
|
||||
(defn timeout-old-runs!
|
||||
"Time out all active runs older than the specified age."
|
||||
[age unit]
|
||||
(t2/update! :model/WorkerRun
|
||||
:is_active true
|
||||
:start_time [:< (sql.qp/add-interval-honeysql-form (mdb/db-type) :%now (- age) unit)]
|
||||
{:status :timeout
|
||||
:end_time :%now
|
||||
:is_active nil
|
||||
:message "Timed out by metabase"}))
|
||||
|
||||
(defn cancel-old-canceling-runs!
|
||||
"Cancel all canceling runs older than the specified age."
|
||||
[age unit]
|
||||
(t2/update! :model/WorkerRun
|
||||
:status "canceling"
|
||||
:end_time [:< (sql.qp/add-interval-honeysql-form (mdb/db-type) :%now (- age) unit)]
|
||||
{:status :canceled
|
||||
:end_time :%now
|
||||
:is_active nil
|
||||
:message "Canceled by user but could not guarantee run stopped."}))
|
||||
|
||||
(defn paged-executions
|
||||
"Return a page of the list of the executions.
|
||||
|
||||
Follows the conventions used by the FE."
|
||||
[{:keys [offset
|
||||
limit
|
||||
sort_column
|
||||
sort_direction
|
||||
work_type
|
||||
work_id
|
||||
status]}]
|
||||
(let [offset (or offset 0)
|
||||
limit (or limit 20)
|
||||
sort-direction (or (keyword sort_direction) :desc)
|
||||
nulls-sort (if (= sort-direction :asc)
|
||||
:nulls-last
|
||||
:nulls-first)
|
||||
sort-column (keyword sort_column)
|
||||
order-by (case sort_column
|
||||
:started_at [[sort-column sort-direction]]
|
||||
:ended_at [[sort-column sort-direction nulls-sort]]
|
||||
[[:start_time sort-direction]
|
||||
[:end_time sort-direction nulls-sort]])
|
||||
conditions (concat (when work_type
|
||||
[:work_type work_type])
|
||||
(when work_id
|
||||
[:work_id work_id])
|
||||
(when status
|
||||
[:= :status status])
|
||||
(when (= status "started")
|
||||
[:is_active true]))
|
||||
conditions-with-sort-and-pagination (concat conditions [{:order-by order-by
|
||||
:offset offset
|
||||
:limit limit}])
|
||||
runs (apply t2/select :model/WorkerRun conditions-with-sort-and-pagination)]
|
||||
{:data (t2/hydrate runs :transform)
|
||||
:limit limit
|
||||
:offset offset
|
||||
:total (apply t2/count :model/WorkerRun conditions)}))
|
||||
|
||||
(comment
|
||||
(t2/select :model/WorkerRun)
|
||||
(cancel-run! "slo7dhL7zoclb0uI0Zchj")
|
||||
-)
|
||||
130
enterprise/backend/src/metabase_enterprise/worker/server.clj
Normal file
130
enterprise/backend/src/metabase_enterprise/worker/server.clj
Normal file
@@ -0,0 +1,130 @@
|
||||
(ns metabase-enterprise.worker.server
|
||||
(:require
|
||||
[clojure.edn :as edn]
|
||||
[clojure.string :as str]
|
||||
[compojure.core :as compojure]
|
||||
[compojure.route :as route]
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[metabase-enterprise.transforms.core :as transforms]
|
||||
[metabase-enterprise.worker.tracking :as tracking]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.lib.schema.common :as schema.common]
|
||||
[metabase.server.core :as server]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.yaml :as yaml]
|
||||
[ring.adapter.jetty :as ring-jetty]
|
||||
[ring.middleware.keyword-params :refer [wrap-keyword-params]]
|
||||
[ring.middleware.params :refer [wrap-params]]
|
||||
[ring.util.response :as response])
|
||||
(:import (java.util.concurrent
|
||||
Executors
|
||||
ExecutorService
|
||||
Future
|
||||
ScheduledExecutorService
|
||||
Semaphore
|
||||
TimeUnit)
|
||||
(org.eclipse.jetty.util.thread QueuedThreadPool)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;; allow no more than 1000 running workers at a time
|
||||
(defonce ^:private ^Semaphore semaphore (Semaphore. 1000 true))
|
||||
|
||||
(defonce ^:private ^ExecutorService executor (Executors/newVirtualThreadPerTaskExecutor))
|
||||
|
||||
(mr/def ::transform-api-request
|
||||
[:map
|
||||
[:driver [:keyword {:decode/normalize schema.common/normalize-keyword}]]
|
||||
[:transform-details :metabase-enterprise.transforms.execute/transform-details]
|
||||
[:opts :metabase-enterprise.transforms.execute/transform-opts]
|
||||
[:mb-source :string]])
|
||||
|
||||
(defn- handle-transform-put
|
||||
[{:keys [params body]}]
|
||||
(log/info "Handling transform PUT request")
|
||||
(if (.tryAcquire semaphore)
|
||||
(let [{:keys [run-id]} params
|
||||
{:keys [driver transform-details opts mb-source]} (mc/coerce
|
||||
::transform-api-request
|
||||
body
|
||||
(mtx/transformer {:name :normalize}))]
|
||||
(when (tracking/track-start! run-id mb-source)
|
||||
(.submit executor
|
||||
^Runnable #(try
|
||||
(driver/execute-transform! driver
|
||||
transform-details
|
||||
opts)
|
||||
(tracking/track-finish! run-id)
|
||||
(catch Throwable t
|
||||
(log/error t "Error executing transform")
|
||||
(tracking/track-error! run-id (.getMessage t)))
|
||||
(finally
|
||||
(.release semaphore)))))
|
||||
(-> (response/response (tracking/get-status run-id mb-source))
|
||||
(response/content-type "application/json")))
|
||||
|
||||
(-> (response/response "Too many requests")
|
||||
(response/status 429))))
|
||||
|
||||
(defn- handle-status-get
|
||||
[run-id mb-source]
|
||||
(log/info "Handling status GET request")
|
||||
(if-let [resp (tracking/get-status run-id mb-source)]
|
||||
(response/response resp)
|
||||
(-> (response/response "Not found")
|
||||
(response/status 404))))
|
||||
|
||||
(def ^:private routes
|
||||
(compojure/routes
|
||||
(compojure/GET "/api/health" [] "healthy")
|
||||
(compojure/PUT "/transform/:run-id" request (handle-transform-put request))
|
||||
(compojure/GET "/status/:run-id" [run-id mb-source] (handle-status-get run-id mb-source))
|
||||
(route/not-found "Page not found")))
|
||||
|
||||
(def ^:private handler
|
||||
(reduce (fn [handler middleware-fn]
|
||||
(middleware-fn handler))
|
||||
routes
|
||||
[server/wrap-json-body
|
||||
server/wrap-streamed-json-response
|
||||
wrap-keyword-params
|
||||
wrap-params]))
|
||||
|
||||
(defn ^:private port []
|
||||
(if-let [port (config/config-str :mb-worker-jetty-port)]
|
||||
(Integer/parseInt port)
|
||||
3030))
|
||||
|
||||
(defonce ^:private instance (atom nil))
|
||||
|
||||
(defn stop! []
|
||||
(when @instance
|
||||
(log/info "Stopping worker server")
|
||||
(.stop ^QueuedThreadPool @instance)
|
||||
(reset! instance nil))
|
||||
nil)
|
||||
|
||||
(defn start!
|
||||
([] (start! {}))
|
||||
([opts]
|
||||
(stop!)
|
||||
(let [thread-pool (doto (new QueuedThreadPool)
|
||||
(.setVirtualThreadsExecutor (Executors/newVirtualThreadPerTaskExecutor)))
|
||||
jetty-port (port)]
|
||||
(log/info "Starting worker server on port" jetty-port)
|
||||
(reset! instance (ring-jetty/run-jetty #'handler
|
||||
{:port jetty-port
|
||||
:thread-pool thread-pool
|
||||
:join? (not (:dev opts))})))
|
||||
nil))
|
||||
|
||||
(comment
|
||||
|
||||
(start! {:dev true})
|
||||
(stop!))
|
||||
142
enterprise/backend/src/metabase_enterprise/worker/tracking.clj
Normal file
142
enterprise/backend/src/metabase_enterprise/worker/tracking.clj
Normal file
@@ -0,0 +1,142 @@
|
||||
(ns metabase-enterprise.worker.tracking
|
||||
(:require
|
||||
[java-time.api :as t]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.driver.sql-jdbc.execute :as sql-jdbc.execute])
|
||||
(:import
|
||||
(java.sql
|
||||
PreparedStatement)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- run-query! [driver conn-str sql params]
|
||||
(with-open [conn (java.sql.DriverManager/getConnection conn-str)
|
||||
stmt (sql-jdbc.execute/statement-or-prepared-statement driver
|
||||
conn
|
||||
sql
|
||||
params
|
||||
nil)]
|
||||
(let [rs (if (instance? PreparedStatement stmt)
|
||||
(.executeQuery ^PreparedStatement stmt)
|
||||
(.executeQuery stmt sql))
|
||||
rsmeta (.getMetaData rs)]
|
||||
(into [] (sql-jdbc.execute/reducible-rows driver rs rsmeta)))))
|
||||
|
||||
(defn- run-update! [driver conn-str sql params]
|
||||
(with-open [conn (java.sql.DriverManager/getConnection conn-str)
|
||||
stmt (sql-jdbc.execute/statement-or-prepared-statement driver
|
||||
conn
|
||||
sql
|
||||
params
|
||||
nil)]
|
||||
(if (instance? PreparedStatement stmt)
|
||||
(.executeUpdate ^PreparedStatement stmt)
|
||||
(.executeUpdate stmt sql))))
|
||||
|
||||
(defn track-start!
|
||||
[run-id mb-source]
|
||||
(-> (run-update! :postgres (config/config-str :mb-worker-db)
|
||||
"
|
||||
INSERT INTO worker_runs (run_id, source, status)
|
||||
SELECT ?, ?, 'running'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM worker_runs WHERE run_id = ?
|
||||
);"
|
||||
|
||||
[run-id mb-source run-id])
|
||||
(= 1))) ;; new?
|
||||
|
||||
(defn set-status!
|
||||
([run-id status]
|
||||
(set-status! run-id status ""))
|
||||
([run-id status note]
|
||||
(run-update! :postgres (config/config-str :mb-worker-db)
|
||||
"UPDATE worker_runs
|
||||
SET status = ?, end_time = now(), note = ?
|
||||
WHERE run_id = ?"
|
||||
[status note run-id])
|
||||
:ok))
|
||||
|
||||
(defn track-finish!
|
||||
[run-id]
|
||||
(set-status! run-id "success"))
|
||||
|
||||
(defn track-error!
|
||||
[run-id msg]
|
||||
(set-status! run-id "error" msg))
|
||||
|
||||
(def ^:private timeout-sec (* 4 60 60) #_sec)
|
||||
|
||||
(defn- handle-timeout
|
||||
[run]
|
||||
(if (and (= "running" (:status run))
|
||||
(> (:run-time run) timeout-sec))
|
||||
(-> run
|
||||
(assoc :status "timeout")
|
||||
(assoc :end-time (t/plus (:start-time run) (t/duration 4 :hours)))
|
||||
(assoc :note "Timed out.")
|
||||
(dissoc :run-time))
|
||||
(dissoc run :run-time)))
|
||||
|
||||
(defn get-status
|
||||
[run-id mb-source]
|
||||
(->> (run-query! :postgres (config/config-str :mb-worker-db)
|
||||
"SELECT run_id, status, start_time, end_time, note, EXTRACT(EPOCH FROM now() - start_time) as run_time
|
||||
FROM worker_runs
|
||||
WHERE run_id = ? AND source = ?"
|
||||
[run-id mb-source])
|
||||
first ;; row
|
||||
(zipmap [:run-id :status :start-time :end-time :note :run-time])
|
||||
handle-timeout
|
||||
not-empty))
|
||||
|
||||
(defn timeout-old-tasks
|
||||
[]
|
||||
(run-update! :postgres (config/config-str :mb-worker-db)
|
||||
"UPDATE worker_runs
|
||||
SET status = ?, end_time = NOW(), note = ?
|
||||
WHERE status = 'running' AND start_time < NOW() - INTERVAL '4 hours'"
|
||||
["timeout" "Timed out by worker"]))
|
||||
|
||||
(comment
|
||||
|
||||
(let [driver :postgres
|
||||
sql "SELECT * from worker_runs"]
|
||||
|
||||
(with-open [conn (java.sql.DriverManager/getConnection "jdbc:postgresql://localhost:5432/worker")]
|
||||
(with-open [stmt (sql-jdbc.execute/statement-or-prepared-statement driver
|
||||
conn
|
||||
sql
|
||||
[]
|
||||
nil)]
|
||||
(let [rs (if (instance? PreparedStatement stmt)
|
||||
(.executeQuery ^PreparedStatement stmt)
|
||||
(.executeQuery stmt sql))
|
||||
rsmeta (.getMetaData rs)]
|
||||
(into [] (sql-jdbc.execute/reducible-rows driver rs rsmeta))))))
|
||||
|
||||
(let [driver :postgres
|
||||
sql "insert into worker_runs (work_id, type, source, status) values (?, ?, ?, ?) RETURNING run_id"]
|
||||
|
||||
(with-open [conn (java.sql.DriverManager/getConnection "jdbc:postgresql://localhost:5432/worker")]
|
||||
(with-open [stmt (sql-jdbc.execute/statement-or-prepared-statement driver
|
||||
conn
|
||||
sql
|
||||
[1, "sql-transform", "mb-1", "running"]
|
||||
nil)]
|
||||
(let [rs (if (instance? PreparedStatement stmt)
|
||||
(.executeQuery ^PreparedStatement stmt)
|
||||
(.executeQuery stmt sql))
|
||||
rsmeta (.getMetaData rs)]
|
||||
(into [] (sql-jdbc.execute/reducible-rows driver rs rsmeta)))))))
|
||||
|
||||
(comment
|
||||
|
||||
(alter-var-root #'environ.core/env assoc :mb-worker-db "jdbc:postgresql://localhost:5432/worker")
|
||||
|
||||
(def id (str (random-uuid)))
|
||||
(track-start! id "mb-1")
|
||||
(track-finish! id)
|
||||
(track-error! id "oops")
|
||||
|
||||
(get-status id "mb-1"))
|
||||
@@ -0,0 +1,100 @@
|
||||
(ns metabase-enterprise.worker-db.migrations
|
||||
"Database migration utilities for worker databases."
|
||||
(:require
|
||||
[clojure.core :exclude [run!]]
|
||||
[clojure.java.io :as io]
|
||||
[metabase.classloader.core :as classloader]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms])
|
||||
(:import
|
||||
(javax.sql DataSource)
|
||||
(liquibase Contexts LabelExpression Liquibase)
|
||||
(liquibase.database Database DatabaseFactory)
|
||||
(liquibase.database.jvm JdbcConnection)
|
||||
(liquibase.resource ClassLoaderResourceAccessor ResourceAccessor)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mu/defn- run-migrations-from-resource!
|
||||
"Run Liquibase migrations from a resource file in the classpath.
|
||||
|
||||
Parameters:
|
||||
- data-source: A javax.sql.DataSource for the target database
|
||||
- resource-path: Path to the Liquibase changelog file in resources (e.g. \"migrations/my-db-changelog.yaml\")
|
||||
|
||||
Example:
|
||||
(run-migrations-from-resource! my-datasource \"migrations/external-db-changelog.yaml\")"
|
||||
[data-source :- (ms/InstanceOfClass DataSource)
|
||||
resource-path :- :string]
|
||||
(log/info "Running migrations from resource:" resource-path)
|
||||
|
||||
;; Verify the resource exists
|
||||
(when-not (io/resource resource-path)
|
||||
(throw (ex-info (str "Migration resource not found: " resource-path)
|
||||
{:resource-path resource-path})))
|
||||
|
||||
;; Create Liquibase instance manually with custom changelog
|
||||
(with-open [^java.sql.Connection conn (.getConnection ^DataSource data-source)]
|
||||
(let [^JdbcConnection liquibase-conn (JdbcConnection. conn)
|
||||
^Database database (.findCorrectDatabaseImplementation (DatabaseFactory/getInstance) liquibase-conn)
|
||||
^ResourceAccessor resource-accessor (ClassLoaderResourceAccessor. (classloader/the-classloader))
|
||||
^Liquibase lb (Liquibase. ^String resource-path resource-accessor database)]
|
||||
;; Run the migrations
|
||||
(try
|
||||
(log/info "Starting migration execution for" resource-path)
|
||||
(.update lb (Contexts.) (LabelExpression.))
|
||||
(log/info "Successfully completed migrations from" resource-path)
|
||||
:success
|
||||
(catch Exception e
|
||||
(log/error e "Failed to run migrations from" resource-path)
|
||||
(throw e))))))
|
||||
|
||||
(mu/defn- run-migrations-from-resource-with-connection-string!
|
||||
"Run Liquibase migrations from a resource file using a JDBC connection string.
|
||||
|
||||
Parameters:
|
||||
- connection-string: JDBC connection string (e.g. \"jdbc:postgresql://localhost/mydb?user=myuser&password=mypass\")
|
||||
- resource-path: Path to the Liquibase changelog file in resources (e.g. \"migrations/my-db-changelog.yaml\")
|
||||
|
||||
Example:
|
||||
(run-migrations-from-resource-with-connection-string!
|
||||
\"jdbc:postgresql://localhost/mydb?user=myuser&password=mypass\"
|
||||
\"migrations/external-db-changelog.yaml\")"
|
||||
[connection-string :- :string
|
||||
resource-path :- :string]
|
||||
(log/info "Running migrations from resource:" resource-path "with connection:" connection-string)
|
||||
|
||||
;; Verify the resource exists
|
||||
(when-not (io/resource resource-path)
|
||||
(throw (ex-info (str "Migration resource not found: " resource-path)
|
||||
{:resource-path resource-path})))
|
||||
|
||||
;; Create Liquibase instance manually with custom changelog and connection string
|
||||
(with-open [^java.sql.Connection conn (java.sql.DriverManager/getConnection connection-string)]
|
||||
(let [^JdbcConnection liquibase-conn (JdbcConnection. conn)
|
||||
^Database database (.findCorrectDatabaseImplementation (DatabaseFactory/getInstance) liquibase-conn)
|
||||
^ResourceAccessor resource-accessor (ClassLoaderResourceAccessor. (classloader/the-classloader))
|
||||
^Liquibase lb (Liquibase. ^String resource-path resource-accessor database)]
|
||||
;; Run the migrations
|
||||
(try
|
||||
(log/info "Starting migration execution for" resource-path)
|
||||
(.update lb (Contexts.) (LabelExpression.))
|
||||
(log/info "Successfully completed migrations from" resource-path)
|
||||
:success
|
||||
(catch Exception e
|
||||
(log/error e "Failed to run migrations from" resource-path)
|
||||
(throw e))))))
|
||||
|
||||
(defn run!
|
||||
"Run migrations for the worker."
|
||||
[]
|
||||
(let [worker-db-connection-string (config/config-str :mb-worker-db)]
|
||||
(run-migrations-from-resource-with-connection-string! worker-db-connection-string
|
||||
"migrations/workers/worker_runs.yaml")))
|
||||
|
||||
(comment
|
||||
|
||||
(run-migrations-from-resource-with-connection-string! "jdbc:postgresql://localhost:5432/worker"
|
||||
"migrations/workers/worker_runs.yaml"))
|
||||
@@ -0,0 +1,11 @@
|
||||
(ns metabase-enterprise.workspaces.api
|
||||
"`/api/ee/workspace/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.api.workspace]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/workspace` routes."
|
||||
metabase-enterprise.workspaces.api.workspace/routes)
|
||||
@@ -0,0 +1,533 @@
|
||||
|
||||
(ns metabase-enterprise.workspaces.api.workspace
|
||||
#_{:clj-kondo/ignore [:metabase/modules]}
|
||||
(:require
|
||||
[metabase-enterprise.workspaces.models.workspace :as m.workspace]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(comment
|
||||
m.workspace/keep-me)
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; Helper functions
|
||||
|
||||
(defn update-workspace-entity-at-index
|
||||
"Updates an entity at a specific index in the workspace's entity collection.
|
||||
|
||||
Args:
|
||||
- workspace-id: The workspace ID
|
||||
- entity-key: The key in the workspace map (:plans, :transforms, etc.)
|
||||
- index: The 0-based index of the entity to update
|
||||
- update-fn: Function that takes the current entity and returns the updated entity"
|
||||
[workspace-id entity-key index update-fn]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id workspace-id))
|
||||
current-items (vec (get workspace entity-key []))
|
||||
current-item (api/check-404 (nth current-items index nil))
|
||||
new-item (update-fn current-item)
|
||||
updated-items (assoc current-items index new-item)]
|
||||
(t2/update! :model/Workspace workspace-id {entity-key updated-items})
|
||||
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id workspace-id))))
|
||||
|
||||
(defn- delete-workspace-entity-at-index
|
||||
"Deletes an entity at a specific index in the workspace's entity collection.
|
||||
|
||||
Args:
|
||||
- workspace-id: The workspace ID
|
||||
- entity-key: The key in the workspace map (:plans, :transforms, etc.)
|
||||
- index: The 0-based index of the entity to delete"
|
||||
[workspace-id entity-key index]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id workspace-id))
|
||||
current-items (vec (get workspace entity-key []))
|
||||
_ (api/check-404 (nth current-items index nil)) ; Verify index exists
|
||||
updated-items (vec (concat (subvec current-items 0 index)
|
||||
(subvec current-items (inc index))))]
|
||||
(t2/update! :model/Workspace workspace-id {entity-key updated-items})
|
||||
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id workspace-id))))
|
||||
|
||||
;; GET /api/ee/workspace
|
||||
(api.macros/defendpoint :get "/"
|
||||
"List all workspaces for the current user"
|
||||
[]
|
||||
(mapv m.workspace/sort-workspace
|
||||
(t2/select :model/Workspace {:order-by [[:created_at :desc]]})))
|
||||
|
||||
;; GET /api/ee/workspace/:workspace-id
|
||||
(api.macros/defendpoint :get "/:workspace-id"
|
||||
"Get a workspace by ID"
|
||||
[{:keys [workspace-id]} :- [:map [:workspace-id ms/PositiveInt]]]
|
||||
(let [workspace (t2/select-one :model/Workspace :id workspace-id)]
|
||||
(api/check-404 workspace)
|
||||
(m.workspace/sort-workspace workspace)))
|
||||
|
||||
;; POST /api/ee/workspace
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Create a new workspace.
|
||||
|
||||
Request body:
|
||||
- name (required): Workspace name
|
||||
- description (optional): Workspace description"
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [name description collection_id]}
|
||||
:- [:map
|
||||
[:name ms/NonBlankString]
|
||||
[:collection_id ms/PositiveInt]
|
||||
[:description {:optional true} [:maybe :string]]]]
|
||||
(let [workspace-data {:name name
|
||||
:description description
|
||||
:collection_id collection_id
|
||||
:users []
|
||||
:plans []
|
||||
:transforms []
|
||||
:activity_logs []
|
||||
:permissions []
|
||||
:documents []
|
||||
:data_warehouses []}]
|
||||
(m.workspace/sort-workspace
|
||||
(t2/insert-returning-instance! :model/Workspace workspace-data))))
|
||||
|
||||
(comment
|
||||
|
||||
(doseq [del-id (map :id (rest (t2/select [:model/Collection :id] :name "Workspace Collection")))]
|
||||
(t2/delete! :model/Collection :id del-id))
|
||||
|
||||
(require '[metabase.test :as mt]
|
||||
'[toucan2.core :as t2])
|
||||
|
||||
(mt/user-http-request :crowberto :post 200 "collection/" {:name "Workspace Collection"
|
||||
:slug "workspace_collection"})
|
||||
|
||||
(t2/select [:model/Collection :id :name] :name "Workspace Collection")
|
||||
|
||||
(mt/user-http-request
|
||||
:crowberto :post 200 (format "ee/workspace/%s/plan" (:id (t2/select [:model/Collection :id :name] :name "Workspace Collection")))
|
||||
{:name "New Plan"
|
||||
:collection_id (:id (t2/select-one [:model/Collection :id] :name "Workspace Collection"))
|
||||
:description "This is a new plan"
|
||||
:content {:steps ["Step 1" "Step 2"]}})
|
||||
|
||||
(t2/select-one [:model/Workspace :id :name :description] :name "New Plan")
|
||||
;; => (toucan2.instance/instance :model/Workspace {:id 1, :name "New Plan", :description "This is a new plan"})
|
||||
)
|
||||
|
||||
;; PUT /api/ee/workspace/:id
|
||||
(api.macros/defendpoint :put "/:id"
|
||||
"Update an existing workspace's name or description.
|
||||
|
||||
Request body:
|
||||
- name (required): Updated workspace name
|
||||
- description (optional): Updated workspace description
|
||||
|
||||
Returns the updated workspace."
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [name description]} :- [:map
|
||||
[:name ms/NonBlankString]
|
||||
[:description {:optional true} [:maybe :string]]]]
|
||||
(api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
(t2/update! :model/Workspace id {:name name :description description})
|
||||
(t2/select-one :model/Workspace :id id))
|
||||
|
||||
;; DELETE /api/ee/workspace/:id
|
||||
(api.macros/defendpoint :delete "/:id"
|
||||
"Delete a workspace."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
(t2/delete! :model/Workspace :id id)
|
||||
api/generic-204-no-content)
|
||||
|
||||
;; PUT /api/ee/workspace/:id/plan
|
||||
(api.macros/defendpoint :put "/:id/plan"
|
||||
"Add a plan to the workspace.
|
||||
|
||||
Request body:
|
||||
- title (required): Plan title
|
||||
- description (required): Plan description
|
||||
- content (required): Plan content object"
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [title description content]}
|
||||
:- [:map
|
||||
[:title ms/NonBlankString]
|
||||
[:description {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:content :map]]]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
current-plans (or (:plans workspace) [])
|
||||
new-plan {:title title
|
||||
:description description
|
||||
:content content
|
||||
:created_at (str (java.time.Instant/now))}
|
||||
updated-plans (conj current-plans new-plan)]
|
||||
(t2/update! :model/Workspace id {:plans updated-plans})
|
||||
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
|
||||
|
||||
(api.macros/defendpoint :put "/:id/plan/:index"
|
||||
"Replace a plan in the workspace, by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the plan to replace (0-based)
|
||||
|
||||
Request body:
|
||||
- title (required): Plan title
|
||||
- description (required): Plan description
|
||||
- content (required): Plan content object"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params
|
||||
{:keys [title description content]}
|
||||
:- [:map
|
||||
[:title ms/NonBlankString]
|
||||
[:description {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:content :map]]]
|
||||
(update-workspace-entity-at-index id :plans index
|
||||
(fn [current-plan]
|
||||
(merge current-plan {:title title
|
||||
:description description
|
||||
:content content
|
||||
:created_at (str (java.time.Instant/now))}))))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id/plan/:index"
|
||||
"Delete a plan from the workspace by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the plan to delete (0-based)"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params]
|
||||
(delete-workspace-entity-at-index id :plans index)
|
||||
api/generic-204-no-content)
|
||||
|
||||
;; PUT /api/ee/workspace/:id/transform
|
||||
(api.macros/defendpoint :put "/:id/transform"
|
||||
"Add a transform to the workspace.
|
||||
|
||||
Request body:
|
||||
- name (required): Transform name
|
||||
- description (required): Transform description
|
||||
- source (required): Source configuration
|
||||
- target (required): Target configuration
|
||||
- config (optional): Transform configuration"
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [name description source target config]}
|
||||
:- [:map
|
||||
[:name ms/NonBlankString]
|
||||
[:description ms/NonBlankString]
|
||||
[:source :map]
|
||||
[:target :map]
|
||||
[:config {:optional true} [:maybe :map]]]]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
current-transforms (or (:transforms workspace) [])
|
||||
new-transform {:name name
|
||||
:description description
|
||||
:source source
|
||||
:target target
|
||||
:config config
|
||||
:created_at (str (java.time.Instant/now))}
|
||||
updated-transforms (conj current-transforms new-transform)]
|
||||
(t2/update! :model/Workspace id
|
||||
{:transforms updated-transforms})
|
||||
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
|
||||
|
||||
(api.macros/defendpoint :put "/:id/transform/:index"
|
||||
"Replace a transform in the workspace, by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the transform to replace (0-based)
|
||||
|
||||
Request body:
|
||||
- name (required): Transform name
|
||||
- description (required): Transform description
|
||||
- source (required): Source configuration
|
||||
- target (required): Target configuration
|
||||
- config (optional): Transform configuration"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params
|
||||
{:keys [name description source target config]}
|
||||
:- [:map
|
||||
[:name ms/NonBlankString]
|
||||
[:description ms/NonBlankString]
|
||||
[:source :map]
|
||||
[:target :map]
|
||||
[:config {:optional true} [:maybe :map]]]]
|
||||
(update-workspace-entity-at-index id :transforms index
|
||||
(fn [current-transform]
|
||||
(merge current-transform {:name name
|
||||
:description description
|
||||
:source source
|
||||
:target target
|
||||
:config config
|
||||
:created_at (str (java.time.Instant/now))}))))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id/transform/:index"
|
||||
"Delete a transform from the workspace by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the transform to delete (0-based)"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params]
|
||||
(delete-workspace-entity-at-index id :transforms index)
|
||||
api/generic-204-no-content)
|
||||
|
||||
;; POST /api/ee/workspace/:id/transform/link
|
||||
(api.macros/defendpoint :post "/:id/transform/link"
|
||||
"Link an existing transform to the workspace.
|
||||
|
||||
Request body:
|
||||
- transform_id (required): ID of the transform to link"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [transform_id]} :- [:map [:transform_id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
transform (api/check-404 (t2/select-one :model/Transform :id transform_id))
|
||||
current-transforms (or (:transforms workspace) [])
|
||||
;; Convert the existing transform to workspace format
|
||||
linked-transform {:id (:id transform)
|
||||
:name (:name transform)
|
||||
:description (:description transform)
|
||||
:source (:source transform)
|
||||
:target (:target transform)
|
||||
:config (:config transform)
|
||||
:created_at (str (java.time.Instant/now))}
|
||||
updated-transforms (conj current-transforms linked-transform)]
|
||||
(t2/update! :model/Workspace id {:transforms updated-transforms})
|
||||
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
|
||||
|
||||
;; PUT /api/ee/workspace/:id/document
|
||||
(api.macros/defendpoint :put "/:id/document"
|
||||
"Add a document to the workspace.
|
||||
|
||||
Request body:
|
||||
- document_id (required): ID of the document to link"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [document_id]} :- [:map [:document_id ms/PositiveInt]]]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
current-docs (or (:documents workspace) [])
|
||||
updated-docs (distinct (conj current-docs document_id))]
|
||||
(t2/update! :model/Workspace id {:documents updated-docs})
|
||||
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
|
||||
|
||||
;; PUT /api/ee/workspace/:id/data_warehouse
|
||||
(api.macros/defendpoint :put "/:id/data_warehouse"
|
||||
"Add a data warehouse to the workspace.
|
||||
|
||||
Request body:
|
||||
- dwh_id (required): ID of the data warehouse
|
||||
- name (required): Name of the data warehouse
|
||||
- type (required): Access type (read-only or read-write)
|
||||
- credentials (required): Credentials object"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [data_warehouses_id name
|
||||
type credentials]} :- [:map
|
||||
[:data_warehouses_id ms/PositiveInt]
|
||||
[:name ms/NonBlankString]
|
||||
[:type [:enum "read-only" "read-write"]]
|
||||
[:credentials :map]]]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
current-dwh (or (:data_warehouses workspace) [])
|
||||
new-dwh {:id data_warehouses_id
|
||||
:name name
|
||||
:type (keyword type)
|
||||
:credentials credentials
|
||||
:created_at (str (java.time.Instant/now))}
|
||||
updated-dwh (conj current-dwh new-dwh)]
|
||||
(t2/update! :model/Workspace id
|
||||
{:data_warehouses updated-dwh})
|
||||
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id id))))
|
||||
|
||||
(api.macros/defendpoint :put "/:id/data_warehouse/:index"
|
||||
"Replace a data warehouse in the workspace, by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the data warehouse to replace (0-based)
|
||||
|
||||
Request body:
|
||||
- data_warehouses_id (required): ID of the data warehouse
|
||||
- name (required): Name of the data warehouse
|
||||
- type (required): Access type (read-only or read-write)
|
||||
- credentials (required): Credentials object"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params
|
||||
{:keys [data_warehouses_id name type credentials]}
|
||||
:- [:map
|
||||
[:data_warehouses_id ms/PositiveInt]
|
||||
[:name ms/NonBlankString]
|
||||
[:type [:enum "read-only" "read-write"]]
|
||||
[:credentials :map]]]
|
||||
(update-workspace-entity-at-index id :data_warehouses index
|
||||
(fn [current-dwh]
|
||||
(merge current-dwh {:id data_warehouses_id
|
||||
:name name
|
||||
:type (keyword type)
|
||||
:credentials credentials
|
||||
:created_at (str (java.time.Instant/now))}))))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id/data_warehouse/:index"
|
||||
"Delete a data warehouse from the workspace by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the data warehouse to delete (0-based)"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params]
|
||||
(delete-workspace-entity-at-index id :data_warehouses index)
|
||||
api/generic-204-no-content)
|
||||
|
||||
(defn- add-user! [workspace user]
|
||||
(let [current-users (or (:users workspace) [])
|
||||
new-user (assoc user :created_at (str (java.time.Instant/now)))
|
||||
updated-users (conj current-users new-user)]
|
||||
(t2/update! :model/Workspace (:id workspace) {:users updated-users})))
|
||||
|
||||
;; PUT /api/ee/workspace/:id/user
|
||||
(api.macros/defendpoint :put "/:workspace-id/user"
|
||||
"Add a user to the workspace.
|
||||
|
||||
Request body:
|
||||
- id (required): User ID
|
||||
- name (required): User name
|
||||
- email (required): User email
|
||||
- type (required): User type (e.g., 'workspace-user')"
|
||||
[{:keys [workspace-id]} :- [:map [:workspace-id ms/PositiveInt]]
|
||||
_query-params
|
||||
user :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:name ms/NonBlankString]
|
||||
[:email ms/NonBlankString]
|
||||
[:type ms/NonBlankString]]]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id workspace-id))]
|
||||
(add-user! workspace user)
|
||||
(m.workspace/sort-workspace (t2/select-one :model/Workspace :id workspace-id))))
|
||||
|
||||
(api.macros/defendpoint :put "/:id/user/:index"
|
||||
"Replace a user in the workspace, by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the user to replace (0-based)
|
||||
|
||||
Request body:
|
||||
- id (required): User ID
|
||||
- name (required): User name
|
||||
- email (required): User email
|
||||
- type (required): User type (e.g., 'workspace-user')"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params
|
||||
{:keys [user_id name email type]}
|
||||
:- [:map
|
||||
[:user_id ms/PositiveInt]
|
||||
[:name ms/NonBlankString]
|
||||
[:email ms/NonBlankString]
|
||||
[:type ms/NonBlankString]]]
|
||||
(update-workspace-entity-at-index id :users index
|
||||
(fn [current-user]
|
||||
(merge current-user {:id user_id
|
||||
:name name
|
||||
:email email
|
||||
:type type
|
||||
:created_at (str (java.time.Instant/now))}))))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id/user/:index"
|
||||
"Delete a user from the workspace by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the user to delete (0-based)"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params]
|
||||
(delete-workspace-entity-at-index id :users index)
|
||||
api/generic-204-no-content)
|
||||
|
||||
;; PUT /api/ee/workspace/:id/permission
|
||||
(api.macros/defendpoint :put "/:id/permission"
|
||||
"Add a permission to the workspace.
|
||||
|
||||
Request body:
|
||||
- table (required): Table name
|
||||
- permission (required): Permission type (read or write)"
|
||||
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [table permission]}
|
||||
:- [:map
|
||||
[:table ms/NonBlankString]
|
||||
[:permission [:enum "read" "write"]]]]
|
||||
(let [workspace (api/check-404 (t2/select-one :model/Workspace :id id))
|
||||
current-permissions (or (:permissions workspace) [])
|
||||
new-permission {:table table
|
||||
:permission (keyword permission)
|
||||
:created_at (str (java.time.Instant/now))}
|
||||
updated-permissions (conj current-permissions new-permission)]
|
||||
(t2/update! :model/Workspace id
|
||||
{:permissions updated-permissions})
|
||||
(t2/select-one :model/Workspace :id id)))
|
||||
|
||||
(api.macros/defendpoint :put "/:id/permission/:index"
|
||||
"Replace a permission in the workspace, by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the permission to replace (0-based)
|
||||
|
||||
Request body:
|
||||
- table (required): Table name
|
||||
- permission (required): Permission type (read or write)"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params
|
||||
{:keys [table permission]}
|
||||
:- [:map
|
||||
[:table ms/NonBlankString]
|
||||
[:permission [:enum "read" "write"]]]]
|
||||
(update-workspace-entity-at-index id :permissions index
|
||||
(fn [current-permission]
|
||||
(merge current-permission {:table table
|
||||
:permission (keyword permission)
|
||||
:created_at (str (java.time.Instant/now))}))))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id/permission/:index"
|
||||
"Delete a permission from the workspace by index.
|
||||
|
||||
Route Params:
|
||||
- id (required): Workspace ID
|
||||
- index (required): Index of the permission to delete (0-based)"
|
||||
[{:keys [id index]} :- [:map
|
||||
[:id ms/PositiveInt]
|
||||
[:index :int]]
|
||||
_query-params]
|
||||
(delete-workspace-entity-at-index id :permissions index)
|
||||
api/generic-204-no-content)
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/workspace/` routes for workspace management"
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,540 @@
|
||||
(ns metabase-enterprise.workspaces.isolation-manager
|
||||
"A namespace that will manage database isolation. Will operate on database connection details compatible with
|
||||
clojure.java.jdbc. The goal is that satisfy a high level goal of creating an isolated workspace in a database that
|
||||
an LLM agent can have read/write access to. This space will be adjacent to data in the rest of the database but
|
||||
isolated to it. We must create three things:
|
||||
|
||||
1. a schema (for postgres, snowflake, redshift) or a database (for clickhouse) where data can be ETL'd into. The
|
||||
schema should have some pattern to it that includes some identifying information of the workspace, and also a prefix
|
||||
so that Metabase knows now to index or sync this schema. It must be very bespoke and at extremely low risk of being
|
||||
confused with real customer data. The schema/database will have the following naming converion:
|
||||
|
||||
mb__isolation_7748c_test_workspace_123
|
||||
|-----------| |---| |----------------|
|
||||
† †† †††
|
||||
|
||||
† mb__isolation: common prefix to all workspace instances. Indicates that metabase owns this and we can sync or not
|
||||
as we like
|
||||
|
||||
†† 7748c: derived from instance-uuid. On my instance `7f43c806-7ef1-4308-8fab-cb41b177db91`
|
||||
7 7 4 8 c
|
||||
Let's an instance know if this isolation unit was created by this instance or not.
|
||||
|
||||
††† test_workspace_123: a mapping that should tie back to a workspace in some manner. This is largely opaque but
|
||||
should indicate uniquely back to a workspace on this instance.
|
||||
|
||||
2. a user with read privileges to the original data and write access to the new schema
|
||||
|
||||
The public api is
|
||||
(create-isolation driver, connection-details, workspace-id)
|
||||
|
||||
(delete-isolation driver, connection-details, workspace-id, [isolation-info])
|
||||
Isolation info is optional but nice to have. The isolation mechanism should be deterministic from the connection
|
||||
details and worksapce-id.
|
||||
|
||||
It is expected that a workspace can use databases it knows about to create isolations for it's work."
|
||||
(:require
|
||||
[clojure.java.jdbc :as jdbc]
|
||||
[clojure.string :as str]
|
||||
[metabase.driver.ddl.interface :as ddl.i]
|
||||
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
|
||||
[metabase.driver.sql.util :as sql.u]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(defn- instance-uuid-slug
|
||||
"Create a slug from the site UUID, taking the first character of each section."
|
||||
[site-uuid-string]
|
||||
(apply str (map first (str/split site-uuid-string #"-"))))
|
||||
|
||||
(defn- isolation-schema-name
|
||||
"Generate schema/database name for workspace isolation following mb__isolation_<slug>_<workspace-id> pattern."
|
||||
[workspace-id]
|
||||
(let [instance-slug (instance-uuid-slug (str (system/site-uuid)))
|
||||
clean-workspace-id (str/replace (str workspace-id) #"[^a-zA-Z0-9]" "_")]
|
||||
(format "mb__isolation_%s_%s" instance-slug clean-workspace-id)))
|
||||
|
||||
|
||||
(defn- isolation-user-name
|
||||
"Generate username for workspace isolation."
|
||||
[workspace-id user-type]
|
||||
(let [instance-slug (instance-uuid-slug (str (system/site-uuid)))
|
||||
clean-workspace-id (str/replace (str workspace-id) #"[^a-zA-Z0-9]" "_")]
|
||||
(format "mb_iso_%s_%s_%s" instance-slug clean-workspace-id (name user-type))))
|
||||
|
||||
(mr/def ::rule any?)
|
||||
(mr/def ::options :map)
|
||||
(mr/def ::steps
|
||||
[:cat :keyword ::options [:* [:alt [:schema [:ref ::steps]] ::rule]]])
|
||||
|
||||
(mu/defn- evaluate-steps
|
||||
"Evaluate steps. Steps is a tree of steps matching the ::steps schema. F should be a function that takes in the
|
||||
opaque value of a step. `f` must return a tuple `[:success _]` or `[:error ]`.
|
||||
|
||||
Error handling: by default an error will stop all evaluation. It will report which _steps_ are skipped, and which
|
||||
_trees_ are also skipped. It will not descend into trees to enumerate steps once skipped. You can put
|
||||
`{:error-strategy ::continue-on-error}` as options anywhere in the tree. This option affects subtrees. You can
|
||||
shadow this back to the regular error strategy with any other value to prevent finding the continue on error
|
||||
strategy."
|
||||
[steps :- ::steps f]
|
||||
(letfn [(subtree? [rule] (and (vector? rule) (keyword? (first rule))))
|
||||
(error-strategy [stackframes]
|
||||
(when (seq stackframes)
|
||||
(if-some [strategy (-> stackframes peek :options :error-strategy)]
|
||||
strategy
|
||||
(recur (pop stackframes)))))]
|
||||
(loop [stackframes [{:q (into clojure.lang.PersistentQueue/EMPTY
|
||||
(nthrest steps 2))
|
||||
:options (second steps)
|
||||
:tree (first steps)}]
|
||||
acc [[:tree (first steps)]]
|
||||
state :running
|
||||
gas 50]
|
||||
(when (zero? gas) (throw (ex-info "ran out of gas" {:acc acc})))
|
||||
(if-not (seq stackframes)
|
||||
[acc state]
|
||||
(let [{:keys [q] :as frame} (peek stackframes)
|
||||
base-stack (pop stackframes)
|
||||
rule (peek q)]
|
||||
;; done with this stackframe
|
||||
(cond (not (seq q))
|
||||
(let [state' (if (= state :error)
|
||||
(let [strategy (error-strategy base-stack)]
|
||||
(if (not= strategy ::continue-on-error)
|
||||
state
|
||||
:running))
|
||||
state)]
|
||||
(recur base-stack acc state' (dec gas)))
|
||||
|
||||
;; scoot through
|
||||
(= state :error)
|
||||
(let [info (if (subtree? rule)
|
||||
[:skipping-tree (first rule)]
|
||||
[:skipping-step rule])
|
||||
frame' (update frame :q pop)]
|
||||
(recur (conj base-stack frame') (conj acc info) state (dec gas)))
|
||||
|
||||
;; hit a tree node
|
||||
(subtree? rule)
|
||||
(let [[tree-name options & sub-rules] rule
|
||||
|
||||
new-stackframe {:q (into clojure.lang.PersistentQueue/EMPTY
|
||||
sub-rules)
|
||||
:options options
|
||||
:tree tree-name}
|
||||
stackframes (conj base-stack
|
||||
(update frame :q pop)
|
||||
new-stackframe)]
|
||||
(recur stackframes
|
||||
(conj acc [:tree tree-name])
|
||||
state
|
||||
(dec gas)))
|
||||
:else
|
||||
(let [[status _response :as result] (f rule)]
|
||||
(when-not (#{:error :success} status)
|
||||
(throw (ex-info "Bad status from rule" {:rule rule :result result})))
|
||||
(let [switch-to-error? (and (= :error status)
|
||||
(not= (error-strategy stackframes)
|
||||
::continue-on-error))]
|
||||
(recur (conj base-stack (update frame :q pop))
|
||||
(conj acc result)
|
||||
(if switch-to-error? :error state)
|
||||
(dec gas))))))))))
|
||||
|
||||
(defmulti ^:private create-isolation*
|
||||
"Create database isolation for a workspace."
|
||||
(fn [driver workspace] driver))
|
||||
|
||||
;; todo: use (jdbc/metadata-result (.getSchemas metadata))
|
||||
|
||||
(mu/defn- postgres-steps :- ::steps
|
||||
"Return postgres steps. Needs the populator and read users (maps with user and password) and the schema name to be
|
||||
populated. Will create the schema, uses, and setup privileges. Currently hardcoded to give the populator access to
|
||||
the `public` schema. This will need to be updated to include the schemas from the source database."
|
||||
[{:keys [populator schema-name]}]
|
||||
[:schema
|
||||
{}
|
||||
(format "CREATE SCHEMA %s" schema-name)
|
||||
[:users
|
||||
{}
|
||||
;; Create populator user with random password
|
||||
[:populator
|
||||
{}
|
||||
(format "CREATE USER %s WITH PASSWORD '%s'" (:user populator) (:password populator))
|
||||
[:populator-privileges
|
||||
{}
|
||||
;; Grant privileges to populator user
|
||||
|
||||
;; source data hardcorded to public: FIX
|
||||
(format "GRANT USAGE ON SCHEMA %s TO %s" "public" (:user populator))
|
||||
;; todo: don't hardcode this to public
|
||||
(format "GRANT USAGE ON SCHEMA public TO %s" (:user populator))
|
||||
|
||||
(format "GRANT USAGE ON SCHEMA %s TO %s" schema-name (:user populator))
|
||||
(format "GRANT SELECT ON ALL TABLES IN SCHEMA %s TO %s" "public" (:user populator))
|
||||
(format "GRANT CREATE ON SCHEMA %s TO %s" schema-name (:user populator))
|
||||
(format "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %s TO %s"
|
||||
schema-name (:user populator))]]]])
|
||||
|
||||
(mu/defn snowflake-steps :- ::steps
|
||||
[{:keys [populator schema-name source-database-name]}]
|
||||
(letfn [(q [d]
|
||||
(try
|
||||
(sql.u/quote-name :clickhouse :database (ddl.i/format-name :clickhouse d))
|
||||
(catch Exception e
|
||||
(log/errorf e "Error quoting %s" (pr-str d))
|
||||
(throw e))))]
|
||||
[:use-database
|
||||
{}
|
||||
(format "USE DATABASE \"%s\" " source-database-name)
|
||||
[:schema
|
||||
{}
|
||||
(format "CREATE SCHEMA IF NOT EXISTS %s" schema-name)
|
||||
;; todo: snowflake extra users. They are difficult to get right with roles and quoting
|
||||
#_[:usage-on-new-schema
|
||||
{}
|
||||
;; hardcoded developer. ownership of new schema to developer conflicts with next bit
|
||||
#_(format "GRANT OWNERSHIP ON SCHEMA %s to ROLE DEVELOPER" schema-name)
|
||||
#_(format "GRANT USAGE ON SCHEMA %s to ROLE DEVELOPER" schema-name)]
|
||||
#_[:users
|
||||
{}
|
||||
;; Create populator user with random password
|
||||
[:populator-role
|
||||
{}
|
||||
(format "CREATE ROLE \"%s\" " (:role populator))
|
||||
[:populator
|
||||
{}
|
||||
(format "CREATE OR REPLACE USER %s PASSWORD = '%s' DEFAULT_ROLE='%s'"
|
||||
(:user populator)
|
||||
(:password populator)
|
||||
(:role populator))
|
||||
[:populator-privileges
|
||||
{}
|
||||
;; Grant privileges to populator user
|
||||
|
||||
;; source data hardcorded to public: FIX
|
||||
(format "GRANT OWNERSHIP ON SCHEMA %s TO \"%s\" " schema-name #_(:user populator) (:role populator))
|
||||
;; todo: don't hardcode this to public
|
||||
(format "GRANT USAGE ON DATABASE \"%s\" TO \"%s\" " source-database-name #_(:user populator) (:role populator))]
|
||||
[:assign-role
|
||||
{}
|
||||
(format "GRANT ROLE \"%s\" to USER %s" (:role populator) (:user populator))]]]]]]))
|
||||
|
||||
(comment
|
||||
(let [spec (metabase.driver.sql-jdbc.connection/db->pooled-connection-spec (toucan2.core/select-one-fn :id :model/Database :engine :snowflake))]
|
||||
(jdbc/query spec ["select current_role()"]))
|
||||
)
|
||||
|
||||
(defmethod create-isolation* :snowflake
|
||||
[_driver {:keys [id connection-details]}]
|
||||
;; schema name is automatically uppercased by snowflake, but then if you quote it it doesn't find the uppercased
|
||||
;; version.
|
||||
(let [schema-name (str/upper-case (isolation-schema-name id))
|
||||
jdbc-spec (sql-jdbc.conn/connection-details->spec :snowflake connection-details)
|
||||
populator {:user (isolation-user-name id :populator)
|
||||
:password (str (random-uuid))
|
||||
:role (str (random-uuid))}
|
||||
steps (snowflake-steps {:populator populator
|
||||
:schema-name schema-name
|
||||
:source-database-name ((some-fn :db :dbname) connection-details)})]
|
||||
(jdbc/with-db-transaction [tx jdbc-spec]
|
||||
(let [results (evaluate-steps steps (fn [sql]
|
||||
(try [:success sql (jdbc/execute! tx
|
||||
(if (string? sql)
|
||||
[sql]
|
||||
sql))]
|
||||
(catch Exception e
|
||||
(log/error e)
|
||||
[:error sql (ex-message e)]))))]
|
||||
{:isolation-type :schema
|
||||
:schema-name schema-name
|
||||
;;:populator populator
|
||||
:results results}))))
|
||||
|
||||
|
||||
(comment
|
||||
(malli.core/validate ::steps
|
||||
(snowflake-steps {:populator {:user "populator_user123" :password (str (random-uuid))}
|
||||
:schema-name (isolation-schema-name "workspace_1234")
|
||||
:source-database-name "sales"}))
|
||||
(postgres-steps {:populator {:user "populator" :password "populator-pw"}
|
||||
:schema-name "scratchpad"})
|
||||
(malli.core/validate ::steps
|
||||
(postgres-steps {:populator {:user "populator" :password "populator-pw"}
|
||||
:schema-name "scratchpad"}))
|
||||
|
||||
(evaluate-steps (postgres-steps {:populator {:user "populator" :password "populator-pw"}
|
||||
:schema-name "scratchpad"})
|
||||
(fn [step] (if (= 0 (rand-int 10))
|
||||
[:error (format "error during: %s" step)]
|
||||
[:success (format "performed: %s" step)])))
|
||||
|
||||
(evaluate-steps [:schema
|
||||
{:error-strategy ::continue-on-error}
|
||||
"CREATE SCHEMA scratchpad"
|
||||
[:users
|
||||
{}
|
||||
[:populator
|
||||
{}
|
||||
"CREATE USER populator WITH PASSWORD 'populator-pw'"
|
||||
[:populator-privileges
|
||||
{}
|
||||
"GRANT USAGE ON SCHEMA public TO populator"
|
||||
"GRANT USAGE ON SCHEMA public TO populator"
|
||||
"GRANT USAGE ON SCHEMA scratchpad TO populator"
|
||||
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO populator"
|
||||
"GRANT CREATE ON SCHEMA scratchpad TO populator"
|
||||
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA scratchpad TO populator"]]]]
|
||||
(fn [step] (if (= 0 (rand-int 10))
|
||||
[:error (format "error during: %s" step)]
|
||||
[:success (format "performed: %s" step)]))))
|
||||
|
||||
|
||||
|
||||
(defmethod create-isolation* :postgres
|
||||
[_driver {:keys [id connection-details]}]
|
||||
(let [schema-name (isolation-schema-name id)
|
||||
populator {:user (isolation-user-name id :populator)
|
||||
:password (str (random-uuid))}
|
||||
jdbc-spec (sql-jdbc.conn/connection-details->spec :postgres connection-details)
|
||||
steps (postgres-steps {:populator populator
|
||||
:schema-name schema-name})]
|
||||
(jdbc/with-db-transaction [tx jdbc-spec]
|
||||
;; Create schema
|
||||
|
||||
(let [results (evaluate-steps steps (fn [sql]
|
||||
(try [:success
|
||||
sql
|
||||
(jdbc/execute! tx [sql])]
|
||||
(catch Exception e
|
||||
[:error sql (ex-message e)]))))]
|
||||
;; Return isolation info
|
||||
{:isolation-type :schema
|
||||
:schema-name schema-name
|
||||
:populator populator
|
||||
:results results}))))
|
||||
|
||||
;; todo: create an evaluator of steps. steps will perhaps for a dag, preconditions, checks, failures. ideally a test
|
||||
;; would have a list of all steps, randomly execute one, and then execute all steps and ensure that we can proceed to
|
||||
;; the end. ie, any particular step tha tis already satisfied (schema already exists, user already exists) is ok and
|
||||
;; we can carry on.
|
||||
|
||||
(mu/defn- clickhouse-steps :- ::steps
|
||||
[{:keys [populator database-name source-database-name]}]
|
||||
(letfn [(q [d]
|
||||
(try
|
||||
(sql.u/quote-name :clickhouse :database (ddl.i/format-name :clickhouse d))
|
||||
(catch Exception e
|
||||
(log/errorf e "Error quoting %s" (pr-str d))
|
||||
(throw e))))]
|
||||
[:database
|
||||
{}
|
||||
(format "CREATE DATABASE %s" (q database-name))
|
||||
[:users
|
||||
{}
|
||||
[:populator
|
||||
{}
|
||||
(format "CREATE USER %s IDENTIFIED WITH sha256_password BY '%s'"
|
||||
(:user populator) (:password populator))
|
||||
[:populator-privileges
|
||||
{}
|
||||
(format "GRANT SELECT ON %s.* TO %s" (q source-database-name) (:user populator))
|
||||
(format "GRANT CREATE ON %s.* TO %s" (q database-name) (:user populator))
|
||||
(format "GRANT INSERT ON %s.* TO %s" (q database-name) (:user populator))
|
||||
(format "GRANT SELECT ON %s.* TO %s" (q database-name) (:user populator))
|
||||
(format "GRANT DROP ON %s.* TO %s" (q database-name) (:user populator))
|
||||
(format "GRANT SHOW DATABASES ON *.* TO %s" (:user populator))]]]]))
|
||||
|
||||
(comment
|
||||
|
||||
(malli.core/validate ::steps
|
||||
(clickhouse-steps {:populator {:user "populator" :password "populator-pw"}
|
||||
:database-name "scratchpad"
|
||||
:source-database-name "foo"}))
|
||||
)
|
||||
|
||||
(defmethod create-isolation* :clickhouse
|
||||
[_driver {:keys [id connection-details]}]
|
||||
(let [database-name (isolation-schema-name id)
|
||||
populator {:user (isolation-user-name id :populator) :password (str (random-uuid))}
|
||||
jdbc-spec (sql-jdbc.conn/connection-details->spec :clickhouse connection-details)
|
||||
steps (clickhouse-steps {:populator populator :database-name database-name
|
||||
:source-database-name ((some-fn :db :dbname) connection-details)})]
|
||||
|
||||
(jdbc/with-db-transaction [tx jdbc-spec]
|
||||
(let [results (evaluate-steps steps (fn [sql]
|
||||
(try [:success sql (jdbc/execute! tx [sql])]
|
||||
(catch Exception e
|
||||
[:error sql (ex-message e)]))))]
|
||||
{:isolation-type :database
|
||||
:database-name database-name
|
||||
:populator populator
|
||||
:results results}))))
|
||||
|
||||
(comment
|
||||
|
||||
;; future work to check for existing schemas
|
||||
(let [metadata (.getMetaData (:connection tx))]
|
||||
(let [schemas (into [] (map :table_schem) (jdbc/result-set-seq (.getSchemas metadata)))]
|
||||
{:schemas schemas
|
||||
:isolations (filter #(String/.startsWith % "mb__isolation_") schemas)
|
||||
:our-isolations (filter #(String/.startsWith % "mb__isolation_7748c") schemas)
|
||||
:already-exists? (some #{schema-name} schemas)
|
||||
:driver _driver}))
|
||||
|
||||
;; we're a bit sloppy with db vs dbname?
|
||||
(let [workspace-id "workspace_manual_01"
|
||||
connection-details {:scan-all-databases false, :ssl false, :password "password", :destination-database false,
|
||||
:port 8123, :advanced-options false, :dbname "sales_data", :host "localhost",
|
||||
:tunnel-enabled false, :user "default"}]
|
||||
(create-isolation :clickhouse (:connection-details workspace) workspace-id))
|
||||
|
||||
{:isolation-type :database,
|
||||
:database-name "mb__isolation_7748c_workspace_manual_01",
|
||||
:populator {:user "mb_iso_7748c_workspace_manual_01_populator",
|
||||
:password "9d8285d5-94c3-4e87-a98a-1c47c9fb727b"},}
|
||||
|
||||
|
||||
(delete-isolation :clickhouse (:connection-details workspace) (:id workspace))
|
||||
{:deleted-database "mb__isolation_7748c_workspace_manual_01",
|
||||
:deleted-users ["mb_iso_7748c_workspace_manual_01_populator"]}
|
||||
)
|
||||
|
||||
(defn create-isolation
|
||||
"Create database isolation for a single database.
|
||||
|
||||
Creates an isolated environment in the specified database where LLM agents can operate safely.
|
||||
The isolation includes:
|
||||
- A schema (PostgreSQL) or database (ClickHouse) with workspace-specific naming
|
||||
- A populator user with read access to original data + write access to isolated area
|
||||
|
||||
Args:
|
||||
engine - Database engine keyword (:postgres, :clickhouse, etc.)
|
||||
connection-details - Database connection details (compatible with Metabase format)
|
||||
workspace-id - Unique workspace identifier for naming isolation resources
|
||||
|
||||
Returns:
|
||||
Map containing isolation details:
|
||||
:schema-name/:database-name - Name of created isolation area
|
||||
:populator - map with user and password for populator (read original +
|
||||
write isolated)
|
||||
|
||||
Example:
|
||||
(create-isolation :postgres db-connection-details \"workspace-123\")
|
||||
;; postgres isolations by schema
|
||||
{:isolation-type :schema
|
||||
:schema-name \"mb__isolation_7748c_test_workspace_123\"
|
||||
:populator {:user \"mb_iso_7748c_test_workspace_123_populator\"
|
||||
:password \"d632f61c-398a-467d-856c-d8411665bd0f\"}}
|
||||
|
||||
;; clickhouse isolates by database
|
||||
{:isolation-type :database
|
||||
:database-name \"mb__isolation_7748c_testing_workspace216479\"
|
||||
:populator {:user \"mb_iso_7748c_testing_workspace216479_populator\"
|
||||
:password \"a9445a75-621f-4211-82e1-9f96ee797dd6\"}}"
|
||||
[engine connection-details workspace-id]
|
||||
(create-isolation* engine {:id workspace-id :connection-details connection-details}))
|
||||
|
||||
(defmulti ^:private delete-isolation*
|
||||
"Delete database isolation for a workspace. This is deliberately not public. Use `delete-isolation` as an entrypoint."
|
||||
(fn [driver workspace] driver))
|
||||
|
||||
(defmethod delete-isolation* :postgres
|
||||
[_driver {:keys [id connection-details isolation-info]}]
|
||||
(let [schema-name (or (:schema-name isolation-info)
|
||||
(str/upper-case (isolation-schema-name id)))
|
||||
populator-user (or (-> isolation-info :populator :user)
|
||||
(isolation-user-name id :populator))
|
||||
jdbc-spec (sql-jdbc.conn/connection-details->spec :postgres connection-details)
|
||||
teardown-steps [:cleanup
|
||||
{:error-strategy ::continue-on-error}
|
||||
[:remove-privileges
|
||||
{}
|
||||
(format "REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA %s FROM %s" "public" populator-user)
|
||||
(format "REVOKE ALL PRIVILEGES ON SCHEMA %s FROM %s" "public" populator-user)]
|
||||
[:remove-new-schema
|
||||
{}
|
||||
;; Drop schema (CASCADE to drop all objects in it).
|
||||
(format "DROP SCHEMA IF EXISTS %s CASCADE" schema-name)]
|
||||
[:drop-user
|
||||
{}
|
||||
;; Drop users
|
||||
(format "DROP USER IF EXISTS %s" populator-user)]]]
|
||||
(jdbc/with-db-transaction [tx jdbc-spec]
|
||||
(let [results (evaluate-steps teardown-steps (fn [sql]
|
||||
(try [:success sql (jdbc/execute! tx [sql])]
|
||||
(catch Exception e
|
||||
[:error sql (ex-message e)]))))]
|
||||
{:deleted-schema schema-name
|
||||
:deleted-users [populator-user]
|
||||
:results results}))))
|
||||
|
||||
(defmethod delete-isolation* :clickhouse
|
||||
[_driver {:keys [id connection-details isolation-info]}]
|
||||
(let [database-name (or (:database-name isolation-info)
|
||||
(isolation-schema-name id))
|
||||
populator-user (or (-> isolation-info :populator :user)
|
||||
(isolation-user-name id :populator))
|
||||
jdbc-spec (sql-jdbc.conn/connection-details->spec :clickhouse connection-details)
|
||||
steps [:cleanup
|
||||
{:error-strategy ::continue-on-error}
|
||||
[:drop-database {}
|
||||
(format "DROP DATABASE IF EXISTS %s" database-name)]
|
||||
[:remove-users {}
|
||||
(format "DROP USER IF EXISTS %s" populator-user)]]]
|
||||
(jdbc/with-db-transaction [tx jdbc-spec]
|
||||
(let [results (evaluate-steps steps (fn [sql]
|
||||
(try [:success sql (jdbc/execute! tx [sql])]
|
||||
(catch Exception e
|
||||
[:error sql (ex-message e)]))))]
|
||||
{:deleted-database database-name
|
||||
:deleted-users [populator-user]
|
||||
:results results}))))
|
||||
|
||||
(defmethod delete-isolation* :snowflake
|
||||
[_driver {:keys [id connection-details isolation-info] :as x}]
|
||||
(let [schema-name (or (:schema-name isolation-info)
|
||||
(isolation-schema-name id))
|
||||
populator-user (or (-> isolation-info :populator :user)
|
||||
(isolation-user-name id :populator))
|
||||
jdbc-spec (sql-jdbc.conn/connection-details->spec :snowflake connection-details)
|
||||
steps [:cleanup {:error-strategy ::continue-on-error}
|
||||
[:drop-schema {} (format "DROP SCHEMA %s" schema-name)]
|
||||
[:drop-user {} (format "DROP USER %s" populator-user)]
|
||||
[:drop-role {} (format "DROP ROLE %s" (-> isolation-info :populator :role))]]]
|
||||
(jdbc/with-db-transaction [tx jdbc-spec]
|
||||
(let [results (evaluate-steps steps (fn [sql]
|
||||
(try [:success sql (jdbc/execute! tx [sql])]
|
||||
(catch Exception e
|
||||
[:error sql (ex-message e)]))))]
|
||||
{:deleted-schema schema-name :deleted-users [populator-user] :results results}))))
|
||||
|
||||
(defn delete-isolation
|
||||
"Delete database isolation for a single database.
|
||||
|
||||
Cleans up all isolation resources created for the specified database:
|
||||
- Drops the isolated schema/database
|
||||
- Drops the created user (populator)
|
||||
- Revokes any granted privileges
|
||||
|
||||
Args:
|
||||
engine - Database engine keyword (:postgres, :clickhouse, etc.)
|
||||
connection-details - Database connection details (compatible with Metabase format)
|
||||
workspace-id - Unique workspace identifier used when creating isolation
|
||||
isolation-info - (Optional) Result from create-isolation for more efficient cleanup
|
||||
|
||||
Returns:
|
||||
Map containing cleanup details:
|
||||
:deleted-schema/:deleted-database - Name of dropped isolation area
|
||||
:deleted-users - List of dropped usernames
|
||||
|
||||
Example:
|
||||
(delete-isolation :postgres db-connection-details \"workspace-123\" isolation-result)"
|
||||
([engine connection-details workspace-id]
|
||||
(delete-isolation* engine {:id workspace-id :connection-details connection-details}))
|
||||
([engine connection-details workspace-id isolation-info]
|
||||
(delete-isolation* engine {:id workspace-id
|
||||
:connection-details connection-details
|
||||
:isolation-info isolation-info})))
|
||||
@@ -0,0 +1,280 @@
|
||||
(ns metabase-enterprise.workspaces.models.workspace
|
||||
"Workspaces are a container that an LLM can use to store and manage state. They are the answer to 'How do we allow LLMs to do stuff, in a safe way?'.
|
||||
|
||||
It has locked down permissions and
|
||||
is used to store information that the LLM can use to answer questions or perform tasks. It is not a database, but
|
||||
rather a way to manage state in a way that is secure and controlled.
|
||||
|
||||
There's also a concept of a workspace having a user and a workspace having a database. The user has limited access to the database so that they can only
|
||||
access the data that is relevant to them. The workspace is a way to manage this access and control what the user can do with the data.
|
||||
|
||||
Workspace users are not the same as Metabase users. They do not have a permission group, They are a separate concept
|
||||
that is used to manage access to the workspace and the data within it. Creating a workspace should also create a user.
|
||||
|
||||
Workspaces are independent of one another by design. A workspace can be deleted without affecting other workspaces.
|
||||
And they cannot be used together. This is to ensure that the data in one workspace cannot be accessed by another
|
||||
workspace user.
|
||||
|
||||
"
|
||||
#_{:clj-kondo/ignore [:metabase/modules]}
|
||||
(:require
|
||||
[babashka.fs :as fs]
|
||||
[clj-yaml.core :as yaml]
|
||||
[clojure.string :as str]
|
||||
[java-time.api :as t]
|
||||
[malli.generator :as mg]
|
||||
[malli.util :as mut]
|
||||
[metabase-enterprise.transforms.ordering :as transforms.ordering]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.query-processor.store :as qp.store]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(mr/def ::created_at
|
||||
[:string {:gen/fmap #(str (t/instant (hash %)))}])
|
||||
|
||||
(mr/def ::plan [:map
|
||||
[:title :string]
|
||||
[:description :string]
|
||||
[:content :map]
|
||||
[:created_at ::created_at]])
|
||||
|
||||
;; maybe another table?
|
||||
(mr/def ::activity-log [:map
|
||||
[:name [:string {:min 1}]]
|
||||
;; needs a plan?
|
||||
[:steps [:sequential
|
||||
[:map
|
||||
[:step-id [:int {:min 0}]]
|
||||
[:description :string]
|
||||
[:status [:enum :pending :running :completed :failed]]
|
||||
[:outcome {:optional true} [:enum :success :error :warning]]
|
||||
[:error-message {:optional true} :string]
|
||||
[:start-time [:string {:description "When the step started"}]]
|
||||
[:end-time {:optional true} [:maybe {:description "When the step ended"} :string]]
|
||||
[:created_at ::created_at]
|
||||
[:updated-at [:string {:description "When the step was last updated"}]]]]]])
|
||||
|
||||
;; look at transforms, make it match
|
||||
(mr/def ::transform
|
||||
[:map
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:source [:map
|
||||
[:type [:or :string]] #_[:enum "query" "table"]
|
||||
[:query {:optional true} [:map
|
||||
[:database {:description "ID of the source database"} :int]
|
||||
[:type :string] ;; native or query
|
||||
[:native {:optional true} [:map
|
||||
[:query :string]
|
||||
[:template-tags {:optional true} [:map]]]]]]]]
|
||||
[:target [:map [:name :string] [:type :string]]]
|
||||
[:created_at ::created_at]
|
||||
[:config {:optional true} [:map]]])
|
||||
|
||||
;; wip
|
||||
(mr/def ::document [:int {:description "ID of a document in app-db" :min 1}])
|
||||
|
||||
(mr/def ::user
|
||||
[:map
|
||||
[:id :int]
|
||||
[:type :string] ;; workspace-user
|
||||
[:name :string]
|
||||
[:email :string]
|
||||
[:created_at ::created_at]
|
||||
;; api-key?
|
||||
;; db creds?
|
||||
])
|
||||
|
||||
(mr/def ::data-warehouse
|
||||
[:map
|
||||
[:id [:int {:min 1}]]
|
||||
[:created_at ::created_at]
|
||||
[:type [:enum "read-only" "read-write"]]
|
||||
[:credentials :map]
|
||||
[:name :string]])
|
||||
|
||||
(mr/def ::permission
|
||||
[:map
|
||||
[:table :string]
|
||||
[:created_at ::created_at]
|
||||
[:permission [:enum "read" "write"]]])
|
||||
|
||||
(mr/def ::workspace
|
||||
[:map
|
||||
[:id {:optional true} [:maybe [:int {:min 1}]]]
|
||||
[:collection_id {:optional true} [:maybe [:int {:min 1}]]]
|
||||
[:name [:string {:min 1}]]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
;; each plan, transofrm, and document(<-unsure) is basically a file abstraction
|
||||
[:plans [:sequential ::plan]]
|
||||
;; This should maybe be another table:
|
||||
[:activity_logs [:sequential ::activity-log]]
|
||||
[:transforms [:sequential ::transform]]
|
||||
[:documents [:sequential ::document]]
|
||||
[:users [:sequential ::user]]
|
||||
[:data_warehouses [:sequential ::data-warehouse]]
|
||||
[:permissions [:sequential ::permission]]
|
||||
[:created_at [:any {:description "The date and time the workspace was created"}]]
|
||||
[:updated_at [:any {:description "The date and time the workspace was last updated"}]]])
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Workspace [_model] :workspace)
|
||||
|
||||
(t2/deftransforms :model/Workspace
|
||||
{:plans mi/transform-json
|
||||
:activity_logs mi/transform-json
|
||||
:transforms mi/transform-json
|
||||
:documents mi/transform-json
|
||||
:users mi/transform-json
|
||||
:data_warehouses mi/transform-json
|
||||
:permissions mi/transform-json})
|
||||
|
||||
(doto :model/Workspace
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(defn- sort-toplevel-keys
|
||||
"Sorts top-level keys in the order they're defined in the schema."
|
||||
[k1 k2]
|
||||
(let [order (zipmap (mut/keys (mr/resolve-schema ::workspace)) (range))]
|
||||
(compare (get order k1 Integer/MAX_VALUE)
|
||||
(get order k2 Integer/MAX_VALUE))))
|
||||
|
||||
(defn- created-at-sort
|
||||
"Sort a collection of workspaces by their `created_at` timestamp, newest first.
|
||||
Handles nil or invalid created_at values by treating them as oldest."
|
||||
[xs]
|
||||
(vec (sort-by (fn [item]
|
||||
(try
|
||||
(when-let [created_at (get item :created_at)]
|
||||
(t/instant created_at))
|
||||
(catch Exception _
|
||||
;; Return a very old instant for invalid dates
|
||||
(t/instant "1970-01-01T00:00:00Z"))))
|
||||
#(compare %2 %1) ; Reverse comparison for newest first
|
||||
xs)))
|
||||
|
||||
(defn sort-workspace
|
||||
"Required for a stable diff in a yaml view"
|
||||
[workspace]
|
||||
(-> (into (sorted-map-by sort-toplevel-keys) workspace)
|
||||
(update :documents (fnil (comp vec sort) []))
|
||||
(update :plans (fnil created-at-sort []))
|
||||
(update :transforms (fnil created-at-sort []))
|
||||
(update :users (fnil created-at-sort []))
|
||||
(update :data_warehouses (fnil created-at-sort []))
|
||||
(update :permissions (fnil created-at-sort []))))
|
||||
|
||||
(defn transform-table-dependencies
|
||||
"Identifies the database tables that a transform depends on.
|
||||
|
||||
For workspace transforms, this function takes a transform object and returns
|
||||
a set of table IDs that the transform's source query depends on.
|
||||
|
||||
Args:
|
||||
- transform: A transform object with :source containing query information
|
||||
|
||||
Returns:
|
||||
- A set of table IDs (integers) that the transform depends on"
|
||||
[transform]
|
||||
(when-let [db-id (get-in transform [:source :query :database])]
|
||||
(qp.store/with-metadata-provider db-id
|
||||
(#'transforms.ordering/transform-deps transform))))
|
||||
|
||||
(defn workspace-transform-dependencies
|
||||
"Analyzes all transforms in a workspace and returns their table dependencies.
|
||||
|
||||
Args:
|
||||
- workspace: A workspace object containing transforms
|
||||
|
||||
Returns:
|
||||
- A map of transform name -> set of table IDs that transform depends on"
|
||||
[workspace]
|
||||
(into {}
|
||||
(keep (fn [transform]
|
||||
(when-let [deps (transform-table-dependencies transform)]
|
||||
[(:name transform) deps])))
|
||||
(:transforms workspace [])))
|
||||
|
||||
(defn transform-table-dependencies-detailed
|
||||
"Returns detailed information about the tables a transform depends on.
|
||||
|
||||
Args:
|
||||
- transform: A transform object with :source containing query information
|
||||
|
||||
Returns:
|
||||
- A seq of maps containing table details: {:id, :name, :schema, :display_name, etc.}"
|
||||
[transform]
|
||||
(when-let [table-ids (transform-table-dependencies transform)]
|
||||
(when (seq table-ids)
|
||||
(t2/select [:model/Table :id :name :schema :display_name :description :db_id]
|
||||
:id [:in table-ids]))))
|
||||
|
||||
(defn workspace-transform-dependencies-detailed
|
||||
"Returns detailed table dependency information for all transforms in a workspace.
|
||||
|
||||
Args:
|
||||
- workspace: A workspace object containing transforms
|
||||
|
||||
Returns:
|
||||
- A map of transform name -> seq of detailed table information"
|
||||
[workspace]
|
||||
(into {}
|
||||
(keep (fn [transform]
|
||||
(when-let [deps (transform-table-dependencies-detailed transform)]
|
||||
[(:name transform) deps])))
|
||||
(:transforms workspace [])))
|
||||
|
||||
(comment
|
||||
(require '[clojure.walk :as walk])
|
||||
|
||||
(require '[malli.core :as mc] '[malli.error :as me] '[malli.util :as mut] '[metabase.util.malli :as mu]
|
||||
'[metabase.util.malli.describe :as umd] '[malli.provider :as mp] '[malli.generator :as mg]
|
||||
'[malli.transform :as mtx] '[metabase.util.malli.registry :as mr] '[malli.json-schema :as mjs])
|
||||
|
||||
(defn super-get [m k]
|
||||
(let [found (volatile! [])]
|
||||
(walk/postwalk
|
||||
(fn [x]
|
||||
(when (and (coll? x)
|
||||
(= 2 (count x))
|
||||
(= k (first x)))
|
||||
(vswap! found conj (second x)))
|
||||
x)
|
||||
m)
|
||||
@found))
|
||||
|
||||
(transform-table-dependencies (:transforms (sort-workspace (t2/select-one :model/Workspace 254))))
|
||||
;; correct order:
|
||||
;; => ("2025-08-08T21:16:50.420896Z"
|
||||
;; "2025-08-08T21:13:40.567837Z"
|
||||
;; "2025-08-08T21:07:45.052178Z"
|
||||
;; "2025-08-08T21:03:43.194484Z"
|
||||
;; "2025-08-08T20:07:45.709135Z")
|
||||
|
||||
;; => ("2025-08-08T20:07:45.709135Z"
|
||||
;; "2025-08-08T21:16:50.420896Z"
|
||||
;; "2025-08-08T21:13:40.567837Z"
|
||||
;; "2025-08-08T21:07:45.052178Z"
|
||||
;; "2025-08-08T21:03:43.194484Z")
|
||||
)
|
||||
(mu/defn- ->yaml [workspace :- ::workspace]
|
||||
(yaml/generate-string (sort-workspace workspace) :dumper-options {:flow-style :block}))
|
||||
|
||||
;; temp for testing
|
||||
(mu/defn- write-yaml
|
||||
([workspace] (write-yaml workspace {}))
|
||||
([workspace :- ::workspace
|
||||
options :- [:map [:prefix :string]]]
|
||||
(let [file-name (str (:prefix options "")
|
||||
(str/replace (:name workspace) " " "_") ".yaml")]
|
||||
(fs/create-dirs (fs/parent file-name))
|
||||
(println "Writing workspace to" file-name)
|
||||
(spit file-name (->yaml workspace)))))
|
||||
|
||||
(comment
|
||||
|
||||
(let [w (mg/generate ::workspace {:size 1})]
|
||||
(write-yaml w {:prefix "yaml_samples/"})))
|
||||
@@ -0,0 +1,82 @@
|
||||
(ns metabase-enterprise.documents.api.collection-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest document-cards-do-not-appear-in-collection-items
|
||||
(testing "GET /api/collection/:id/items excludes cards with document_id"
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Document {doc-id :id} {}
|
||||
:model/Card {normal-card-id :id} {:collection_id coll-id}
|
||||
:model/Card _ {:collection_id coll-id
|
||||
:document_id doc-id}
|
||||
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
|
||||
(testing "Normal cards and dashboards appear, but in_document cards do not"
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model)
|
||||
(:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items"))))))))
|
||||
(testing "Even with show_dashboard_questions=true, in_document cards do not appear"
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model)
|
||||
(:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items?show_dashboard_questions=true")))))))))))
|
||||
|
||||
(deftest document-cards-do-not-appear-in-root-items
|
||||
(testing "GET /api/collection/root/items excludes cards with document_id"
|
||||
(mt/with-temp [:model/Document {doc-id :id} {}
|
||||
:model/Card {normal-card-id :id} {:collection_id nil
|
||||
:name "Normal Root Card"}
|
||||
:model/Card {in-document-card-id :id} {:collection_id nil
|
||||
:document_id doc-id
|
||||
:name "In Document Root Card"}
|
||||
:model/Dashboard {dash-id :id} {:collection_id nil
|
||||
:name "Root Dashboard"}]
|
||||
(testing "Normal cards and dashboards in root appear, but in_document cards do not"
|
||||
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
|
||||
root-test-items (filter #(#{normal-card-id dash-id in-document-card-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) root-test-items))))))
|
||||
(testing "Even with show_dashboard_questions=true, in_document cards do not appear"
|
||||
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items?show_dashboard_questions=true")
|
||||
root-test-items (filter #(#{normal-card-id dash-id in-document-card-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) root-test-items)))))))))
|
||||
|
||||
(deftest documents-appear-in-collection-items
|
||||
(testing "GET /api/collection/:id/items includes documents"
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Document {doc-id :id} {:collection_id coll-id
|
||||
:name "Test Document"}
|
||||
:model/Card {card-id :id} {:collection_id coll-id}
|
||||
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
|
||||
(testing "Documents appear alongside cards and dashboards"
|
||||
(is (= #{[doc-id "document"]
|
||||
[card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model)
|
||||
(:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items")))))))))))
|
||||
|
||||
(deftest documents-appear-in-root-items
|
||||
(testing "GET /api/collection/root/items includes documents"
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:collection_id nil
|
||||
:name "Root Document"}
|
||||
:model/Card {card-id :id} {:collection_id nil
|
||||
:name "Root Card"}
|
||||
:model/Dashboard {dash-id :id} {:collection_id nil
|
||||
:name "Root Dashboard"}]
|
||||
(testing "Documents appear alongside cards and dashboards in root"
|
||||
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
|
||||
root-test-items (filter #(#{doc-id card-id dash-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[doc-id "document"]
|
||||
[card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) root-test-items)))))))))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,361 @@
|
||||
(ns metabase-enterprise.documents.models.document-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.documents.models.document :as document]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db :test-users))
|
||||
|
||||
(deftest sync-document-cards-collection-matching-cards-test
|
||||
(testing "should only update cards with matching document_id"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
|
||||
:model/Document {document-id :id} {:name "Test Document"}
|
||||
:model/Card {card1-id :id} {:name "Document Card 1"
|
||||
:document_id document-id
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)}
|
||||
:model/Card {card2-id :id} {:name "Document Card 2"
|
||||
:document_id document-id
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)}
|
||||
:model/Card {other-card-id :id} {:name "Other Card"
|
||||
:type :question
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)}]
|
||||
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
|
||||
;; Should update exactly 2 cards
|
||||
(is (= 2 updated-count))
|
||||
|
||||
;; Verify the document cards were updated
|
||||
(let [updated-card1 (t2/select-one :model/Card :id card1-id)
|
||||
updated-card2 (t2/select-one :model/Card :id card2-id)
|
||||
other-card (t2/select-one :model/Card :id other-card-id)]
|
||||
(is (= collection-id (:collection_id updated-card1)))
|
||||
(is (= collection-id (:collection_id updated-card2)))
|
||||
;; Other card should not be affected
|
||||
(is (nil? (:collection_id other-card))))))))
|
||||
|
||||
(deftest sync-document-cards-collection-nil-document-document-id-test
|
||||
(testing "should not affect cards with nil document_id"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
|
||||
:model/Document {document-id :id} {:name "Test Document"}
|
||||
:model/Card {nil-document-card-id :id} {:name "Nil Document Card"
|
||||
:document_id nil
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)}]
|
||||
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
|
||||
;; Should update 0 cards since no cards have matching document_id
|
||||
(is (= 0 updated-count))
|
||||
|
||||
;; Verify the card with nil document_id was not affected
|
||||
(let [unchanged-card (t2/select-one :model/Card :id nil-document-card-id)]
|
||||
(is (nil? (:collection_id unchanged-card))))))))
|
||||
|
||||
(deftest sync-document-cards-collection-large-numbers-test
|
||||
(testing "should handle large numbers of cards efficiently"
|
||||
(mt/with-model-cleanup [:model/Card]
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
|
||||
:model/Document {document-id :id} {:name "Test Document with Many Cards"}]
|
||||
;; Create 50 cards associated with the document
|
||||
(let [card-ids (doall
|
||||
(for [i (range 50)]
|
||||
(:id (t2/insert-returning-pks! :model/Card
|
||||
(merge (mt/with-temp-defaults :model/Card)
|
||||
{:document_id document-id
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)})))))]
|
||||
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
|
||||
;; Should update all 50 cards
|
||||
(is (= 50 updated-count))
|
||||
|
||||
;; Verify all cards were updated (check a few samples)
|
||||
(let [sample-cards (t2/select :model/Card :id [:in card-ids] {:limit 5})]
|
||||
(is (every? #(= collection-id (:collection_id %)) sample-cards)))))))))
|
||||
|
||||
(deftest sync-document-cards-collection-empty-result-sets-test
|
||||
(testing "should handle empty result sets gracefully"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
|
||||
:model/Document {document-id :id} {:name "Empty Document"}]
|
||||
;; No cards associated with this document
|
||||
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
|
||||
;; Should return 0 for no updates
|
||||
(is (= 0 updated-count))))))
|
||||
|
||||
(deftest document-collection-sync-hook-triggers-on-collection-change-test
|
||||
(testing "Hook is called when document collection_id changes"
|
||||
(mt/with-temp
|
||||
[:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/Document {document-id :id} {:collection_id old-collection-id
|
||||
:name "Test Document"}
|
||||
:model/Card {card1-id :id} {:name "Card 1"
|
||||
:document_id document-id
|
||||
:collection_id old-collection-id}
|
||||
:model/Card {card2-id :id} {:name "Card 2"
|
||||
:document_id document-id
|
||||
:collection_id old-collection-id}]
|
||||
|
||||
;; Update the document's collection_id
|
||||
(t2/update! :model/Document document-id {:collection_id new-collection-id})
|
||||
|
||||
;; Verify that associated cards were updated to match the new collection
|
||||
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card1-id))))
|
||||
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card2-id)))))))
|
||||
|
||||
(deftest document-collection-sync-hook-handles-nil-collections-test
|
||||
(testing "Hook correctly handles nil collection values"
|
||||
(mt/with-temp
|
||||
[:model/Collection {collection-id :id} {:name "Test Collection"}
|
||||
:model/Document {document-id :id} {:collection_id collection-id
|
||||
:name "Test Document"}
|
||||
:model/Card {card-id :id} {:name "Card"
|
||||
:document_id document-id
|
||||
:collection_id collection-id}]
|
||||
|
||||
;; Move document to no collection (nil)
|
||||
(t2/update! :model/Document document-id {:collection_id nil})
|
||||
|
||||
;; Verify that the card's collection_id was updated to nil
|
||||
(is (nil? (:collection_id (t2/select-one :model/Card :id card-id)))))))
|
||||
|
||||
(deftest document-collection-sync-hook-only-affects-cards-test
|
||||
(testing "Hook only updates cards with matching document_id"
|
||||
(mt/with-temp
|
||||
[:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/Document {document-id :id} {:collection_id old-collection-id
|
||||
:name "Test Document"}
|
||||
;; Card that should be updated (correct type and document_id)
|
||||
:model/Card {in-document-card-id :id} {:name "In-Document Card"
|
||||
:type :question
|
||||
:document_id document-id
|
||||
:collection_id old-collection-id}
|
||||
;; Card that should be updated (wrong type)
|
||||
:model/Card {question-card-id :id} {:name "Question Card"
|
||||
:type :model
|
||||
:collection_id old-collection-id}
|
||||
;; Card that should NOT be updated (no document_id)
|
||||
:model/Card {regular-card-id :id} {:name "Regular Card"
|
||||
:collection_id old-collection-id}]
|
||||
|
||||
;; Update the document's collection_id
|
||||
(t2/update! :model/Document document-id {:collection_id new-collection-id})
|
||||
|
||||
;; Verify only the correct card was updated
|
||||
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id in-document-card-id))))
|
||||
|
||||
;; Verify other cards were NOT updated
|
||||
(is (= old-collection-id (:collection_id (t2/select-one :model/Card :id question-card-id))))
|
||||
(is (= old-collection-id (:collection_id (t2/select-one :model/Card :id regular-card-id)))))))
|
||||
|
||||
(deftest personal-collection-edge-cases-test
|
||||
(testing "Personal collection handling"
|
||||
(binding [collection/*allow-deleting-personal-collections* true]
|
||||
(mt/with-temp [:model/User {user-id :id} {:first_name "Test" :last_name "User" :email "test@example.com"}
|
||||
:model/Collection {personal-collection-id :id} {:name "Personal Collection"
|
||||
:personal_owner_id user-id}
|
||||
:model/Collection {regular-collection-id :id} {:name "Regular Collection"}
|
||||
:model/Document {document-id :id} {:collection_id regular-collection-id
|
||||
:name "Personal Test Document"}
|
||||
:model/Card {card-id :id} {:name "Personal Card"
|
||||
:document_id document-id
|
||||
:collection_id regular-collection-id
|
||||
:dataset_query (mt/mbql-query venues)}]
|
||||
|
||||
(testing "moving document to personal collection moves associated cards"
|
||||
(mt/with-current-user user-id
|
||||
;; As the personal collection owner, update should succeed
|
||||
(t2/update! :model/Document document-id {:collection_id personal-collection-id})
|
||||
|
||||
;; Verify both document and card moved to personal collection
|
||||
(is (= personal-collection-id (:collection_id (t2/select-one :model/Document :id document-id))))
|
||||
(is (= personal-collection-id (:collection_id (t2/select-one :model/Card :id card-id))))))
|
||||
|
||||
(testing "moving document from personal collection works"
|
||||
(mt/with-current-user user-id
|
||||
;; Move back to regular collection
|
||||
(t2/update! :model/Document document-id {:collection_id regular-collection-id})
|
||||
|
||||
;; Verify both document and card moved back
|
||||
(is (= regular-collection-id (:collection_id (t2/select-one :model/Document :id document-id))))
|
||||
(is (= regular-collection-id (:collection_id (t2/select-one :model/Card :id card-id))))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-allows-move-with-both-permissions-test
|
||||
(testing "allows move when user has write permissions for both collections"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permissions to both collections
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
|
||||
|
||||
;; Should not throw any exception
|
||||
(is (true? (document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-403-missing-old-permission-test
|
||||
(testing "throws 403 when user lacks write permission for old collection"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission only to new collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
|
||||
|
||||
;; Should throw 403 exception
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
|
||||
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-403-missing-new-permission-test
|
||||
(testing "throws 403 when user lacks write permission for new collection"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission only to old collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
|
||||
;; Should throw 403 exception
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
|
||||
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-403-no-permissions-test
|
||||
(testing "throws 403 when user lacks write permissions for both collections"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; No permissions granted
|
||||
|
||||
;; Should throw 403 exception
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
|
||||
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-allows-move-from-root-test
|
||||
(testing "allows move when old collection is nil (moving from root)"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission to new collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
|
||||
|
||||
;; Should not throw any exception when old collection is nil
|
||||
(is (true? (document/validate-collection-move-permissions nil new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-allows-move-to-root-test
|
||||
(testing "allows move when new collection is nil (moving to root)"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission to old collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
|
||||
;; Should not throw any exception when new collection is nil
|
||||
(is (nil? (document/validate-collection-move-permissions old-collection-id nil))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-allows-move-both-nil-test
|
||||
(testing "allows move when both collections are nil"
|
||||
(mt/with-temp [:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Should not throw any exception when both collections are nil
|
||||
(is (nil? (document/validate-collection-move-permissions nil nil)))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-400-nonexistent-collection-test
|
||||
(testing "throws 400 when new collection does not exist"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission to old collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
|
||||
;; Use a non-existent collection ID
|
||||
(let [non-existent-id 999999]
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
|
||||
(document/validate-collection-move-permissions old-collection-id non-existent-id)))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-400-archived-collection-test
|
||||
(testing "throws 400 when new collection is archived"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {archived-collection-id :id} {:name "Archived Collection" :archived true}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permissions to both collections
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) archived-collection-id)
|
||||
|
||||
;; Should throw 400 exception for archived collection
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid"
|
||||
(document/validate-collection-move-permissions old-collection-id archived-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-superuser-test
|
||||
(testing "superuser can move between any collections"
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
;; Superuser should be able to move without explicit permissions
|
||||
(is (true? (document/validate-collection-move-permissions old-collection-id new-collection-id)))))))
|
||||
|
||||
(deftest hydrate-document-creator-test
|
||||
(testing "hydrates document creator correctly"
|
||||
(mt/with-temp [:model/User {user-id :id} {:first_name "John"
|
||||
:last_name "Doe"
|
||||
:email "john.doe@example.com"}
|
||||
:model/Document {document-id :id} {:name "Test Document"
|
||||
:creator_id user-id}]
|
||||
(let [hydrated-doc (t2/hydrate (t2/select-one :model/Document :id document-id) :creator)]
|
||||
(testing "creator is hydrated with correct user data"
|
||||
(is (some? (:creator hydrated-doc)))
|
||||
(is (= user-id (get-in hydrated-doc [:creator :id])))
|
||||
(is (= "John" (get-in hydrated-doc [:creator :first_name])))
|
||||
(is (= "Doe" (get-in hydrated-doc [:creator :last_name])))
|
||||
(is (= "john.doe@example.com" (get-in hydrated-doc [:creator :email]))))))))
|
||||
|
||||
(deftest hydrate-multiple-documents-creator-test
|
||||
(testing "hydrates creators for multiple documents efficiently"
|
||||
(mt/with-temp [:model/User {user1-id :id} {:first_name "Alice"
|
||||
:last_name "Smith"
|
||||
:email "alice@example.com"}
|
||||
:model/User {user2-id :id} {:first_name "Bob"
|
||||
:last_name "Jones"
|
||||
:email "bob@example.com"}
|
||||
:model/Document {doc1-id :id} {:name "Document 1"
|
||||
:creator_id user1-id}
|
||||
:model/Document {doc2-id :id} {:name "Document 2"
|
||||
:creator_id user2-id}
|
||||
:model/Document {doc3-id :id} {:name "Document 3"
|
||||
:creator_id user1-id}] ; Same creator as doc1
|
||||
(let [documents (t2/select :model/Document :id [:in [doc1-id doc2-id doc3-id]])
|
||||
hydrated-docs (t2/hydrate documents :creator)]
|
||||
(testing "all documents have their creators hydrated"
|
||||
(is (= 3 (count hydrated-docs)))
|
||||
(doseq [doc hydrated-docs]
|
||||
(is (some? (:creator doc)))
|
||||
(is (some? (get-in doc [:creator :id])))
|
||||
(is (some? (get-in doc [:creator :first_name])))
|
||||
(is (some? (get-in doc [:creator :last_name])))
|
||||
(is (some? (get-in doc [:creator :email])))))
|
||||
|
||||
(testing "creators are correctly matched to documents"
|
||||
(let [doc1 (first (filter #(= doc1-id (:id %)) hydrated-docs))
|
||||
doc2 (first (filter #(= doc2-id (:id %)) hydrated-docs))
|
||||
doc3 (first (filter #(= doc3-id (:id %)) hydrated-docs))]
|
||||
(is (= user1-id (get-in doc1 [:creator :id])))
|
||||
(is (= "Alice" (get-in doc1 [:creator :first_name])))
|
||||
|
||||
(is (= user2-id (get-in doc2 [:creator :id])))
|
||||
(is (= "Bob" (get-in doc2 [:creator :first_name])))
|
||||
|
||||
(is (= user1-id (get-in doc3 [:creator :id])))
|
||||
(is (= "Alice" (get-in doc3 [:creator :first_name])))))))))
|
||||
@@ -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 #p (first revisions)]
|
||||
(is (= {:content [{:content [{:text "Original content"}]}]}
|
||||
(get-in content-revision [:diff :before :document])))
|
||||
(is (= {:content [{:content [{:text "New content"}]}]}
|
||||
(get-in content-revision [:diff :after :document])))))))))
|
||||
@@ -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"))))))))
|
||||
@@ -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,107 @@
|
||||
(ns metabase-enterprise.transforms.ordering-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.transforms.ordering :as ordering]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- make-transform [{:keys [query]}]
|
||||
{:source {:type "query",
|
||||
:query query}
|
||||
:name "transform1"
|
||||
:target {:schema "public"
|
||||
:name "orders_2"
|
||||
:type "table"}})
|
||||
|
||||
(deftest basic-ordering-test
|
||||
(mt/with-temp [:model/Transform {t1 :id} (make-transform
|
||||
{:query {:database (mt/id),
|
||||
:type "query",
|
||||
:query {:source-table (mt/id :orders)}}})]
|
||||
(is (= {t1 #{}}
|
||||
(ordering/transform-ordering [(t2/select-one :model/Transform :id t1)])))))
|
||||
|
||||
(deftest basic-dependencies-test
|
||||
(mt/with-temp [:model/Transform {t1 :id} (make-transform
|
||||
{:query {:database (mt/id),
|
||||
:type "query",
|
||||
:query {:source-table (mt/id :orders)}}})]
|
||||
(is (= #{(mt/id :orders)}
|
||||
(#'ordering/transform-deps (t2/select-one :model/Transform :id t1))))))
|
||||
|
||||
(deftest joined-dependencies-test
|
||||
(mt/with-temp [:model/Transform {t1 :id} (make-transform
|
||||
{:query {:database (mt/id),
|
||||
:type "query",
|
||||
:query {:source-table (mt/id :orders),
|
||||
:joins
|
||||
[{:fields "all",
|
||||
:strategy "left-join",
|
||||
:alias "Products",
|
||||
:condition
|
||||
[:=
|
||||
[:field
|
||||
(mt/id :orders :product_id)
|
||||
{:base-type "type/Integer"}]
|
||||
[:field
|
||||
(mt/id :products :id)
|
||||
{:base-type "type/Integer",
|
||||
:join-alias "Products"}]],
|
||||
:source-table (mt/id :products)}]},
|
||||
:parameters []}})]
|
||||
(is (= #{(mt/id :orders)
|
||||
(mt/id :products)}
|
||||
(into #{}
|
||||
(#'ordering/transform-deps (t2/select-one :model/Transform :id t1)))))))
|
||||
|
||||
(deftest card-dependencies-test
|
||||
(mt/with-temp [:model/Card {card :id} {:dataset_query {:database (mt/id),
|
||||
:type "query",
|
||||
:query {:source-table (mt/id :orders),
|
||||
:joins
|
||||
[{:fields "all",
|
||||
:strategy "left-join",
|
||||
:alias "Products",
|
||||
:condition
|
||||
[:=
|
||||
[:field
|
||||
(mt/id :orders :product_id)
|
||||
{:base-type "type/Integer"}]
|
||||
[:field
|
||||
(mt/id :products :id)
|
||||
{:base-type "type/Integer",
|
||||
:join-alias "Products"}]],
|
||||
:source-table (mt/id :products)}]},
|
||||
:parameters []}}
|
||||
:model/Transform {t1 :id} (make-transform
|
||||
{:query {:database (mt/id),
|
||||
:type "query",
|
||||
:query
|
||||
{:aggregation [["count"]],
|
||||
:breakout [["field" "Products__category" {:base-type "type/Text"}]],
|
||||
:source-table (str "card__" card)},
|
||||
:parameters []}})]
|
||||
(is (= #{(mt/id :orders)
|
||||
(mt/id :products)}
|
||||
(into #{}
|
||||
(#'ordering/transform-deps (t2/select-one :model/Transform :id t1)))))))
|
||||
|
||||
(defn- rotations
|
||||
[v]
|
||||
(let [n (count v)
|
||||
elements (cycle v)]
|
||||
(into #{} (map #(take n (drop % elements)))
|
||||
(range n))))
|
||||
|
||||
(deftest find-cycle-test
|
||||
(let [test-graph
|
||||
{1 #{2}
|
||||
2 #{3 5}
|
||||
3 #{4}
|
||||
4 #{6}
|
||||
5 #{6}
|
||||
6 #{7}
|
||||
7 #{3}}
|
||||
cycles (rotations [3 4 6 7])]
|
||||
(is (contains? cycles
|
||||
(ordering/find-cycle test-graph)))))
|
||||
@@ -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)))
|
||||
@@ -0,0 +1,547 @@
|
||||
(ns metabase-enterprise.workspaces.api.workspace-test
|
||||
"Tests for workspace API endpoints"
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[malli.core :as mc]
|
||||
[malli.util :as mut]
|
||||
[metabase-enterprise.workspaces.models.workspace :as m.workspace]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest workspace-validation-test
|
||||
(testing "Workspace validation with updated Malli schema structure"
|
||||
(let [valid-workspace {:name "Test"
|
||||
:description "Test workspace"
|
||||
:created_at "2023-08-06T15:00:00Z"
|
||||
:updated_at "2023-08-06T15:00:00Z"
|
||||
:plans []
|
||||
:transforms []
|
||||
:activity_logs []
|
||||
:permissions []
|
||||
:users []
|
||||
:data_warehouses []
|
||||
:documents []}]
|
||||
(is (nil? (mc/explain ::m.workspace/workspace valid-workspace))))))
|
||||
|
||||
(deftest create-workspace-test
|
||||
(testing "Creating a workspace with valid data using mt/with-temp"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "API Workspace Collection"}
|
||||
:model/Workspace {workspace-id :id :as wksp} {:name "Test Workspace"
|
||||
:description "A test workspace"
|
||||
:collection_id collection-id}]
|
||||
(is (pos? workspace-id))
|
||||
(is (= "Test Workspace"
|
||||
(:name (t2/select-one :model/Workspace :id workspace-id))))
|
||||
(is (= (mut/keys (mr/resolve-schema ::m.workspace/workspace))
|
||||
(keys (m.workspace/sort-workspace wksp)))))))
|
||||
|
||||
;;; API Endpoint Tests
|
||||
|
||||
(defn- test-workspace-data
|
||||
"Generate test workspace data with a valid collection."
|
||||
([]
|
||||
(test-workspace-data {}))
|
||||
([overrides]
|
||||
(merge {:name "Test Workspace"
|
||||
:description "A test workspace"
|
||||
:collection_id 1 ; Will be replaced with actual collection ID in tests
|
||||
:users []
|
||||
:plans []
|
||||
:transforms []
|
||||
:documents []
|
||||
:data_warehouses []
|
||||
:permissions []
|
||||
:activity_logs []}
|
||||
overrides)))
|
||||
|
||||
(defn- create-test-collection
|
||||
"Create a test collection for workspace tests."
|
||||
[]
|
||||
(mt/user-http-request :crowberto :post 200 "collection/"
|
||||
{:name "Test Workspace Collection"
|
||||
:slug "test_workspace_collection"}))
|
||||
|
||||
(deftest api-create-workspace-validation-test
|
||||
(testing "POST /api/ee/workspace/ - validation tests"
|
||||
(mt/with-model-cleanup [:model/Workspace]
|
||||
(let [collection (create-test-collection)]
|
||||
|
||||
(testing "should require name"
|
||||
(mt/user-http-request :crowberto :post 400 "ee/workspace/"
|
||||
{:description "Missing name"
|
||||
:collection_id (:id collection)}))
|
||||
|
||||
(testing "should require collection_id"
|
||||
(mt/user-http-request :crowberto :post 400 "ee/workspace/"
|
||||
{:name "Missing collection"
|
||||
:description "Test"}))
|
||||
|
||||
(testing "should reject blank name"
|
||||
(mt/user-http-request :crowberto :post 400 "ee/workspace/"
|
||||
{:name ""
|
||||
:collection_id (:id collection)
|
||||
:description "Blank name"}))))))
|
||||
|
||||
(deftest api-get-workspaces-test
|
||||
(testing "GET /api/ee/workspace/ - list workspaces"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "API Workspace Collection"}
|
||||
:model/Workspace _ {:name "API Workspace 1"
|
||||
:collection_id collection-id}
|
||||
:model/Workspace _ {:name "API Workspace 2"
|
||||
:collection_id collection-id}]
|
||||
(let [result (mt/user-http-request :crowberto :get 200 "ee/workspace/")]
|
||||
(is (>= (count result) 2))
|
||||
(is (some #(= "API Workspace 1" (:name %)) result))
|
||||
(is (some #(= "API Workspace 2" (:name %)) result))))))
|
||||
|
||||
(deftest api-get-workspace-by-id-test
|
||||
(testing "GET /api/ee/workspace/:id - get specific workspace"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Test Workspace Collection"}
|
||||
:model/Workspace {workspace-id :id :as test-workspace} {:name "Test Workspace"
|
||||
:collection_id collection-id}]
|
||||
(def tw test-workspace)
|
||||
(testing "should get existing workspace"
|
||||
(let [result (mt/user-http-request :crowberto :get 200 (format "ee/workspace/%s" workspace-id))]
|
||||
(is (= workspace-id (:id result)))))
|
||||
(testing "should return 404 for non-existent workspace"
|
||||
(mt/user-http-request :crowberto :get 404 "ee/workspace/99999")))))
|
||||
|
||||
(deftest api-update-workspace-test
|
||||
(testing "PUT /api/ee/workspace/:id - update workspace"
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
|
||||
(testing "should update workspace successfully"
|
||||
(let [updated-data {:name "Updated Workspace"
|
||||
:description "Updated description"}
|
||||
result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s" workspace-id)
|
||||
updated-data)]
|
||||
(is (partial= updated-data result))
|
||||
(is (= workspace-id (:id result)))
|
||||
|
||||
;; Verify in database
|
||||
(let [db-workspace (t2/select-one :model/Workspace :id workspace-id)]
|
||||
(is (partial= updated-data db-workspace)))))
|
||||
|
||||
(testing "should return 404 for non-existent workspace"
|
||||
(mt/user-http-request :crowberto :put 404 "ee/workspace/99999"
|
||||
{:name "Non-existent"})))))
|
||||
|
||||
(deftest api-delete-workspace-test
|
||||
(testing "DELETE /api/ee/workspace/:id - delete workspace"
|
||||
(mt/with-model-cleanup [:model/Workspace]
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
|
||||
(testing "should delete workspace successfully"
|
||||
(mt/user-http-request :crowberto :delete 204
|
||||
(format "ee/workspace/%s" workspace-id))
|
||||
|
||||
;; Verify workspace is deleted
|
||||
(is (nil? (t2/select-one :model/Workspace :id workspace-id))))
|
||||
|
||||
(testing "should return 404 for non-existent workspace"
|
||||
(mt/user-http-request :crowberto :delete 404 "ee/workspace/99999"))))))
|
||||
|
||||
;;; Workspace Plans API Tests
|
||||
|
||||
(deftest api-add-plan-to-workspace-test
|
||||
(testing "PUT /api/ee/workspace/:id/plan - add plan to workspace"
|
||||
(mt/with-model-cleanup [:model/Workspace]
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
|
||||
(testing "should add plan successfully"
|
||||
(let [plan-data {:title "Test Plan"
|
||||
:description "Test plan description"
|
||||
:content {:steps ["Step 1" "Step 2" "Step 3"]
|
||||
:timeline "Q1 2025"}}
|
||||
result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/plan" workspace-id)
|
||||
plan-data)]
|
||||
(is (= workspace-id (:id result)))
|
||||
(is (= 1 (count (:plans result))))
|
||||
(let [added-plan (first (:plans result))]
|
||||
(is (partial= plan-data added-plan))
|
||||
(is (contains? added-plan :created_at)))))
|
||||
|
||||
(testing "should add multiple plans"
|
||||
(let [second-plan {:title "Second Plan"
|
||||
:description "Second plan description"
|
||||
:content {:steps ["Another step"]}}]
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/plan" workspace-id)
|
||||
second-plan)
|
||||
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)]
|
||||
(is (= 2 (count (:plans updated-workspace)))))))
|
||||
|
||||
(testing "should validate required fields"
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/plan" workspace-id)
|
||||
{:description "Missing title"
|
||||
:content {}}))
|
||||
|
||||
(testing "should return 404 for non-existent workspace"
|
||||
(mt/user-http-request :crowberto :put 404 "ee/workspace/99999/plan"
|
||||
{:title "Plan" :description "Desc" :content {}}))))))
|
||||
|
||||
;;; Workspace Transforms API Tests
|
||||
|
||||
(deftest api-add-transform-to-workspace-test
|
||||
(testing "PUT /api/ee/workspace/:id/transform - add transform to workspace"
|
||||
(mt/with-model-cleanup [:model/Workspace]
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
|
||||
(testing "should add transform successfully"
|
||||
(let [transform-data {:name "ETL Transform"
|
||||
:description "Transform raw data"
|
||||
:source {:type "database" :connection "source-db"}
|
||||
:target {:type "warehouse" :connection "target-db"}
|
||||
:config {:schedule "daily"}}
|
||||
result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/transform" workspace-id)
|
||||
transform-data)]
|
||||
(is (= workspace-id (:id result)))
|
||||
(is (= 1 (count (:transforms result))))
|
||||
(let [added-transform (first (:transforms result))]
|
||||
(is (partial= transform-data added-transform))
|
||||
(is (contains? added-transform :created_at)))))
|
||||
|
||||
(testing "should validate required fields"
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/transform" workspace-id)
|
||||
{:description "Missing name and source/target"})
|
||||
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/transform" workspace-id)
|
||||
{:name "Transform"
|
||||
:description "Missing source"
|
||||
:target {}}))))))
|
||||
|
||||
(deftest api-add-user-to-workspace-test
|
||||
(testing "PUT /api/ee/workspace/:id/user - add user to workspace"
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
|
||||
(testing "should add user successfully"
|
||||
(let [user-data {:id 123
|
||||
:name "John Doe"
|
||||
:email "john@example.com"
|
||||
:type "workspace-user"}
|
||||
result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/user" workspace-id)
|
||||
user-data)]
|
||||
result
|
||||
(def result result)
|
||||
(is (= workspace-id (:id result)))
|
||||
(is (= 1 (count (:users result))))
|
||||
(let [added-user (first (:users result))]
|
||||
(is (partial= user-data added-user))
|
||||
(is (contains? added-user :created_at)))))
|
||||
|
||||
(testing "should validate required fields"
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/user" workspace-id)
|
||||
{:name "Missing user_id and email"
|
||||
:type "user"})))))
|
||||
|
||||
(deftest api-add-document-to-workspace-test
|
||||
(testing "PUT /api/ee/workspace/:id/document - add document to workspace"
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
|
||||
(testing "should add document successfully"
|
||||
(let [document-data {:document_id 456}
|
||||
result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/document" workspace-id)
|
||||
document-data)]
|
||||
(is (= workspace-id (:id result)))
|
||||
(is (= [456] (:documents result)))))
|
||||
|
||||
(testing "should add multiple documents"
|
||||
(let [second-document {:document_id 789}]
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/document" workspace-id)
|
||||
second-document)
|
||||
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)]
|
||||
(is (= #{456 789} (set (:documents updated-workspace)))))))
|
||||
|
||||
(testing "should not duplicate document IDs"
|
||||
(let [duplicate-document {:document_id 456}]
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/document" workspace-id)
|
||||
duplicate-document)
|
||||
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)]
|
||||
(is (= [456 789] (:documents updated-workspace))))))
|
||||
|
||||
(testing "should validate required fields"
|
||||
(is (= {:specific-errors {:document_id ["missing required key, received: nil"]},
|
||||
:errors {:document_id "value must be an integer greater than zero."}}
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/document" workspace-id)
|
||||
{:missing "document_id"})))))))
|
||||
|
||||
;;; Workspace Data Warehouses API Tests
|
||||
|
||||
(deftest api-add-data-warehouse-to-workspace-test
|
||||
(testing "PUT /api/ee/workspace/:id/data_warehouse - add data warehouse to workspace"
|
||||
(mt/with-model-cleanup [:model/Workspace]
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
|
||||
(testing "should add data warehouse successfully"
|
||||
(let [dwh-data {:data_warehouses_id 101
|
||||
:name "Production DWH"
|
||||
:type "read-only"
|
||||
:credentials {:host "dwh.company.com"
|
||||
:port 5432
|
||||
:username "analyst"}}
|
||||
result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/data_warehouse" workspace-id)
|
||||
dwh-data)]
|
||||
(is (= workspace-id (:id result)))
|
||||
(is (= 1 (count (:data_warehouses result))))
|
||||
(let [added-dwh (first (:data_warehouses result))]
|
||||
(is (= 101 (:id added-dwh)))
|
||||
(is (= "Production DWH" (:name added-dwh)))
|
||||
(is (= "read-only" (:type added-dwh)))
|
||||
(is (= (:credentials dwh-data) (:credentials added-dwh)))
|
||||
(is (contains? added-dwh :created_at)))))
|
||||
|
||||
(testing "should add multiple data warehouses"
|
||||
(let [second-dwh {:data_warehouses_id 102
|
||||
:name "Analytics DWH"
|
||||
:type "read-write"
|
||||
:credentials {:host "analytics.company.com"}}]
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/data_warehouse" workspace-id)
|
||||
second-dwh)
|
||||
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)]
|
||||
(is (= 2 (count (:data_warehouses updated-workspace)))))))
|
||||
|
||||
(testing "should validate required fields"
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/data_warehouse" workspace-id)
|
||||
{:name "Missing required fields"})
|
||||
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/data_warehouse" workspace-id)
|
||||
{:data_warehouses_id 103
|
||||
:name "Invalid type"
|
||||
:type "invalid-type"
|
||||
:credentials {}}))
|
||||
|
||||
(testing "should validate type enum"
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/data_warehouse" workspace-id)
|
||||
{:data_warehouses_id 104
|
||||
:name "Bad Type"
|
||||
:type "write-only"
|
||||
:credentials {}}))))))
|
||||
|
||||
;;; Workspace Permissions API Tests
|
||||
|
||||
(deftest api-add-permission-to-workspace-test
|
||||
(testing "PUT /api/ee/workspace/:id/permission - add permission to workspace"
|
||||
(mt/with-model-cleanup [:model/Workspace]
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
:model/Workspace {workspace-id :id} (test-workspace-data {:collection_id col-id})]
|
||||
(testing "should add permission successfully"
|
||||
(let [permission-data {:table "customers"
|
||||
:permission "read"}
|
||||
result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/permission" workspace-id)
|
||||
permission-data)]
|
||||
(is (= workspace-id (:id result)))
|
||||
(is (= 1 (count (:permissions result))))
|
||||
(let [added-permission (first (:permissions result))]
|
||||
(is (= "customers" (:table added-permission)))
|
||||
(is (= "read" (:permission added-permission)))
|
||||
(is (contains? added-permission :created_at)))))
|
||||
|
||||
(testing "should add multiple permissions"
|
||||
(let [write-permission {:table "orders" :permission "write"}]
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/permission" workspace-id)
|
||||
write-permission)
|
||||
(let [updated-workspace (t2/select-one :model/Workspace :id workspace-id)
|
||||
perm-set (set (mapv #(dissoc % :created_at) (:permissions updated-workspace)))]
|
||||
(def perm-set perm-set)
|
||||
(is (= 2 (count perm-set)))
|
||||
(is (contains? perm-set
|
||||
{:table "orders" :permission "write"})))))
|
||||
|
||||
(testing "should validate required fields"
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/permission" workspace-id)
|
||||
{:table "products"}))
|
||||
|
||||
(testing "should validate permission enum"
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/permission" workspace-id)
|
||||
{:table "products"
|
||||
:permission "execute"}))))))
|
||||
|
||||
;;; Authentication Tests
|
||||
|
||||
(deftest api-workspace-authentication-test
|
||||
(testing "Workspace API endpoints require authentication"
|
||||
(let [endpoints [["GET" "ee/workspace/"]
|
||||
["GET" "ee/workspace/1"]
|
||||
["POST" "ee/workspace/"]
|
||||
["PUT" "ee/workspace/1"]
|
||||
["DELETE" "ee/workspace/1"]
|
||||
["PUT" "ee/workspace/1/plan"]
|
||||
["PUT" "ee/workspace/1/transform"]
|
||||
["PUT" "ee/workspace/1/user"]
|
||||
["PUT" "ee/workspace/1/document"]
|
||||
["PUT" "ee/workspace/1/dwh"]
|
||||
["PUT" "ee/workspace/1/permission"]]]
|
||||
(doseq [[method endpoint] endpoints]
|
||||
(testing (format "%s /api/%s should require authentication" method endpoint)
|
||||
(case method
|
||||
"GET" (mt/client :get 401 endpoint)
|
||||
"POST" (mt/client :post 401 endpoint {})
|
||||
"PUT" (mt/client :put 401 endpoint {})
|
||||
"DELETE" (mt/client :delete 401 endpoint)))))))
|
||||
|
||||
;;; Integration Tests
|
||||
|
||||
(deftest api-workspace-full-lifecycle-test
|
||||
(testing "Complete workspace lifecycle integration test via API"
|
||||
(mt/with-temp [:model/Collection {col-id :id} {}
|
||||
|
||||
:model/Workspace
|
||||
{workspace-id :id :as workspace}
|
||||
{:collection_id col-id :name "Test Workspace"}]
|
||||
(testing "Workspace creation"
|
||||
(is (pos? workspace-id))
|
||||
(is (= "Test Workspace" (:name workspace))))
|
||||
|
||||
(testing "Add plan to workspace"
|
||||
(let [plan-result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/plan" workspace-id)
|
||||
{:title "Strategic Plan"
|
||||
:description "2025 strategic initiatives"
|
||||
:content {:goals ["Increase efficiency" "Reduce costs"]}})]
|
||||
(is (= 1 (count (:plans plan-result))))))
|
||||
|
||||
(testing "Add transform to workspace"
|
||||
(let [transform-result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/transform" workspace-id)
|
||||
{:name "Daily ETL"
|
||||
:description "Daily data processing"
|
||||
:source {:type "oltp"}
|
||||
:target {:type "olap"}})]
|
||||
(is (= 1 (count (:transforms transform-result))))))
|
||||
|
||||
(testing "Add user to workspace"
|
||||
(let [user-result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/user" workspace-id)
|
||||
{:id 100
|
||||
:name "Alice Smith"
|
||||
:email "alice@company.com"
|
||||
:type "analyst"})]
|
||||
(is (= 1 (count (:users user-result))))))
|
||||
|
||||
(testing "Add document to workspace"
|
||||
(let [document-result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/document" workspace-id)
|
||||
{:document_id 999})]
|
||||
(is (= [999] (:documents document-result)))))
|
||||
|
||||
(testing "Add data warehouse to workspace"
|
||||
(let [dwh-result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/data_warehouse" workspace-id)
|
||||
{:data_warehouses_id 200
|
||||
:name "Test DWH"
|
||||
:type "read-only"
|
||||
:credentials {:host "test.db"}})]
|
||||
(is (= 1 (count (:data_warehouses dwh-result))))))
|
||||
|
||||
(testing "Add permission to workspace"
|
||||
(let [permission-result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/permission" workspace-id)
|
||||
{:table "test_table"
|
||||
:permission "read"})]
|
||||
(is (= 1 (count (:permissions permission-result))))))
|
||||
|
||||
(testing "Update workspace"
|
||||
(let [update-result (mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s" workspace-id)
|
||||
{:name "Updated Strategic Workspace"
|
||||
:description "Updated workspace description"})]
|
||||
(is (= "Updated Strategic Workspace" (:name update-result)))))
|
||||
|
||||
(testing "Get workspace with all components"
|
||||
(let [final-workspace (mt/user-http-request :crowberto :get 200
|
||||
(format "ee/workspace/%s" workspace-id))]
|
||||
(is (= "Updated Strategic Workspace" (:name final-workspace)))
|
||||
(is (= 1 (count (:plans final-workspace))))
|
||||
(is (= 1 (count (:transforms final-workspace))))
|
||||
;;(is (= 1 (count (:users final-workspace))))
|
||||
(is (= [999] (:documents final-workspace)))
|
||||
(is (= 1 (count (:data_warehouses final-workspace))))
|
||||
(is (= 1 (count (:permissions final-workspace))))))
|
||||
|
||||
(testing "Delete workspace"
|
||||
(mt/user-http-request :crowberto :delete 204
|
||||
(format "ee/workspace/%s" workspace-id))
|
||||
(is (nil? (t2/select-one :model/Workspace :id workspace-id)))))))
|
||||
|
||||
(comment
|
||||
|
||||
;; repl stuff
|
||||
(do
|
||||
(let [id (atom 1000)]
|
||||
(defn next-id [] (swap! id inc)))
|
||||
(def c (t2/insert-returning-instance! :model/Collection {:name "Test Workspace Collection"}))
|
||||
|
||||
(try (def w (t2/insert-returning-instance! :model/Workspace {:name "test workspace"
|
||||
:collection_id (:id c)
|
||||
:data_warehouses []
|
||||
:users []
|
||||
:plans []
|
||||
:activity_logs []
|
||||
:transforms []
|
||||
:documents []}))
|
||||
(catch Exception e (ex-data e))))
|
||||
|
||||
[w c]
|
||||
|
||||
(mt/user-http-request :crowberto :get 200
|
||||
(format "ee/workspace/%s" (:id w)))
|
||||
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/user" (:id w))
|
||||
{:id (next-id) :name "name" :email "email" :type "type"})
|
||||
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/user" (:id w))
|
||||
{:id 100 :name "Alice Smith" :email "alice@company.com" :type "analyst"})
|
||||
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/user" (:id w))
|
||||
{:id 100 :name "Alice Smith" :email "alice@company.com" :type "analyst"})
|
||||
|
||||
;; could that somehow get back:
|
||||
[{:operation :select :table "workspace" :query [:= :id (:id w)]}
|
||||
{:operation :insert
|
||||
:table "workspace"
|
||||
:row {:id 100
|
||||
:name "Alice Smith"
|
||||
:email "alice@company.com"
|
||||
:type "analyst"}}]
|
||||
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/plan" (:id w))
|
||||
{:title "x" :content {}})
|
||||
|
||||
(mt/user-http-request :crowberto :put 200
|
||||
(format "ee/workspace/%s/plan/0" (:id w))
|
||||
{:title (str "xx" (rand)) :content {}})
|
||||
|
||||
(mt/user-http-request :crowberto :put 400
|
||||
(format "ee/workspace/%s/plan" (:id w))
|
||||
{:title "X" :content {}})
|
||||
|
||||
[(mt/user-http-request :crowberto :put 200 (format "ee/workspace/%s/document" (:id w))
|
||||
{:document_id (next-id)})
|
||||
(t2/select-one :model/Workspace :id (:id w))]
|
||||
(t2/select-one :model/Workspace :id (:id w)))
|
||||
@@ -0,0 +1,235 @@
|
||||
(ns ^:mb/driver-tests metabase-enterprise.workspaces.isolation-manager-test
|
||||
"Tests for metabase-enterprise.workspaces.isolation-manager."
|
||||
(:require
|
||||
[clojure.java.jdbc :as jdbc]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[mb.hawk.assert-exprs.approximately-equal :as =?]
|
||||
[metabase-enterprise.workspaces.isolation-manager :as isolation-manager]
|
||||
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
|
||||
[metabase.query-processor.compile :as qp.compile]
|
||||
[metabase.test :as mt]
|
||||
[metabase.driver :as driver]))
|
||||
|
||||
(deftest create-isolation-test
|
||||
(mt/test-drivers #{:postgres :clickhouse}
|
||||
(let [db (mt/db)
|
||||
result (isolation-manager/create-isolation (:engine db) (:details db) "test-workspace-123")]
|
||||
(try
|
||||
(is (=? {:isolation-type (=?/malli [:enum :database :schema])
|
||||
:populator {:user (=?/malli :string) :password (=?/malli :string)}}
|
||||
result))
|
||||
(if (= :database (:isolation-type result))
|
||||
(is (not (str/blank? (:database-name result))))
|
||||
(is (not (str/blank? (:schema-name result)))))
|
||||
(if (= :schema (:isolation-type result))
|
||||
(is (str/starts-with? (:schema-name result) "mb__isolation_"))
|
||||
(is (str/starts-with? (:database-name result) "mb__isolation_")))
|
||||
(is (str/includes? (-> result :populator :user) "populator"))
|
||||
(finally
|
||||
(isolation-manager/delete-isolation (:engine db) (:details db) "test-workspace-123" result))))))
|
||||
|
||||
(def driver-creates
|
||||
{:postgres "create table %s.%s (id int, stuff text)"
|
||||
:clickhouse "CREATE TABLE %s.%s (id Int32, stuff String) ENGINE = MergeTree() ORDER BY id"
|
||||
:snowflake "CREATE TABLE \"%s\".\"%s\" (id INT, stuff TEXT)"})
|
||||
|
||||
(def driver-drops
|
||||
{:postgres "DROP table %s.%s"
|
||||
:clickhouse "DROP table %s.%s"
|
||||
:snowflake "DROP table \"%s\".\"%s\" "})
|
||||
|
||||
(deftest e2e
|
||||
(mt/test-drivers #{:postgres :clickhouse :snowflake}
|
||||
(let [db (mt/db)
|
||||
driver (:engine db)
|
||||
details (merge (:details db))
|
||||
workspace-id (str (gensym "testing-workspace"))
|
||||
isolation-info (isolation-manager/create-isolation (:engine db)
|
||||
details
|
||||
workspace-id)]
|
||||
(letfn [(run-query! [user-info read|write sql]
|
||||
;; user-info from snowflake is nil intentionally. that does not create a new user
|
||||
(let [spec (sql-jdbc.conn/connection-details->spec (:engine db)
|
||||
(merge details user-info))]
|
||||
(try {:success true
|
||||
:results (with-open [conn (jdbc/get-connection spec)]
|
||||
(driver/set-database-used! driver conn db)
|
||||
(if (= read|write :read)
|
||||
(jdbc/query {:connection conn} sql)
|
||||
(jdbc/execute! {:connection conn} sql)))}
|
||||
(catch Exception e
|
||||
{:error true
|
||||
:message (ex-message e)
|
||||
#_#_:e e
|
||||
:query sql :user-info user-info}))))]
|
||||
(try
|
||||
(let [{:keys [query]} (qp.compile/compile (mt/mbql-query venues {:limit 10}))
|
||||
{:keys [populator isolation-type]} isolation-info
|
||||
isolation (isolation-info (if (= :schema isolation-type) :schema-name :database-name))
|
||||
temp-table (str (gensym "some_table"))]
|
||||
(testing "populator can query original data"
|
||||
(is (=? {:success true} (run-query! populator :read query))))
|
||||
(testing "populator can populate new schema"
|
||||
(is (=? {:success true} (run-query! populator :write
|
||||
(format (driver-creates driver) isolation temp-table)))))
|
||||
(testing "populator can delete table in new schema"
|
||||
(is (=? {:success true} (run-query! populator :write (format (driver-drops driver) isolation temp-table)))))
|
||||
(testing "populator can recreate dropped table"
|
||||
(is (=? {:success true} (run-query! populator :write (format (driver-creates (:engine db)) isolation temp-table))))))
|
||||
(finally
|
||||
(isolation-manager/delete-isolation (:engine db) details workspace-id
|
||||
isolation-info)))))))
|
||||
|
||||
(deftest evaluator-test
|
||||
(let [steps [:overall
|
||||
{}
|
||||
[:subgoal1 {} "action1" "action2"]
|
||||
[:subgoal2 {}
|
||||
"action3"
|
||||
[:subsubgoal {} "action4"]]]]
|
||||
(testing "general evaluation works"
|
||||
(let [result (#'isolation-manager/evaluate-steps steps (fn [x] [:success x]))]
|
||||
(is (= [[[:tree :overall]
|
||||
[:tree :subgoal1]
|
||||
[:success "action1"]
|
||||
[:success "action2"]
|
||||
[:tree :subgoal2]
|
||||
[:success "action3"]
|
||||
[:tree :subsubgoal]
|
||||
[:success "action4"]]
|
||||
:running] result ))))
|
||||
(testing "errors prevent more work"
|
||||
(let [result (#'isolation-manager/evaluate-steps steps (fn [x]
|
||||
(if (= x "action2")
|
||||
[:error "action2 is failed"]
|
||||
[:success x])))]
|
||||
(is (= [[[:tree :overall]
|
||||
[:tree :subgoal1]
|
||||
[:success "action1"]
|
||||
[:error "action2 is failed"]
|
||||
[:skipping-tree :subgoal2]]
|
||||
:error]
|
||||
result)))))
|
||||
(testing "errors can be marked recoverable"
|
||||
(let [steps [:cleanup {:error-strategy ::isolation-manager/continue-on-error}
|
||||
[:remove-privileges {} "revoke privileges"]
|
||||
[:remove-schema {} "drop schema"]
|
||||
[:remove-user {} "drop user"]]
|
||||
result (#'isolation-manager/evaluate-steps steps
|
||||
(fn [x]
|
||||
[:error x "failed"]))]
|
||||
(is (= [[[:tree :cleanup]
|
||||
[:tree :remove-privileges]
|
||||
[:error "revoke privileges" "failed"]
|
||||
[:tree :remove-schema]
|
||||
[:error "drop schema" "failed"]
|
||||
[:tree :remove-user]
|
||||
[:error "drop user" "failed"]]
|
||||
:running] result))))
|
||||
(testing "can error one subtree and then continue"
|
||||
(let [steps [:overall-with-continue
|
||||
{:error-strategy ::isolation-manager/continue-on-error}
|
||||
[:subgoal1-with-fail {:error-strategy ::isolation-manager/fail} "action1" "action2"]
|
||||
[:subgoal2 {}
|
||||
"action3"
|
||||
[:subsubgoal {} "action4"]]]
|
||||
results (#'isolation-manager/evaluate-steps steps
|
||||
(fn [x]
|
||||
(if (= x "action1")
|
||||
[:error x]
|
||||
[:success x])))]
|
||||
(is (= [[[:tree :overall-with-continue]
|
||||
[:tree :subgoal1-with-fail] [:error "action1"] [:skipping-step "action2"]
|
||||
[:tree :subgoal2] [:success "action3"]
|
||||
[:tree :subsubgoal] [:success "action4"]]
|
||||
:running]
|
||||
results))))
|
||||
(testing "can error one subtree and then continue"
|
||||
(let [steps [:overall-with-continue
|
||||
{:error-strategy ::isolation-manager/continue-on-error}
|
||||
[:subgoal1-with-fail {:error-strategy ::isolation-manager/fail}
|
||||
"action1" "action2"
|
||||
[:subtree-should-be-skipped {} "not-important1" "not-important2"]]
|
||||
[:subgoal2 {}
|
||||
"action3"
|
||||
[:subsubgoal {} "action4"]]]
|
||||
results (#'isolation-manager/evaluate-steps steps
|
||||
(fn [x]
|
||||
(if (= x "action1")
|
||||
[:error x]
|
||||
[:success x])))]
|
||||
(is (= [[[:tree :overall-with-continue]
|
||||
[:tree :subgoal1-with-fail]
|
||||
[:error "action1"] [:skipping-step "action2"]
|
||||
;; tree maintains the failure strategy
|
||||
[:skipping-tree :subtree-should-be-skipped]
|
||||
;; but popping back out continues according to main strategy
|
||||
[:tree :subgoal2] [:success "action3"]
|
||||
[:tree :subsubgoal] [:success "action4"]]
|
||||
:running]
|
||||
results))))
|
||||
(testing "rules can be opaque"
|
||||
(let [steps [:compute {}
|
||||
[+ 1]
|
||||
[+ 1]
|
||||
[:multiply {}
|
||||
[* 10] [* 5]]
|
||||
[:divide {:error-strategy ::isolation-manager/continue-on-error}
|
||||
[/ 2] [/ 0]]
|
||||
[:subtract
|
||||
{}
|
||||
[- 10]]]]
|
||||
(let [result (atom 0)
|
||||
results (#'isolation-manager/evaluate-steps steps (fn [[op x]]
|
||||
(try
|
||||
[:success (swap! result op x)]
|
||||
(catch Exception _e
|
||||
[:error :failed]))))]
|
||||
(is (=? [[[:tree :compute]
|
||||
[:success 1]
|
||||
[:success 2]
|
||||
[:tree :multiply]
|
||||
[:success 20]
|
||||
[:success 100]
|
||||
[:tree :divide]
|
||||
[:success 50]
|
||||
[:error :failed]
|
||||
[:tree :subtract]
|
||||
[:success 40]]
|
||||
:running]
|
||||
results)
|
||||
(= 50 @result))))))
|
||||
|
||||
|
||||
(comment
|
||||
(run-tests)
|
||||
(mt/set-test-drivers! #{:postgres :clickhouse :snowflake})
|
||||
(mt/set-test-drivers! #{:snowflake})
|
||||
(mt/set-test-drivers! #{:postgres})
|
||||
(mt/set-test-drivers! #{:clickhouse})
|
||||
(do (run-test e2e)
|
||||
(run-test create-isolation-test))
|
||||
|
||||
{:isolation-type :schema
|
||||
:schema-name "mb__isolation_7748c_test_workspace_123"
|
||||
:populator {:user "mb_iso_7748c_test_workspace_123_populator"
|
||||
:password "d632f61c-398a-467d-856c-d8411665bd0f"}}
|
||||
(mt/test-drivers #{:postgres}
|
||||
(let [db (mt/db)]
|
||||
(isolation-manager/delete-isolation (:engine db) (:details db) "test-workspace-123")
|
||||
(tap> (isolation-manager/create-isolation (:engine db) (:details db) "test-workspace-123"))))
|
||||
(mt/test-drivers #{:postgres}
|
||||
(let [db (mt/db)
|
||||
engine (:engine db)
|
||||
connection-details (:details db)
|
||||
|
||||
;; Simple API call
|
||||
result (isolation-manager/create-isolation engine connection-details "demo-workspace")]
|
||||
(try
|
||||
(tap> result)
|
||||
(def result result)
|
||||
(finally
|
||||
(isolation-manager/delete-isolation engine connection-details "demo-workspace" result)
|
||||
(println "Cleaned up demo")))))
|
||||
)
|
||||
@@ -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 } from "./tags";
|
||||
|
||||
export const documentApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getDocument: builder.query<Document, { id: DocumentId }>({
|
||||
query: ({ id }) => ({
|
||||
method: "GET",
|
||||
url: `/api/ee/document/${id}`,
|
||||
}),
|
||||
providesTags: (result, error, { id }) =>
|
||||
!error ? [idTag("document", id)] : [],
|
||||
}),
|
||||
createDocument: builder.mutation<Document, CreateDocumentRequest>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/document",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, error) => (error ? [] : []), // TODO: invalidate parent collection?
|
||||
}),
|
||||
updateDocument: builder.mutation<
|
||||
Document,
|
||||
Pick<Document, "id" | "name"> & {
|
||||
document?: DocumentContent;
|
||||
cards?: Record<number, Card>;
|
||||
}
|
||||
>({
|
||||
query: (document) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/document/${document.id}`,
|
||||
body: document,
|
||||
}),
|
||||
invalidatesTags: (_, error, { id }) =>
|
||||
!error ? [idTag("document", id)] : [],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetDocumentQuery,
|
||||
useCreateDocumentMutation,
|
||||
useUpdateDocumentMutation,
|
||||
} = documentApi;
|
||||
@@ -3,11 +3,14 @@ export * from "./ai-sql-generation";
|
||||
export * from "./audit-info";
|
||||
export * from "./api";
|
||||
export * from "./billing-info";
|
||||
export * from "./metabot";
|
||||
export * from "./content-translation";
|
||||
export * from "./gdrive";
|
||||
export * from "./db-routing";
|
||||
export * from "./gdrive";
|
||||
export * from "./metabot";
|
||||
export * from "./document";
|
||||
export * from "./saml";
|
||||
export * from "./scim";
|
||||
export * from "./tags";
|
||||
export * from "./transform";
|
||||
export * from "./upload-management";
|
||||
export * from "./workspace";
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { TagDescription } from "@reduxjs/toolkit/query";
|
||||
|
||||
import { TAG_TYPES } from "metabase/api/tags";
|
||||
import type { Transform } from "metabase-types/api";
|
||||
|
||||
export const ENTERPRISE_TAG_TYPES = [
|
||||
...TAG_TYPES,
|
||||
"scim",
|
||||
"metabot",
|
||||
"metabot-entities-list",
|
||||
"metabot-prompt-suggestions",
|
||||
"gsheets-status",
|
||||
"transform",
|
||||
"document",
|
||||
"workspace",
|
||||
] as const;
|
||||
|
||||
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];
|
||||
@@ -35,3 +42,24 @@ export function invalidateTags(
|
||||
): TagDescription<EnterpriseTagType>[] {
|
||||
return !error ? tags : [];
|
||||
}
|
||||
|
||||
export function provideTransformTags(
|
||||
transform: Transform,
|
||||
): TagDescription<EnterpriseTagType>[] {
|
||||
return [idTag("transform", transform.id)];
|
||||
}
|
||||
|
||||
export function provideTransformListTags(
|
||||
transforms: Transform[],
|
||||
): TagDescription<EnterpriseTagType>[] {
|
||||
return [listTag("transform"), ...transforms.flatMap(provideTransformTags)];
|
||||
}
|
||||
|
||||
export function provideWorkspaceTags(
|
||||
workspaces: Array<{ id: number }>,
|
||||
): TagDescription<EnterpriseTagType>[] {
|
||||
return [
|
||||
listTag("workspace"),
|
||||
...workspaces.map(workspace => idTag("workspace", workspace.id)),
|
||||
];
|
||||
}
|
||||
|
||||
89
enterprise/frontend/src/metabase-enterprise/api/transform.ts
Normal file
89
enterprise/frontend/src/metabase-enterprise/api/transform.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
CreateTransformRequest,
|
||||
Transform,
|
||||
TransformId,
|
||||
UpdateTransformRequest,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
import {
|
||||
idTag,
|
||||
invalidateTags,
|
||||
listTag,
|
||||
provideTransformListTags,
|
||||
provideTransformTags,
|
||||
} from "./tags";
|
||||
|
||||
export const transformApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
listTransforms: builder.query<Transform[], void>({
|
||||
query: (params) => ({
|
||||
method: "GET",
|
||||
url: "/api/ee/transform",
|
||||
params,
|
||||
}),
|
||||
providesTags: (transforms = []) => provideTransformListTags(transforms),
|
||||
}),
|
||||
getTransform: builder.query<Transform, TransformId>({
|
||||
query: (id) => ({
|
||||
method: "GET",
|
||||
url: `/api/ee/transform/${id}`,
|
||||
}),
|
||||
providesTags: (transform) =>
|
||||
transform ? provideTransformTags(transform) : [],
|
||||
}),
|
||||
executeTransform: builder.mutation<Transform, TransformId>({
|
||||
query: (id) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/transform/${id}/execute`,
|
||||
}),
|
||||
invalidatesTags: (_, error, id) =>
|
||||
invalidateTags(error, [idTag("transform", id), listTag("table")]),
|
||||
}),
|
||||
createTransform: builder.mutation<Transform, CreateTransformRequest>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/transform",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, error) =>
|
||||
invalidateTags(error, [listTag("transform")]),
|
||||
}),
|
||||
updateTransform: builder.mutation<Transform, UpdateTransformRequest>({
|
||||
query: ({ id, ...body }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/transform/${id}`,
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, error, { id }) =>
|
||||
invalidateTags(error, [listTag("transform"), idTag("transform", id)]),
|
||||
}),
|
||||
deleteTransform: builder.mutation<Transform, TransformId>({
|
||||
query: (id) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/transform/${id}`,
|
||||
}),
|
||||
invalidatesTags: (_, error) =>
|
||||
invalidateTags(error, [listTag("transform")]),
|
||||
}),
|
||||
deleteTransformTarget: builder.mutation<Transform, TransformId>({
|
||||
query: (id) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/transform/${id}/table`,
|
||||
}),
|
||||
invalidatesTags: (_, error, id) =>
|
||||
invalidateTags(error, [idTag("transform", id), listTag("table")]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useListTransformsQuery,
|
||||
useLazyListTransformsQuery,
|
||||
useGetTransformQuery,
|
||||
useExecuteTransformMutation,
|
||||
useCreateTransformMutation,
|
||||
useUpdateTransformMutation,
|
||||
useDeleteTransformMutation,
|
||||
useDeleteTransformTargetMutation,
|
||||
} = transformApi;
|
||||
299
enterprise/frontend/src/metabase-enterprise/api/workspace.ts
Normal file
299
enterprise/frontend/src/metabase-enterprise/api/workspace.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { CollectionId } from "metabase-types/api";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
import { idTag, listTag, provideWorkspaceTags } from "./tags";
|
||||
|
||||
export interface Plan {
|
||||
title: string;
|
||||
description?: string;
|
||||
content: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Transform {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source: any;
|
||||
target: any;
|
||||
config?: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DataWarehouse {
|
||||
id: string;
|
||||
data_warehouse_id?: number;
|
||||
name: string;
|
||||
type: "read-only" | "read-write";
|
||||
credentials: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
table: string;
|
||||
permission: "read" | "write";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: number;
|
||||
collection_id: CollectionId;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
plans?: Plan[];
|
||||
activity_logs?: any[];
|
||||
transforms?: Transform[];
|
||||
documents?: any[];
|
||||
users?: WorkspaceUser[];
|
||||
data_warehouses?: DataWarehouse[];
|
||||
permissions?: Permission[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string;
|
||||
collection_id: CollectionId;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWorkspaceRequest {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ListWorkspacesResponse {
|
||||
data?: Workspace[];
|
||||
}
|
||||
|
||||
export const workspaceApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
listWorkspaces: builder.query<Workspace[], void>({
|
||||
query: () => ({
|
||||
method: "GET",
|
||||
url: `/api/ee/workspace`,
|
||||
}),
|
||||
providesTags: (workspaces = []) => provideWorkspaceTags(workspaces),
|
||||
}),
|
||||
getWorkspace: builder.query<Workspace, number>({
|
||||
query: (id) => ({
|
||||
method: "GET",
|
||||
url: `/api/ee/workspace/${id}`,
|
||||
}),
|
||||
providesTags: (workspace) =>
|
||||
workspace ? [idTag("workspace", workspace.id)] : [],
|
||||
}),
|
||||
createWorkspace: builder.mutation<Workspace, CreateWorkspaceRequest>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/workspace`,
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: [listTag("workspace")],
|
||||
}),
|
||||
updateWorkspace: builder.mutation<Workspace, UpdateWorkspaceRequest>({
|
||||
query: ({ id, ...body }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/workspace/${id}`,
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, __, { id }) => [
|
||||
idTag("workspace", id),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
deleteWorkspace: builder.mutation<void, number>({
|
||||
query: (id) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/workspace/${id}`,
|
||||
}),
|
||||
invalidatesTags: (_, __, id) => [
|
||||
idTag("workspace", id),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
updatePlan: builder.mutation<
|
||||
Workspace,
|
||||
{ workspaceId: number; planIndex: number; title: string; description?: string; content: any }
|
||||
>({
|
||||
query: ({ workspaceId, planIndex, ...body }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/workspace/${workspaceId}/plan/${planIndex}`,
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
deletePlan: builder.mutation<
|
||||
void,
|
||||
{ workspaceId: number; planIndex: number }
|
||||
>({
|
||||
query: ({ workspaceId, planIndex }) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/workspace/${workspaceId}/plan/${planIndex}`,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
// Transform endpoints (workspace-specific)
|
||||
updateWorkspaceTransform: builder.mutation<
|
||||
Workspace,
|
||||
{ workspaceId: number; transformIndex: number; name: string; description: string; source: any; target: any; config?: any }
|
||||
>({
|
||||
query: ({ workspaceId, transformIndex, ...body }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/workspace/${workspaceId}/transform/${transformIndex}`,
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
deleteWorkspaceTransform: builder.mutation<
|
||||
void,
|
||||
{ workspaceId: number; transformIndex: number }
|
||||
>({
|
||||
query: ({ workspaceId, transformIndex }) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/workspace/${workspaceId}/transform/${transformIndex}`,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
// User endpoints
|
||||
updateUser: builder.mutation<
|
||||
Workspace,
|
||||
{ workspaceId: number; userIndex: number; user_id: number; name: string; email: string; type: string }
|
||||
>({
|
||||
query: ({ workspaceId, userIndex, ...body }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/workspace/${workspaceId}/user/${userIndex}`,
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
deleteUser: builder.mutation<
|
||||
void,
|
||||
{ workspaceId: number; userIndex: number }
|
||||
>({
|
||||
query: ({ workspaceId, userIndex }) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/workspace/${workspaceId}/user/${userIndex}`,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
// Data warehouse endpoints
|
||||
updateDataWarehouse: builder.mutation<
|
||||
Workspace,
|
||||
{ workspaceId: number; dataWarehouseIndex: number; data_warehouses_id: number; name: string; type: "read-only" | "read-write"; credentials: any }
|
||||
>({
|
||||
query: ({ workspaceId, dataWarehouseIndex, ...body }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/workspace/${workspaceId}/data_warehouse/${dataWarehouseIndex}`,
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
deleteDataWarehouse: builder.mutation<
|
||||
void,
|
||||
{ workspaceId: number; dataWarehouseIndex: number }
|
||||
>({
|
||||
query: ({ workspaceId, dataWarehouseIndex }) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/workspace/${workspaceId}/data_warehouse/${dataWarehouseIndex}`,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
// Permission endpoints
|
||||
updatePermission: builder.mutation<
|
||||
Workspace,
|
||||
{ workspaceId: number; permissionIndex: number; table: string; permission: "read" | "write" }
|
||||
>({
|
||||
query: ({ workspaceId, permissionIndex, ...body }) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/workspace/${workspaceId}/permission/${permissionIndex}`,
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
deletePermission: builder.mutation<
|
||||
void,
|
||||
{ workspaceId: number; permissionIndex: number }
|
||||
>({
|
||||
query: ({ workspaceId, permissionIndex }) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/workspace/${workspaceId}/permission/${permissionIndex}`,
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
// Link existing transform to workspace
|
||||
linkTransformToWorkspace: builder.mutation<
|
||||
Workspace,
|
||||
{ workspaceId: number; transformId: number }
|
||||
>({
|
||||
query: ({ workspaceId, transformId }) => ({
|
||||
method: "POST",
|
||||
url: `/api/ee/workspace/${workspaceId}/transform/link`,
|
||||
body: { transform_id: transformId },
|
||||
}),
|
||||
invalidatesTags: (_, __, { workspaceId }) => [
|
||||
idTag("workspace", workspaceId),
|
||||
listTag("workspace"),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useListWorkspacesQuery,
|
||||
useGetWorkspaceQuery,
|
||||
useCreateWorkspaceMutation,
|
||||
useUpdateWorkspaceMutation,
|
||||
useDeleteWorkspaceMutation,
|
||||
useUpdatePlanMutation,
|
||||
useDeletePlanMutation,
|
||||
useUpdateWorkspaceTransformMutation,
|
||||
useDeleteWorkspaceTransformMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUserMutation,
|
||||
useUpdateDataWarehouseMutation,
|
||||
useDeleteDataWarehouseMutation,
|
||||
useUpdatePermissionMutation,
|
||||
useDeletePermissionMutation,
|
||||
useLinkTransformToWorkspaceMutation,
|
||||
} = workspaceApi;
|
||||
@@ -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,135 @@
|
||||
.documentPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--nav-sidebar-height, 60px));
|
||||
background-color: var(--mb-color-bg-white);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0;
|
||||
background-color: var(--mb-color-bg-white);
|
||||
gap: 1rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--mb-color-text-dark);
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.titleInput::placeholder {
|
||||
color: var(--mb-color-text-light);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.titleInput:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.documentContainer {
|
||||
width: 100%;
|
||||
padding: 0 2rem 4rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body:has(.documentContainer) {
|
||||
visibility: hidden;
|
||||
|
||||
/* side nav */
|
||||
aside {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.documentContainer {
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
|
||||
.header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.node-cardEmbed) {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
[data-hide-on-print] {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--mb-color-bg-light);
|
||||
border-left: 1px solid var(--mb-color-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cardEmbed {
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cardEmbed:hover {
|
||||
background-color: var(--mb-color-bg-medium);
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
animation: highlight 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
0% {
|
||||
background-color: var(--mb-color-brand-light);
|
||||
box-shadow: 0 0 0 4px var(--mb-color-brand-light);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.metadataItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--mb-color-text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Route } from "react-router";
|
||||
import { push, replace } from "react-router-redux";
|
||||
import { usePrevious } from "react-use";
|
||||
import useBeforeUnload from "react-use/lib/useBeforeUnload";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { skipToken } from "metabase/api";
|
||||
import { canonicalCollectionId } from "metabase/collections/utils";
|
||||
import DateTime from "metabase/common/components/DateTime";
|
||||
import { LeaveRouteConfirmModal } from "metabase/common/components/LeaveConfirmModal";
|
||||
import { CollectionPickerModal } from "metabase/common/components/Pickers/CollectionPicker";
|
||||
import { useToast } from "metabase/common/hooks";
|
||||
import { useCallbackEffect } from "metabase/common/hooks/use-callback-effect";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Icon,
|
||||
Loader,
|
||||
Menu,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useCreateDocumentMutation,
|
||||
useGetDocumentQuery,
|
||||
useUpdateDocumentMutation,
|
||||
} from "metabase-enterprise/api";
|
||||
import type { Card, RegularCollectionId } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
clearDraftCards,
|
||||
closeSidebar,
|
||||
openVizSettingsSidebar,
|
||||
resetDocuments,
|
||||
setCurrentDocument,
|
||||
} from "../documents.slice";
|
||||
import { useDocumentState, useRegisterDocumentMetabotContext } from "../hooks";
|
||||
import { useDocumentsSelector } from "../redux-utils";
|
||||
import {
|
||||
getDraftCards,
|
||||
getSelectedEmbedIndex,
|
||||
getSelectedQuestionId,
|
||||
} from "../selectors";
|
||||
|
||||
import styles from "./DocumentPage.module.css";
|
||||
import { Editor } from "./Editor";
|
||||
import { EmbedQuestionSettingsSidebar } from "./EmbedQuestionSettingsSidebar";
|
||||
import { downloadFile, getDownloadableMarkdown } from "./exports";
|
||||
|
||||
export const DocumentPage = ({
|
||||
params: { id: documentId },
|
||||
location,
|
||||
route,
|
||||
}: {
|
||||
params: { id?: number | "new" };
|
||||
location?: { query?: { version?: string } };
|
||||
route: Route;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const selectedQuestionId = useDocumentsSelector(getSelectedQuestionId);
|
||||
const selectedEmbedIndex = useDocumentsSelector(getSelectedEmbedIndex);
|
||||
const draftCards = useDocumentsSelector(getDraftCards);
|
||||
const [editorInstance, setEditorInstance] = useState<any>(null);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [currentContent, setCurrentContent] = useState<string>("");
|
||||
const [createDocument] = useCreateDocumentMutation();
|
||||
const [updateDocument] = useUpdateDocumentMutation();
|
||||
const [
|
||||
isShowingCollectionPicker,
|
||||
{ open: showCollectionPicker, close: hideCollectionPicker },
|
||||
] = useDisclosure(false);
|
||||
const [sendToast] = useToast();
|
||||
const selectedVersion = location?.query?.version
|
||||
? Number(location.query.version)
|
||||
: undefined;
|
||||
const previousDocumentId = usePrevious(documentId);
|
||||
const previousVersion = usePrevious(selectedVersion);
|
||||
const [isNavigationScheduled, scheduleNavigation] = useCallbackEffect();
|
||||
const isNewDocument = documentId === "new";
|
||||
|
||||
const { data: documentData, isLoading: isDocumentLoading } =
|
||||
useGetDocumentQuery(
|
||||
documentId && !isNewDocument
|
||||
? { id: documentId, version: selectedVersion }
|
||||
: skipToken,
|
||||
);
|
||||
|
||||
const canWrite = isNewDocument ? true : documentData?.can_write;
|
||||
|
||||
const {
|
||||
documentTitle,
|
||||
setDocumentTitle,
|
||||
documentContent,
|
||||
setDocumentContent,
|
||||
cardEmbeds,
|
||||
updateCardEmbeds,
|
||||
} = useDocumentState(documentData);
|
||||
|
||||
// Reset current content when document changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
documentId !== previousDocumentId ||
|
||||
selectedVersion !== previousVersion
|
||||
) {
|
||||
setCurrentContent(documentContent || "");
|
||||
}
|
||||
}, [
|
||||
documentId,
|
||||
previousDocumentId,
|
||||
selectedVersion,
|
||||
previousVersion,
|
||||
documentContent,
|
||||
]);
|
||||
|
||||
useRegisterDocumentMetabotContext();
|
||||
useBeforeUnload(() => {
|
||||
// warn if you try to navigate away with unsaved changes
|
||||
return hasUnsavedChanges();
|
||||
});
|
||||
|
||||
// Update current content when document content changes
|
||||
useEffect(() => {
|
||||
setCurrentContent(documentContent || "");
|
||||
}, [documentContent]);
|
||||
|
||||
// Reset state when creating a new document
|
||||
useEffect(() => {
|
||||
if (isNewDocument && previousDocumentId !== "new") {
|
||||
setDocumentTitle("");
|
||||
setDocumentContent("");
|
||||
setCurrentContent("");
|
||||
// Clear the Redux state to ensure no card embeds from previous document
|
||||
dispatch(resetDocuments());
|
||||
}
|
||||
}, [
|
||||
documentId,
|
||||
setDocumentTitle,
|
||||
setDocumentContent,
|
||||
previousDocumentId,
|
||||
dispatch,
|
||||
isNewDocument,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set current document when document loads (includes collection_id and all other data)
|
||||
if (documentData && !isNewDocument) {
|
||||
dispatch(setCurrentDocument(documentData));
|
||||
} else if (isNewDocument) {
|
||||
dispatch(setCurrentDocument(null));
|
||||
}
|
||||
}, [documentData, documentId, dispatch, isNewDocument]);
|
||||
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
const currentTitle = documentTitle.trim();
|
||||
const originalTitle = documentData?.name || "";
|
||||
const titleChanged = currentTitle !== originalTitle;
|
||||
|
||||
// Check if there are any draft cards
|
||||
const hasDraftCards = Object.keys(draftCards).length > 0;
|
||||
|
||||
// For new documents, show Save if there's title or content exists or draft cards
|
||||
if (isNewDocument) {
|
||||
const emptyDocAst = JSON.stringify({ type: "doc", content: [] });
|
||||
const hasContent =
|
||||
currentContent !== emptyDocAst && currentContent !== "";
|
||||
return currentTitle.length > 0 || hasContent || hasDraftCards;
|
||||
}
|
||||
|
||||
// For existing documents, compare current content with document content
|
||||
// documentContent is already stringified from the hook
|
||||
const contentChanged = currentContent !== (documentContent ?? "");
|
||||
|
||||
return titleChanged || contentChanged || hasDraftCards;
|
||||
}, [
|
||||
documentTitle,
|
||||
isNewDocument,
|
||||
documentData,
|
||||
currentContent,
|
||||
documentContent,
|
||||
draftCards,
|
||||
]);
|
||||
|
||||
const showSaveButton = hasUnsavedChanges() && canWrite;
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (collectionId: RegularCollectionId | null = null) => {
|
||||
if (!editorInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cardsToSave: Record<number, Card> = {};
|
||||
const processedCardIds = new Set<number>();
|
||||
|
||||
editorInstance.state.doc.descendants((node: any) => {
|
||||
if (node.type.name === "cardEmbed") {
|
||||
const cardId = node.attrs.id;
|
||||
if (!processedCardIds.has(cardId)) {
|
||||
processedCardIds.add(cardId);
|
||||
|
||||
if (cardId < 0 && draftCards[cardId]) {
|
||||
cardsToSave[cardId] = draftCards[cardId];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const documentAst = currentContent ? JSON.parse(currentContent) : null;
|
||||
|
||||
const newDocumentData: any = {
|
||||
name: documentTitle,
|
||||
document: documentAst,
|
||||
cards: Object.keys(cardsToSave).length > 0 ? cardsToSave : undefined,
|
||||
};
|
||||
|
||||
const result = await (documentData?.id
|
||||
? updateDocument({ ...newDocumentData, id: documentData.id }).then(
|
||||
(response) => {
|
||||
if (response.data) {
|
||||
scheduleNavigation(() => {
|
||||
dispatch(push(`/document/${response.data.id}`));
|
||||
});
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
)
|
||||
: createDocument({
|
||||
...newDocumentData,
|
||||
collection_id: collectionId || undefined,
|
||||
}).then((response) => {
|
||||
if (response.data) {
|
||||
scheduleNavigation(() => {
|
||||
dispatch(replace(`/document/${response.data.id}`));
|
||||
});
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
sendToast({
|
||||
message: documentData?.id ? t`Document saved` : t`Document created`,
|
||||
});
|
||||
dispatch(clearDraftCards());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save document:", error);
|
||||
sendToast({ message: t`Error saving document`, icon: "warning" });
|
||||
}
|
||||
},
|
||||
[
|
||||
editorInstance,
|
||||
documentTitle,
|
||||
currentContent,
|
||||
draftCards,
|
||||
documentData?.id,
|
||||
updateDocument,
|
||||
createDocument,
|
||||
scheduleNavigation,
|
||||
dispatch,
|
||||
sendToast,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Save shortcut: Cmd+S (Mac) or Ctrl+S (Windows/Linux)
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
|
||||
event.preventDefault();
|
||||
if (!hasUnsavedChanges() || !canWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
isNewDocument ? showCollectionPicker() : handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
hasUnsavedChanges,
|
||||
handleSave,
|
||||
isNewDocument,
|
||||
showCollectionPicker,
|
||||
canWrite,
|
||||
]);
|
||||
|
||||
const handleQuestionSelect = useCallback(
|
||||
(cardId: number | null, embedIndex?: number | null) => {
|
||||
if (
|
||||
cardId !== null &&
|
||||
embedIndex !== null &&
|
||||
embedIndex !== undefined &&
|
||||
embedIndex >= 0 &&
|
||||
selectedEmbedIndex !== null
|
||||
) {
|
||||
// Only update the selected embed index if the sidebar is already open
|
||||
dispatch(openVizSettingsSidebar({ embedIndex }));
|
||||
}
|
||||
},
|
||||
[dispatch, selectedEmbedIndex],
|
||||
);
|
||||
|
||||
const handleDownloadMarkdown = useCallback(() => {
|
||||
if (!editorInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
const rawMarkdown = editorInstance.storage.markdown.getMarkdown();
|
||||
const processedMarkdown = await getDownloadableMarkdown(
|
||||
rawMarkdown,
|
||||
cardEmbeds,
|
||||
);
|
||||
|
||||
downloadFile(processedMarkdown);
|
||||
} catch (error) {
|
||||
console.error("Failed to download markdown:", error);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
})();
|
||||
}, [cardEmbeds, editorInstance]);
|
||||
|
||||
const handlePrintDocument = useCallback(() => {
|
||||
window.print();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box className={styles.documentPage}>
|
||||
<Box className={styles.contentArea}>
|
||||
<Box className={styles.mainContent}>
|
||||
<Box className={styles.documentContainer}>
|
||||
<Box className={styles.header} mt="xl" pt="xl">
|
||||
{/* <Flex gap="sm" align="center" style={{ width: "100%" }}> */}
|
||||
<Flex direction="column">
|
||||
<TextInput
|
||||
autoFocus={isNewDocument}
|
||||
value={documentTitle}
|
||||
onChange={(event) =>
|
||||
setDocumentTitle(event.currentTarget.value)
|
||||
}
|
||||
flex={1}
|
||||
placeholder={t`New document`}
|
||||
readOnly={!canWrite}
|
||||
styles={{
|
||||
input: {
|
||||
border: "none",
|
||||
padding: 0,
|
||||
fontSize: "2rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{documentData && (
|
||||
<Flex gap="md">
|
||||
<Text className={styles.metadataItem}>
|
||||
<Icon name="person" />
|
||||
{documentData.creator.common_name}{" "}
|
||||
</Text>
|
||||
<Text className={styles.metadataItem}>
|
||||
<Icon name="clock" />
|
||||
<DateTime value={documentData.updated_at} unit="day" />
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex gap="md" align="center">
|
||||
{showSaveButton && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
isNewDocument ? showCollectionPicker() : handleSave();
|
||||
}}
|
||||
variant="filled"
|
||||
data-hide-on-print
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
)}
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="md"
|
||||
aria-label={t`More options`}
|
||||
data-hide-on-print
|
||||
>
|
||||
<Icon name="ellipsis" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
isDownloading ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<Icon name="download" />
|
||||
)
|
||||
}
|
||||
onClick={handleDownloadMarkdown}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? t`Downloading...` : t`Download`}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="document" />}
|
||||
onClick={handlePrintDocument}
|
||||
>
|
||||
{t`Print Document`}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Editor
|
||||
onEditorReady={setEditorInstance}
|
||||
onCardEmbedsChange={updateCardEmbeds}
|
||||
onQuestionSelect={handleQuestionSelect}
|
||||
content={documentContent || ""}
|
||||
onChange={setCurrentContent}
|
||||
editable={canWrite}
|
||||
isLoading={isDocumentLoading}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedQuestionId && selectedEmbedIndex !== null && (
|
||||
<Box className={styles.sidebar}>
|
||||
<EmbedQuestionSettingsSidebar
|
||||
cardId={selectedQuestionId}
|
||||
onClose={() => dispatch(closeSidebar())}
|
||||
editorInstance={editorInstance}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isShowingCollectionPicker && (
|
||||
<CollectionPickerModal
|
||||
title={t`Where should we save this document?`}
|
||||
onClose={hideCollectionPicker}
|
||||
value={{ id: "root", model: "collection" }}
|
||||
options={{
|
||||
showPersonalCollections: true,
|
||||
showRootCollection: true,
|
||||
}}
|
||||
onChange={(collection) => {
|
||||
handleSave(canonicalCollectionId(collection.id));
|
||||
hideCollectionPicker();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LeaveRouteConfirmModal
|
||||
isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
|
||||
route={route}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { cardApi, useListRecentsQuery } from "metabase/api";
|
||||
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
|
||||
import Search from "metabase/entities/search";
|
||||
import { getName } from "metabase/lib/name";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { RecentsListContent } from "metabase/nav/components/search/RecentsList/RecentsListContent";
|
||||
import { SearchResults } from "metabase/nav/components/search/SearchResults";
|
||||
import {
|
||||
ResultNameSection,
|
||||
ResultTitle,
|
||||
SearchResultContainer,
|
||||
} from "metabase/search/components/SearchResult";
|
||||
import { IconWrapper } from "metabase/search/components/SearchResult/components/ItemIcon.styled";
|
||||
import { Box, Group, Icon, Popover, Text } from "metabase/ui";
|
||||
import type {
|
||||
RecentItem,
|
||||
SearchModel,
|
||||
UnrestrictedLinkEntity,
|
||||
} from "metabase-types/api";
|
||||
|
||||
const COMMAND_MODELS_TO_SEARCH: SearchModel[] = ["card", "dataset"];
|
||||
|
||||
interface ExtraItemProps {
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const SearchResultsFooter = ({ isSelected, onClick }: ExtraItemProps) => (
|
||||
<Box mx="sm" mb="sm" mt={-8}>
|
||||
<SearchResultContainer
|
||||
align="center"
|
||||
isActive
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
>
|
||||
<IconWrapper active archived={false} type="search">
|
||||
<Icon name="search" />
|
||||
</IconWrapper>
|
||||
|
||||
<ResultNameSection justify="center" gap="xs">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<ResultTitle
|
||||
role="heading"
|
||||
data-testid="search-result-item-name"
|
||||
truncate
|
||||
>
|
||||
{t`Browse all`}
|
||||
</ResultTitle>
|
||||
</Group>
|
||||
</ResultNameSection>
|
||||
</SearchResultContainer>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const MetabotMenuItem = ({ isSelected, onClick }: ExtraItemProps) => (
|
||||
<Box>
|
||||
<SearchResultContainer isActive isSelected={isSelected} onClick={onClick}>
|
||||
<IconWrapper active archived={false} type="search">
|
||||
<Icon name="metabot" />
|
||||
</IconWrapper>
|
||||
|
||||
<ResultNameSection gap="xs">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<ResultTitle
|
||||
role="heading"
|
||||
data-testid="search-result-item-name"
|
||||
truncate
|
||||
>
|
||||
{t`Ask metabot`}
|
||||
</ResultTitle>
|
||||
</Group>
|
||||
<Text size="sm" c="text-medium">
|
||||
{t`It wants to help!`}
|
||||
</Text>
|
||||
</ResultNameSection>
|
||||
</SearchResultContainer>
|
||||
</Box>
|
||||
);
|
||||
|
||||
interface CommandPluginProps {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const CommandPlugin = ({ editor }: CommandPluginProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [modal, setModal] = useState<"question-picker" | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [commandRange, setCommandRange] = useState<{
|
||||
from: number;
|
||||
to: number;
|
||||
} | null>(null);
|
||||
const [anchorPos, setAnchorPos] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const virtualRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: recents = [], isLoading: isRecentsLoading } =
|
||||
useListRecentsQuery(undefined, { refetchOnMountOrArgChange: true });
|
||||
|
||||
const filteredRecents = recents
|
||||
.filter((item) =>
|
||||
COMMAND_MODELS_TO_SEARCH.includes(item.model as SearchModel),
|
||||
)
|
||||
.slice(0, 4);
|
||||
|
||||
const insertMetabotBlock = useCallback(async () => {
|
||||
if (!commandRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const insertPosition = commandRange.from;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(commandRange)
|
||||
.insertContentAt(insertPosition, {
|
||||
type: "metabot",
|
||||
attrs: {},
|
||||
})
|
||||
.setTextSelection(insertPosition + 1)
|
||||
.run();
|
||||
|
||||
setShowPopover(false);
|
||||
setCommandRange(null);
|
||||
}, [editor, commandRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateHandler = () => {
|
||||
const { $from } = editor.state.selection;
|
||||
if (commandRange && showPopover) {
|
||||
// Check if we're still in command mode
|
||||
const currentText = editor.state.doc.textBetween(
|
||||
commandRange.from,
|
||||
Math.min(editor.state.doc.content.size, $from.pos),
|
||||
"",
|
||||
);
|
||||
|
||||
if (currentText.startsWith("/")) {
|
||||
setQuery(currentText.slice(1));
|
||||
setCommandRange({
|
||||
from: commandRange.from,
|
||||
to: $from.pos,
|
||||
});
|
||||
} else {
|
||||
setShowPopover(false);
|
||||
setCommandRange(null);
|
||||
}
|
||||
}
|
||||
|
||||
const text = $from.nodeBefore?.text || "";
|
||||
// Check if we're typing after "/" and it's the start of the paragraph
|
||||
if (text.trimStart() === "/") {
|
||||
const from = $from.pos - 1;
|
||||
setCommandRange({ from, to: $from.pos });
|
||||
|
||||
// Get cursor position
|
||||
const coords = editor.view.coordsAtPos(from);
|
||||
setAnchorPos({ x: coords.left, y: coords.bottom });
|
||||
|
||||
setShowPopover(true);
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
const keydownHandler = (event: KeyboardEvent) => {
|
||||
if (!showPopover) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent cursor movement in editor for arrow keys
|
||||
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setShowPopover(false);
|
||||
setCommandRange(null);
|
||||
editor.commands.focus();
|
||||
}
|
||||
};
|
||||
|
||||
editor.on("update", updateHandler);
|
||||
editor.on("selectionUpdate", updateHandler);
|
||||
|
||||
const editorElement = editor.view.dom;
|
||||
editorElement.addEventListener("keydown", keydownHandler, true);
|
||||
|
||||
return () => {
|
||||
editor.off("update", updateHandler);
|
||||
editor.off("selectionUpdate", updateHandler);
|
||||
editorElement.removeEventListener("keydown", keydownHandler, true);
|
||||
};
|
||||
}, [editor, commandRange, showPopover]);
|
||||
|
||||
const handleSelect = async (item: UnrestrictedLinkEntity | null) => {
|
||||
if (!commandRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item === null) {
|
||||
setShowPopover(false);
|
||||
setCommandRange(null);
|
||||
editor.commands.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const wrappedItem = Search.wrapEntity(item, dispatch);
|
||||
const insertPosition = commandRange.from;
|
||||
|
||||
try {
|
||||
// Fetch the full card data using RTK Query
|
||||
const { data: originalCard } = await dispatch(
|
||||
cardApi.endpoints.getCard.initiate({ id: wrappedItem.id }),
|
||||
);
|
||||
|
||||
if (!originalCard) {
|
||||
throw new Error("Failed to fetch card data");
|
||||
}
|
||||
|
||||
// Simply insert the original card ID - backend will handle cloning when saving document
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(commandRange)
|
||||
.insertContentAt(insertPosition, {
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: originalCard.id,
|
||||
},
|
||||
})
|
||||
.setTextSelection(insertPosition + 1)
|
||||
.run();
|
||||
|
||||
setShowPopover(false);
|
||||
setCommandRange(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to create snapshot:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecentSelect = (item: RecentItem) => {
|
||||
handleSelect({
|
||||
...item,
|
||||
description: item.description ?? undefined,
|
||||
name: getName(item),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (virtualRef.current && anchorPos) {
|
||||
virtualRef.current.style.position = "fixed";
|
||||
virtualRef.current.style.left = `${anchorPos.x}px`;
|
||||
virtualRef.current.style.top = `${anchorPos.y}px`;
|
||||
virtualRef.current.style.width = "1px";
|
||||
virtualRef.current.style.height = "1px";
|
||||
}
|
||||
}, [anchorPos]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
opened={showPopover}
|
||||
position="bottom-start"
|
||||
width={320}
|
||||
shadow="md"
|
||||
withinPortal
|
||||
closeOnClickOutside={false}
|
||||
middlewares={{ flip: true, shift: true }}
|
||||
onClose={() => {
|
||||
setShowPopover(false);
|
||||
setCommandRange(null);
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div
|
||||
ref={virtualRef}
|
||||
style={{ position: "fixed", pointerEvents: "none" }}
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{query.length > 0 ? (
|
||||
<SearchResults
|
||||
searchText={query}
|
||||
limit={4}
|
||||
forceEntitySelect
|
||||
onEntitySelect={handleSelect}
|
||||
models={COMMAND_MODELS_TO_SEARCH}
|
||||
footerComponent={SearchResultsFooter}
|
||||
onFooterSelect={() => setModal("question-picker")}
|
||||
showFooterOnNoResults
|
||||
/>
|
||||
) : (
|
||||
<RecentsListContent
|
||||
isLoading={isRecentsLoading}
|
||||
results={filteredRecents}
|
||||
onClick={handleRecentSelect}
|
||||
headerChildren={[
|
||||
Object.assign(MetabotMenuItem, {
|
||||
onClick: insertMetabotBlock,
|
||||
}),
|
||||
]}
|
||||
footerChildren={[
|
||||
Object.assign(SearchResultsFooter, {
|
||||
onClick: () => setModal("question-picker"),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
{modal === "question-picker" && (
|
||||
<QuestionPickerModal
|
||||
onChange={async (item) => {
|
||||
await handleSelect(item);
|
||||
setModal(null);
|
||||
}}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
.editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
flex: 1;
|
||||
cursor: text;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editorContent :global(.ProseMirror) {
|
||||
padding: 1.5rem 0 2rem 0;
|
||||
min-height: 100%;
|
||||
outline: none;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
color: var(--mb-color-text-dark);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Apply typography styles only to elements NOT inside .editor-style-boundary */
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:where(:global(h1), :global(h2), :global(h3)):not(
|
||||
:global(.editor-style-boundary) *
|
||||
) {
|
||||
font-weight: 700;
|
||||
color: var(--mb-color-text-dark);
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(h1):not(:global(.editor-style-boundary) *) {
|
||||
font-size: 1.75rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(h2):not(:global(.editor-style-boundary) *) {
|
||||
font-size: 1.375rem;
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(h3):not(:global(.editor-style-boundary) *) {
|
||||
font-size: 1.125rem;
|
||||
margin: 1rem 0 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(p):not(:global(.editor-style-boundary) *) {
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:where(:global(ul), :global(ol)):not(:global(.editor-style-boundary) *) {
|
||||
margin: 0 0 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(ul):not(:global(.editor-style-boundary) *) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(ol):not(:global(.editor-style-boundary) *) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(li):not(:global(.editor-style-boundary) *) {
|
||||
margin: 0 0 0.25rem 0;
|
||||
line-height: 1.5;
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(ul ul):not(:global(.editor-style-boundary) *) {
|
||||
list-style-type: circle;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(ul ul ul):not(:global(.editor-style-boundary) *) {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:where(:global(li > ul), :global(li > ol)):not(
|
||||
:global(.editor-style-boundary) *
|
||||
) {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(li p):not(:global(.editor-style-boundary) *) {
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(code):not(:global(.editor-style-boundary) *) {
|
||||
background-color: var(--mb-base-color-orion-5);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
font-family: var(--mb-default-monospace-font-family, monospace);
|
||||
font-size: 0.875rem;
|
||||
color: var(--mb-color-text-dark);
|
||||
border: 1px solid var(--mb-base-color-orion-10);
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(pre):not(:global(.editor-style-boundary) *) {
|
||||
background-color: var(--mb-base-color-gray-10);
|
||||
color: var(--mb-color-text-dark);
|
||||
padding: 1rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
overflow-x: auto;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--mb-base-color-gray-20);
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(pre code):not(:global(.editor-style-boundary) *) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(blockquote):not(:global(.editor-style-boundary) *) {
|
||||
border-left: 3px solid var(--mb-base-color-orion-20);
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--mb-color-text-medium);
|
||||
font-style: italic;
|
||||
background-color: var(--mb-base-color-orion-5);
|
||||
padding: 1rem;
|
||||
padding-left: 1.25rem;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(hr):not(:global(.editor-style-boundary) *) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.editorContent :global(.ProseMirror:focus) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editorContent :global(p.is-empty:first-child:before) {
|
||||
color: var(--mb-color-text-light);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.img {
|
||||
max-width: 80%;
|
||||
display: block;
|
||||
margin: 1rem auto;
|
||||
|
||||
&:global(.ProseMirror-selectednode) {
|
||||
box-shadow: 0 0 0 1px var(--mb-color-brand);
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import {
|
||||
EditorContent,
|
||||
type Editor as TiptapEditor,
|
||||
useEditor,
|
||||
} from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import cx from "classnames";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { DND_IGNORE_CLASS_NAME } from "metabase/common/components/dnd";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { Box, Loader } from "metabase/ui";
|
||||
|
||||
import { CommandPlugin } from "./CommandPlugin";
|
||||
import styles from "./Editor.module.css";
|
||||
import { MentionPlugin } from "./MentionPlugin";
|
||||
import { CardEmbed } from "./extensions/CardEmbed";
|
||||
import { DisableMetabotSidebar } from "./extensions/DisableMetabotSidebar";
|
||||
import { EmojiExtension } from "./extensions/Emojis";
|
||||
import { MetabotNode } from "./extensions/MetabotEmbed";
|
||||
import { SmartLinkEmbed } from "./extensions/SmartLink";
|
||||
import { Markdown } from "./extensions/markdown/index";
|
||||
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
|
||||
import type { CardEmbedRef } from "./types";
|
||||
|
||||
interface EditorProps {
|
||||
onEditorReady?: (editor: TiptapEditor) => void;
|
||||
onCardEmbedsChange?: (refs: CardEmbedRef[]) => void;
|
||||
content: string;
|
||||
onChange?: (content: string) => void;
|
||||
onQuestionSelect?: (cardId: number | null) => void;
|
||||
editable?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Editor: React.FC<EditorProps> = ({
|
||||
onEditorReady,
|
||||
onCardEmbedsChange,
|
||||
content,
|
||||
onChange,
|
||||
editable = true,
|
||||
onQuestionSelect,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const siteUrl = useSelector((state) => getSetting(state, "site-url"));
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Image.configure({
|
||||
inline: false,
|
||||
HTMLAttributes: {
|
||||
class: styles.img,
|
||||
},
|
||||
}),
|
||||
SmartLinkEmbed.configure({
|
||||
HTMLAttributes: {
|
||||
class: "smart-link",
|
||||
},
|
||||
siteUrl,
|
||||
}),
|
||||
Link.configure({
|
||||
HTMLAttributes: {
|
||||
class: CS.link,
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: t`Start writing, press "/" to insert a chart, or "@" to insert a reference...`,
|
||||
}),
|
||||
Markdown,
|
||||
CardEmbed.configure({
|
||||
HTMLAttributes: {
|
||||
class: "card-embed",
|
||||
},
|
||||
}),
|
||||
MetabotNode,
|
||||
DisableMetabotSidebar,
|
||||
EmojiExtension,
|
||||
],
|
||||
autofocus: false,
|
||||
immediatelyRender: false,
|
||||
},
|
||||
[siteUrl],
|
||||
);
|
||||
|
||||
// Track the previous content to avoid unnecessary updates
|
||||
const previousContent = useRef<string>("");
|
||||
|
||||
// Update editor content when content prop changes
|
||||
useEffect(() => {
|
||||
if (editor && !editor.isDestroyed && content !== previousContent.current) {
|
||||
// Use microtask to defer content update and avoid flushSync warning
|
||||
queueMicrotask(() => {
|
||||
if (editor.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!content || content.trim() === "") {
|
||||
editor.commands.setContent("");
|
||||
} else {
|
||||
// Expect JSON AST format
|
||||
const parsedContent = JSON.parse(content);
|
||||
editor.commands.setContent(parsedContent);
|
||||
}
|
||||
previousContent.current = content;
|
||||
} catch (error) {
|
||||
console.error("Failed to parse JSON AST content:", error);
|
||||
// Set empty document if JSON parsing fails
|
||||
editor.commands.setContent("");
|
||||
previousContent.current = content;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [editor, content]);
|
||||
|
||||
// Notify parent when editor is ready
|
||||
useEffect(() => {
|
||||
if (editor && onEditorReady) {
|
||||
onEditorReady(editor);
|
||||
}
|
||||
}, [editor, onEditorReady]);
|
||||
|
||||
// Set up change handler
|
||||
useEffect(() => {
|
||||
if (!editor || !onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
const currentAst = JSON.stringify(editor.state.doc.toJSON());
|
||||
onChange(currentAst);
|
||||
};
|
||||
|
||||
editor.on("update", handleUpdate);
|
||||
|
||||
return () => {
|
||||
editor.off("update", handleUpdate);
|
||||
};
|
||||
}, [editor, onChange]);
|
||||
|
||||
// Update editor editable state
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(editable);
|
||||
}
|
||||
}, [editor, editable]);
|
||||
|
||||
useCardEmbedsTracking(editor, onCardEmbedsChange);
|
||||
useQuestionSelection(editor, onQuestionSelect);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className={cx(styles.editor, DND_IGNORE_CLASS_NAME)}>
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={cx(styles.editor, DND_IGNORE_CLASS_NAME)}>
|
||||
<Box
|
||||
className={styles.editorContent}
|
||||
onClick={(e) => {
|
||||
// Focus editor when clicking on empty space
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.classList.contains(styles.editorContent) ||
|
||||
target.classList.contains("ProseMirror")
|
||||
) {
|
||||
const clickY = e.clientY;
|
||||
const proseMirrorElement = target.querySelector(".ProseMirror");
|
||||
|
||||
if (proseMirrorElement) {
|
||||
const proseMirrorRect =
|
||||
proseMirrorElement.getBoundingClientRect();
|
||||
const isClickBelowContent = clickY > proseMirrorRect.bottom;
|
||||
|
||||
if (isClickBelowContent) {
|
||||
// Only move to end if clicking below the actual content
|
||||
editor.commands.focus("end");
|
||||
} else {
|
||||
// Just focus without changing cursor position for clicks in padding areas
|
||||
editor.commands.focus();
|
||||
}
|
||||
} else {
|
||||
// Fallback: just focus without position change
|
||||
editor.commands.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
<MentionPlugin editor={editor} />
|
||||
<CommandPlugin editor={editor} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useListRecentsQuery } from "metabase/api";
|
||||
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
|
||||
import Search from "metabase/entities/search";
|
||||
import { getName } from "metabase/lib/name";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { RecentsListContent } from "metabase/nav/components/search/RecentsList/RecentsListContent";
|
||||
import { SearchResults } from "metabase/nav/components/search/SearchResults";
|
||||
import {
|
||||
ResultNameSection,
|
||||
ResultTitle,
|
||||
SearchResultContainer,
|
||||
} from "metabase/search/components/SearchResult";
|
||||
import { IconWrapper } from "metabase/search/components/SearchResult/components/ItemIcon.styled";
|
||||
import { Box, Group, Icon, Popover } from "metabase/ui";
|
||||
import type {
|
||||
RecentItem,
|
||||
SearchModel,
|
||||
UnrestrictedLinkEntity,
|
||||
} from "metabase-types/api";
|
||||
|
||||
const MODELS_TO_SEARCH: SearchModel[] = [
|
||||
"card",
|
||||
"dataset",
|
||||
"dashboard",
|
||||
"collection",
|
||||
"database",
|
||||
"table",
|
||||
];
|
||||
|
||||
interface ExtraItemProps {
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const SearchResultsFooter = ({ isSelected, onClick }: ExtraItemProps) => (
|
||||
<Box mx="sm" mb="sm" mt={-8}>
|
||||
<SearchResultContainer
|
||||
align="center"
|
||||
isActive
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
>
|
||||
<IconWrapper active archived={false} type="search">
|
||||
<Icon name="search" />
|
||||
</IconWrapper>
|
||||
|
||||
<ResultNameSection justify="center" gap="xs">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<ResultTitle
|
||||
role="heading"
|
||||
data-testid="search-result-item-name"
|
||||
truncate
|
||||
>
|
||||
{t`Browse all`}
|
||||
</ResultTitle>
|
||||
</Group>
|
||||
</ResultNameSection>
|
||||
</SearchResultContainer>
|
||||
</Box>
|
||||
);
|
||||
|
||||
interface MentionPluginProps {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const MentionPlugin = ({ editor }: MentionPluginProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [modal, setModal] = useState<"question-picker" | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [mentionRange, setMentionRange] = useState<{
|
||||
from: number;
|
||||
to: number;
|
||||
} | null>(null);
|
||||
const [anchorPos, setAnchorPos] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const virtualRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: recents = [], isLoading: isRecentsLoading } =
|
||||
useListRecentsQuery(undefined, { refetchOnMountOrArgChange: true });
|
||||
|
||||
const filteredRecents = recents
|
||||
.filter((item) => MODELS_TO_SEARCH.includes(item.model as SearchModel))
|
||||
.slice(0, 4);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateHandler = () => {
|
||||
const { $from } = editor.state.selection;
|
||||
if (mentionRange && showPopover) {
|
||||
// Check if we're still in mention mode
|
||||
const currentText = editor.state.doc.textBetween(
|
||||
mentionRange.from,
|
||||
Math.min(editor.state.doc.content.size, $from.pos),
|
||||
"",
|
||||
);
|
||||
|
||||
if (currentText.startsWith("@")) {
|
||||
setQuery(currentText.slice(1));
|
||||
setMentionRange({
|
||||
from: mentionRange.from,
|
||||
to: $from.pos,
|
||||
});
|
||||
} else {
|
||||
setShowPopover(false);
|
||||
setMentionRange(null);
|
||||
}
|
||||
}
|
||||
|
||||
const text = $from.nodeBefore?.text || "";
|
||||
// Check if we're typing after @
|
||||
if (text.endsWith("@")) {
|
||||
const from = $from.pos - 1;
|
||||
setMentionRange({ from, to: $from.pos });
|
||||
|
||||
// Get cursor position
|
||||
const coords = editor.view.coordsAtPos(from);
|
||||
setAnchorPos({ x: coords.left, y: coords.bottom });
|
||||
|
||||
setShowPopover(true);
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
const keydownHandler = (event: KeyboardEvent) => {
|
||||
if (!showPopover) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent cursor movement in editor for arrow keys
|
||||
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setShowPopover(false);
|
||||
setMentionRange(null);
|
||||
editor.commands.focus();
|
||||
}
|
||||
};
|
||||
|
||||
editor.on("update", updateHandler);
|
||||
editor.on("selectionUpdate", updateHandler);
|
||||
|
||||
const editorElement = editor.view.dom;
|
||||
editorElement.addEventListener("keydown", keydownHandler, true);
|
||||
|
||||
return () => {
|
||||
editor.off("update", updateHandler);
|
||||
editor.off("selectionUpdate", updateHandler);
|
||||
editorElement.removeEventListener("keydown", keydownHandler, true);
|
||||
};
|
||||
}, [editor, mentionRange, showPopover]);
|
||||
|
||||
const handleSelect = async (item: UnrestrictedLinkEntity | null) => {
|
||||
if (!mentionRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item === null) {
|
||||
setShowPopover(false);
|
||||
setMentionRange(null);
|
||||
editor.commands.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const wrappedItem = Search.wrapEntity(item, dispatch);
|
||||
const insertPosition = mentionRange.from;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(mentionRange)
|
||||
.insertContentAt(insertPosition, {
|
||||
type: "smartLink",
|
||||
attrs: {
|
||||
entityId: wrappedItem.id,
|
||||
model: wrappedItem.model,
|
||||
},
|
||||
})
|
||||
.setTextSelection(insertPosition + 1)
|
||||
.run();
|
||||
|
||||
setShowPopover(false);
|
||||
setMentionRange(null);
|
||||
};
|
||||
|
||||
const handleRecentSelect = (item: RecentItem) => {
|
||||
handleSelect({
|
||||
...item,
|
||||
description: item.description ?? undefined,
|
||||
name: getName(item),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (virtualRef.current && anchorPos) {
|
||||
virtualRef.current.style.position = "fixed";
|
||||
virtualRef.current.style.left = `${anchorPos.x}px`;
|
||||
virtualRef.current.style.top = `${anchorPos.y}px`;
|
||||
virtualRef.current.style.width = "1px";
|
||||
virtualRef.current.style.height = "1px";
|
||||
}
|
||||
}, [anchorPos]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
opened={showPopover}
|
||||
position="bottom-start"
|
||||
width={320}
|
||||
shadow="md"
|
||||
withinPortal
|
||||
closeOnClickOutside={false}
|
||||
middlewares={{ flip: true, shift: true }}
|
||||
onClose={() => {
|
||||
setShowPopover(false);
|
||||
setMentionRange(null);
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div
|
||||
ref={virtualRef}
|
||||
style={{ position: "fixed", pointerEvents: "none" }}
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{query.length > 0 ? (
|
||||
<SearchResults
|
||||
searchText={query}
|
||||
limit={4}
|
||||
forceEntitySelect
|
||||
onEntitySelect={handleSelect}
|
||||
models={MODELS_TO_SEARCH}
|
||||
footerComponent={SearchResultsFooter}
|
||||
onFooterSelect={() => setModal("question-picker")}
|
||||
showFooterOnNoResults
|
||||
/>
|
||||
) : (
|
||||
<RecentsListContent
|
||||
isLoading={isRecentsLoading}
|
||||
results={filteredRecents}
|
||||
onClick={handleRecentSelect}
|
||||
headerChildren={[]}
|
||||
footerChildren={[
|
||||
Object.assign(SearchResultsFooter, {
|
||||
onClick: () => setModal("question-picker"),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
{modal === "question-picker" && (
|
||||
<QuestionPickerModal
|
||||
onChange={async (item) => {
|
||||
await handleSelect(item);
|
||||
setModal(null);
|
||||
}}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,551 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import cx from "classnames";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { skipToken, useGetCardQuery, useGetCardQueryQuery } from "metabase/api";
|
||||
import {
|
||||
useGetAdhocQueryMetadataQuery,
|
||||
useGetAdhocQueryQuery,
|
||||
} from "metabase/api/dataset";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { loadMetadataForCard } from "metabase/questions/actions";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { Box, Icon, Loader, Menu, Text, TextInput } from "metabase/ui";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import { getUrl } from "metabase-lib/v1/urls";
|
||||
import type { Card } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
openVizSettingsSidebar,
|
||||
setShowNavigateBackToDocumentButton,
|
||||
} from "../../../../documents.slice";
|
||||
import { useDocumentsSelector } from "../../../../redux-utils";
|
||||
import { getCardWithDraft } from "../../../../selectors";
|
||||
import { EDITOR_STYLE_BOUNDARY_CLASS } from "../../constants";
|
||||
import { formatCardEmbed } from "../markdown/card-embed-format";
|
||||
|
||||
import styles from "./CardEmbedNode.module.css";
|
||||
import { ModifyQuestionModal } from "./ModifyQuestionModal";
|
||||
import { NativeQueryModal } from "./NativeQueryModal";
|
||||
|
||||
export interface CardEmbedAttributes {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
export const CardEmbedNode: Node<{
|
||||
HTMLAttributes: CardEmbedAttributes;
|
||||
}> = Node.create({
|
||||
name: "cardEmbed",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const id = element.getAttribute("data-id");
|
||||
if (id) {
|
||||
return parseInt(id);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
name: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-name"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${CardEmbedNode.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-type": CardEmbedNode.name,
|
||||
"data-id": node.attrs.id,
|
||||
"data-name": node.attrs.name,
|
||||
},
|
||||
this.options.HTMLAttributes,
|
||||
),
|
||||
formatCardEmbed(node.attrs as CardEmbedAttributes),
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return formatCardEmbed(node.attrs as CardEmbedAttributes);
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return () => {
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute("data-type", CardEmbedNode.name);
|
||||
dom.className = styles.embedContainer;
|
||||
|
||||
const content = document.createElement("div");
|
||||
dom.appendChild(content);
|
||||
|
||||
return {
|
||||
dom,
|
||||
contentDOM: content,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const CardEmbedComponent = memo(
|
||||
({ node, updateAttributes, selected, editor, getPos }: NodeViewProps) => {
|
||||
const { id, name } = node.attrs;
|
||||
const dispatch = useDispatch();
|
||||
const canWrite = editor.options.editable;
|
||||
|
||||
let embedIndex = -1;
|
||||
|
||||
if (editor && getPos) {
|
||||
const currentPos = getPos() ?? 0;
|
||||
let nodeCount = 0;
|
||||
|
||||
// Count cardEmbed nodes that appear before this position
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "cardEmbed") {
|
||||
if (pos < currentPos) {
|
||||
nodeCount++;
|
||||
} else if (pos === currentPos) {
|
||||
embedIndex = nodeCount;
|
||||
return false; // Stop traversing
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { data: card, isLoading: isLoadingCard } = useGetCardQuery(
|
||||
{ id },
|
||||
{ skip: !id || id < 0 }, // Skip if draft card (negative ID)
|
||||
);
|
||||
|
||||
// Get card with draft if available
|
||||
const cardWithDraft = useDocumentsSelector((state) =>
|
||||
getCardWithDraft(state, id, card),
|
||||
);
|
||||
|
||||
// Use the draft card if available, otherwise use the fetched card
|
||||
const cardToUse = cardWithDraft ?? card;
|
||||
|
||||
// Use different endpoints for draft vs regular cards
|
||||
const { data: regularDataset, isLoading: isLoadingRegularDataset } =
|
||||
useGetCardQueryQuery(
|
||||
{ cardId: id },
|
||||
{ skip: !id || id < 0 || !card }, // Skip for draft cards
|
||||
);
|
||||
|
||||
const { data: draftDataset, isLoading: isLoadingDraftDataset } =
|
||||
useGetAdhocQueryQuery(
|
||||
id < 0 && cardToUse?.dataset_query
|
||||
? {
|
||||
...cardToUse.dataset_query,
|
||||
database: cardToUse.database_id ?? null,
|
||||
parameters: [],
|
||||
}
|
||||
: skipToken,
|
||||
);
|
||||
|
||||
// Use appropriate dataset based on card type
|
||||
const dataset = id < 0 ? draftDataset : regularDataset;
|
||||
const isLoadingDataset =
|
||||
id < 0 ? isLoadingDraftDataset : isLoadingRegularDataset;
|
||||
|
||||
const rawSeries =
|
||||
cardToUse && dataset?.data
|
||||
? [
|
||||
{
|
||||
card: cardToUse,
|
||||
started_at: dataset.started_at,
|
||||
data: dataset.data,
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
const metadata = useDocumentsSelector(getMetadata);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState(name || "");
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isModifyModalOpen, setIsModifyModalOpen] = useState(false);
|
||||
|
||||
const displayName = name || cardToUse?.name;
|
||||
const isNativeQuestion = cardToUse?.dataset_query?.type === "native";
|
||||
const isLoading = isLoadingCard || isLoadingDataset;
|
||||
|
||||
// Only show error if we've tried to load and failed, not if we haven't tried yet
|
||||
const hasTriedToLoad =
|
||||
cardToUse !== undefined || isLoadingCard || isLoadingDataset;
|
||||
const error =
|
||||
hasTriedToLoad && !isLoading && id && !cardToUse
|
||||
? "Failed to load question"
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingTitle && titleInputRef.current) {
|
||||
titleInputRef.current.focus();
|
||||
titleInputRef.current.select();
|
||||
}
|
||||
}, [isEditingTitle]);
|
||||
|
||||
const handleTitleSave = () => {
|
||||
const trimmedTitle = editedTitle.trim();
|
||||
if (trimmedTitle && trimmedTitle !== cardToUse?.name) {
|
||||
updateAttributes({ name: trimmedTitle });
|
||||
} else {
|
||||
updateAttributes({ name: null });
|
||||
setEditedTitle("");
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleTitleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditedTitle(name || "");
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load metadata for the card
|
||||
const { data: adhocMetadata } = useGetAdhocQueryMetadataQuery(
|
||||
id < 0 && cardToUse?.dataset_query ? cardToUse.dataset_query : skipToken,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (cardToUse && id >= 0) {
|
||||
// For regular cards, use the loadMetadataForCard action
|
||||
dispatch(loadMetadataForCard(cardToUse));
|
||||
}
|
||||
}, [cardToUse, dispatch, adhocMetadata, id]);
|
||||
|
||||
const handleEditVisualizationSettings = () => {
|
||||
if (embedIndex !== -1) {
|
||||
dispatch(openVizSettingsSidebar({ embedIndex }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (cardToUse && metadata) {
|
||||
try {
|
||||
dispatch(setShowNavigateBackToDocumentButton(true));
|
||||
const question = new Question(cardToUse, metadata);
|
||||
const url = getUrl(question, { includeDisplayIsLocked: true });
|
||||
dispatch(push(url));
|
||||
} catch (error) {
|
||||
console.error("Failed to navigate to question:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceQuestion = () => {
|
||||
// Get the position of this node in the editor
|
||||
const pos = editor.state.doc.nodeAt(0) ? getPos() : 0;
|
||||
|
||||
if (typeof pos === "number") {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection({ from: pos, to: pos + node.nodeSize })
|
||||
.deleteSelection()
|
||||
.insertContent("/")
|
||||
.run();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drill-through navigation
|
||||
const handleChangeCardAndRun = useCallback(
|
||||
({
|
||||
nextCard,
|
||||
}: {
|
||||
nextCard: Card;
|
||||
previousCard?: Card;
|
||||
objectId?: number;
|
||||
}) => {
|
||||
if (!metadata) {
|
||||
console.warn("Metadata not available for drill-through navigation");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setShowNavigateBackToDocumentButton(true));
|
||||
// For drill-through, we need to ensure the card is treated as adhoc
|
||||
// Remove the ID so getUrl creates an adhoc question URL instead of navigating to saved question
|
||||
const adhocCard = { ...nextCard, id: null };
|
||||
const question = new Question(adhocCard, metadata);
|
||||
const url = getUrl(question, { includeDisplayIsLocked: true });
|
||||
dispatch(push(url));
|
||||
} catch (error) {
|
||||
console.error("Failed to create question URL:", error);
|
||||
// Fallback: navigate to a new question with the dataset_query
|
||||
if (nextCard.dataset_query) {
|
||||
dispatch(setShowNavigateBackToDocumentButton(true));
|
||||
const params = new URLSearchParams();
|
||||
params.set("dataset_query", JSON.stringify(nextCard.dataset_query));
|
||||
dispatch(push(`/question?${params.toString()}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch, metadata],
|
||||
);
|
||||
|
||||
if (isLoadingCard && !cardToUse) {
|
||||
return (
|
||||
<NodeViewWrapper className={styles.embedWrapper}>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
>
|
||||
<Box className={styles.questionHeader}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Box className={styles.titleContainer}>
|
||||
<Text size="md" color="text-dark" fw={700}>
|
||||
{t`Loading question...`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className={styles.questionResults}>
|
||||
<Box className={styles.loadingContainer}>
|
||||
<Loader />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<NodeViewWrapper className={styles.embedWrapper}>
|
||||
<Box
|
||||
className={cx(styles.errorContainer, EDITOR_STYLE_BOUNDARY_CLASS)}
|
||||
>
|
||||
<Text color="error">{t`Failed to load question`}</Text>
|
||||
</Box>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className={styles.embedWrapper}>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
>
|
||||
{cardToUse && (
|
||||
<Box className={styles.questionHeader}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{isEditingTitle ? (
|
||||
<TextInput
|
||||
ref={titleInputRef}
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
onBlur={handleTitleSave}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
size="md"
|
||||
style={{ flex: 1 }}
|
||||
styles={{
|
||||
input: {
|
||||
fontWeight: 700,
|
||||
fontSize: "1rem",
|
||||
border: "1px solid transparent",
|
||||
padding: 0,
|
||||
height: "auto",
|
||||
minHeight: "auto",
|
||||
lineHeight: 1.55,
|
||||
backgroundColor: "transparent",
|
||||
"&:focus": {
|
||||
border: "1px solid var(--mb-color-border)",
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
padding: "0 0.25rem",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box className={styles.titleContainer}>
|
||||
<Text
|
||||
size="md"
|
||||
color="text-dark"
|
||||
fw={700}
|
||||
onClick={handleTitleClick}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{canWrite && (
|
||||
<Icon
|
||||
name="pencil"
|
||||
size={14}
|
||||
color="var(--mb-color-text-medium)"
|
||||
className={styles.titleEditIcon}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditedTitle(displayName);
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!isEditingTitle && (
|
||||
<Menu withinPortal position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Box
|
||||
component="button"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "0.25rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<Icon
|
||||
name="ellipsis"
|
||||
size={16}
|
||||
color="var(--mb-color-text-medium)"
|
||||
/>
|
||||
</Box>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleEditVisualizationSettings}
|
||||
disabled={!canWrite}
|
||||
leftSection={<Icon name="palette" size={14} />}
|
||||
>
|
||||
{t`Edit Visualization`}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setIsModifyModalOpen(true)}
|
||||
disabled={!canWrite}
|
||||
leftSection={
|
||||
<Icon
|
||||
name={isNativeQuestion ? "sql" : "notebook"}
|
||||
size={14}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t`Edit Query`}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={handleReplaceQuestion}
|
||||
disabled={!canWrite}
|
||||
leftSection={<Icon name="refresh" size={14} />}
|
||||
>
|
||||
{t`Replace question`}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{rawSeries ? (
|
||||
<>
|
||||
<Box className={styles.questionResults}>
|
||||
<Visualization
|
||||
rawSeries={rawSeries}
|
||||
metadata={metadata}
|
||||
onChangeCardAndRun={handleChangeCardAndRun}
|
||||
getExtraDataForClick={() => ({})}
|
||||
isEditing={false}
|
||||
isDashboard={false}
|
||||
showTitle={false}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box className={styles.questionResults}>
|
||||
<ChartSkeleton display={cardToUse?.display || "table"} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{isModifyModalOpen &&
|
||||
cardToUse &&
|
||||
(isNativeQuestion ? (
|
||||
<NativeQueryModal
|
||||
card={cardToUse}
|
||||
isOpen={isModifyModalOpen}
|
||||
onClose={() => setIsModifyModalOpen(false)}
|
||||
editor={editor}
|
||||
initialDataset={dataset}
|
||||
onSave={(result) => {
|
||||
updateAttributes({
|
||||
id: result.card_id,
|
||||
name: null,
|
||||
});
|
||||
setIsModifyModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ModifyQuestionModal
|
||||
card={cardToUse}
|
||||
isOpen={isModifyModalOpen}
|
||||
onClose={() => setIsModifyModalOpen(false)}
|
||||
editor={editor}
|
||||
onSave={(result) => {
|
||||
updateAttributes({
|
||||
id: result.card_id,
|
||||
name: null,
|
||||
});
|
||||
setIsModifyModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.node.attrs.id === nextProps.node.attrs.id &&
|
||||
prevProps.node.attrs.name === nextProps.node.attrs.name &&
|
||||
prevProps.selected === nextProps.selected
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardEmbedComponent.displayName = "CardEmbedComponent";
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAsync } from "react-use";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { skipToken } from "metabase/api";
|
||||
import { useGetAdhocQueryMetadataQuery } from "metabase/api/dataset";
|
||||
import { useDispatch, useStore } from "metabase/lib/redux";
|
||||
import { Notebook } from "metabase/querying/notebook/components/Notebook";
|
||||
import { loadMetadataForCard } from "metabase/questions/actions";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { Box, Button, Loader, Modal, Text } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type { Card } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
createDraftCard,
|
||||
generateDraftCardId,
|
||||
} from "../../../../documents.slice";
|
||||
import {
|
||||
useDocumentsDispatch,
|
||||
useDocumentsSelector,
|
||||
} from "../../../../redux-utils";
|
||||
|
||||
interface ModifyQuestionModalProps {
|
||||
card: Card;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (result: { card_id: number; name: string }) => void;
|
||||
editor?: Editor;
|
||||
}
|
||||
|
||||
export const ModifyQuestionModal = ({
|
||||
card,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editor,
|
||||
}: ModifyQuestionModalProps) => {
|
||||
const store = useStore();
|
||||
const dispatch = useDispatch();
|
||||
const documentsDispatch = useDocumentsDispatch();
|
||||
const metadata = useDocumentsSelector(getMetadata);
|
||||
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isDraftCard = card.id < 0;
|
||||
const { data: adhocMetadata, isLoading: isAdhocMetadataLoading } =
|
||||
useGetAdhocQueryMetadataQuery(
|
||||
isDraftCard && card.dataset_query ? card.dataset_query : skipToken,
|
||||
{ skip: !isDraftCard || !card.dataset_query },
|
||||
);
|
||||
|
||||
const metadataState = useAsync(async () => {
|
||||
if (isOpen && card && !isDraftCard) {
|
||||
await dispatch(loadMetadataForCard(card));
|
||||
}
|
||||
}, [isOpen, card, dispatch, isDraftCard]);
|
||||
|
||||
const question = useMemo(() => {
|
||||
const isMetadataLoading = isDraftCard
|
||||
? isAdhocMetadataLoading
|
||||
: metadataState.loading;
|
||||
const hasMetadataError = isDraftCard ? false : metadataState.error;
|
||||
const hasMetadata = isDraftCard ? !!adhocMetadata : !!metadata;
|
||||
|
||||
if (
|
||||
isMetadataLoading ||
|
||||
hasMetadataError ||
|
||||
!card ||
|
||||
!hasMetadata ||
|
||||
!isOpen
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseQuestion = new Question(card, metadata);
|
||||
if (!modifiedQuestion) {
|
||||
setModifiedQuestion(baseQuestion);
|
||||
}
|
||||
return baseQuestion;
|
||||
}, [
|
||||
isDraftCard,
|
||||
isAdhocMetadataLoading,
|
||||
metadataState.loading,
|
||||
metadataState.error,
|
||||
adhocMetadata,
|
||||
card,
|
||||
metadata,
|
||||
isOpen,
|
||||
modifiedQuestion,
|
||||
]);
|
||||
|
||||
const handleUpdateQuestion = async (newQuestion: Question) => {
|
||||
const currentDependencies = modifiedQuestion
|
||||
? Lib.dependentMetadata(
|
||||
modifiedQuestion.query(),
|
||||
modifiedQuestion.id(),
|
||||
modifiedQuestion.type(),
|
||||
)
|
||||
: [];
|
||||
|
||||
const nextDependencies = Lib.dependentMetadata(
|
||||
newQuestion.query(),
|
||||
newQuestion.id(),
|
||||
newQuestion.type(),
|
||||
);
|
||||
|
||||
if (!_.isEqual(currentDependencies, nextDependencies)) {
|
||||
if (!isDraftCard) {
|
||||
await dispatch(loadMetadataForCard(newQuestion.card()));
|
||||
const freshMetadata = getMetadata(store.getState());
|
||||
const questionWithFreshMetadata = new Question(
|
||||
newQuestion.card(),
|
||||
freshMetadata,
|
||||
);
|
||||
setModifiedQuestion(questionWithFreshMetadata);
|
||||
} else {
|
||||
setModifiedQuestion(newQuestion);
|
||||
}
|
||||
} else {
|
||||
setModifiedQuestion(newQuestion);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!modifiedQuestion || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const modifiedData = {
|
||||
dataset_query: modifiedQuestion.datasetQuery(),
|
||||
display: modifiedQuestion.display(),
|
||||
visualization_settings:
|
||||
modifiedQuestion.card().visualization_settings ?? {},
|
||||
};
|
||||
|
||||
const newCardId = generateDraftCardId();
|
||||
|
||||
documentsDispatch(
|
||||
createDraftCard({
|
||||
originalCard: card,
|
||||
modifiedData,
|
||||
draftId: newCardId,
|
||||
}),
|
||||
);
|
||||
|
||||
onSave({ card_id: newCardId, name: card.name });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save modified question:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
size="80%"
|
||||
title={t`Modify question`}
|
||||
padding="lg"
|
||||
>
|
||||
{(isDraftCard ? isAdhocMetadataLoading : metadataState.loading) ? (
|
||||
<Box
|
||||
style={{
|
||||
height: "70vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</Box>
|
||||
) : question && modifiedQuestion ? (
|
||||
<>
|
||||
<Box style={{ height: "70vh", overflow: "auto" }}>
|
||||
<Notebook
|
||||
question={modifiedQuestion}
|
||||
isDirty={true}
|
||||
isRunnable={true}
|
||||
isResultDirty={true}
|
||||
reportTimezone="UTC"
|
||||
hasVisualizeButton={false}
|
||||
updateQuestion={handleUpdateQuestion}
|
||||
runQuestionQuery={async () => {}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
mt="lg"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button variant="filled" onClick={handleSave}>
|
||||
{t`Save and use`}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
height: "70vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text>{t`Failed to load question data`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,666 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAsync } from "react-use";
|
||||
import { c, t } from "ttag";
|
||||
|
||||
import EmptyCodeResult from "assets/img/empty-states/code.svg";
|
||||
import { skipToken } from "metabase/api";
|
||||
import {
|
||||
datasetApi,
|
||||
useGetAdhocQueryMetadataQuery,
|
||||
} from "metabase/api/dataset";
|
||||
import { ErrorMessage } from "metabase/common/components/ErrorMessage";
|
||||
import { isMac } from "metabase/lib/browser";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
|
||||
import DataReference from "metabase/query_builder/components/dataref/DataReference";
|
||||
import { TagEditorSidebar } from "metabase/query_builder/components/template_tags/TagEditorSidebar";
|
||||
import { loadMetadataForCard } from "metabase/questions/actions";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { Box, Button, Flex, Loader, Modal, Stack, Text } from "metabase/ui";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
import NoResultsView from "metabase/visualizations/components/Visualization/NoResultsView/NoResultsView";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type NativeQuery from "metabase-lib/v1/queries/NativeQuery";
|
||||
import type {
|
||||
Card,
|
||||
DatabaseId,
|
||||
Dataset,
|
||||
DatasetQuery,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import {
|
||||
createDraftCard,
|
||||
generateDraftCardId,
|
||||
} from "../../../../documents.slice";
|
||||
import {
|
||||
useDocumentsDispatch,
|
||||
useDocumentsSelector,
|
||||
} from "../../../../redux-utils";
|
||||
|
||||
type DataReferenceStackItem = {
|
||||
type: string;
|
||||
item: unknown;
|
||||
};
|
||||
|
||||
interface NativeQueryModalProps {
|
||||
card: Card;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (result: { card_id: number; name: string }) => void;
|
||||
editor?: Editor;
|
||||
initialDataset?: Dataset;
|
||||
}
|
||||
|
||||
const EDITOR_HEIGHT_RATIO = 0.4;
|
||||
const EDITOR_MIN_HEIGHT = 200;
|
||||
const EDITOR_HEIGHT_OFFSET = 80;
|
||||
|
||||
const MODAL_SIDEBAR_FEATURES = {
|
||||
dataReference: true,
|
||||
variables: false,
|
||||
snippets: false,
|
||||
promptInput: false,
|
||||
formatQuery: false,
|
||||
aiGeneration: false,
|
||||
} as const;
|
||||
|
||||
const getRunQueryShortcut = () => (isMac() ? t`⌘ + return` : t`Ctrl + enter`);
|
||||
|
||||
const isFailedDataset = (dataset: Dataset | null | undefined): boolean => {
|
||||
return !!(dataset?.error || dataset?.status === "failed");
|
||||
};
|
||||
|
||||
const hasEmptyResults = (dataset: Dataset | null | undefined): boolean => {
|
||||
return (
|
||||
!isFailedDataset(dataset) &&
|
||||
!!dataset?.data &&
|
||||
Array.isArray(dataset.data.rows) &&
|
||||
dataset.data.rows.length === 0
|
||||
);
|
||||
};
|
||||
|
||||
const getErrorMessage = (sqlError: any, queryError: unknown): string => {
|
||||
if (sqlError?.error) {
|
||||
return sqlError.error;
|
||||
}
|
||||
if (typeof queryError === "object" && queryError && "message" in queryError) {
|
||||
return String(queryError.message);
|
||||
}
|
||||
return t`An error occurred while running your query. Please check your query and try again.`;
|
||||
};
|
||||
|
||||
const QueryExecutionEmptyState = ({
|
||||
hasInitialData,
|
||||
}: {
|
||||
hasInitialData: boolean;
|
||||
}) => {
|
||||
if (hasInitialData) {
|
||||
return <NoResultsView />;
|
||||
}
|
||||
|
||||
const keyboardShortcut = getRunQueryShortcut();
|
||||
|
||||
return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center">
|
||||
<Stack maw="25rem" gap={0} ta="center" align="center">
|
||||
<Box maw="3rem" mb="0.75rem">
|
||||
<img src={EmptyCodeResult} alt="Code prompt icon" />
|
||||
</Box>
|
||||
<Text c="text-medium">
|
||||
{c("{0} refers to the keyboard shortcut")
|
||||
.jt`To run your code, click on the Run button or type ${(
|
||||
<b key="shortcut">({keyboardShortcut})</b>
|
||||
)}`}
|
||||
</Text>
|
||||
<Text c="text-medium">{t`Here's where your results will appear`}</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Modal opened={isOpen} onClose={onClose} size="95%" title={t`Edit SQL Query`}>
|
||||
<Box
|
||||
style={{
|
||||
height: "400px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export const NativeQueryModal = ({
|
||||
card,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editor,
|
||||
initialDataset,
|
||||
}: NativeQueryModalProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const documentsDispatch = useDocumentsDispatch();
|
||||
const metadata = useDocumentsSelector(getMetadata);
|
||||
|
||||
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
|
||||
null,
|
||||
);
|
||||
const [currentQuery, setCurrentQuery] = useState<DatasetQuery | null>(null);
|
||||
const [hasExecutedQuery, setHasExecutedQuery] = useState(false);
|
||||
const [isShowingTemplateTagsEditor, setIsShowingTemplateTagsEditor] =
|
||||
useState(false);
|
||||
const [isShowingDataReference, setIsShowingDataReference] = useState(false);
|
||||
const [dataReferenceStack, setDataReferenceStack] = useState<
|
||||
DataReferenceStackItem[]
|
||||
>([]);
|
||||
|
||||
const {
|
||||
data: queryResult,
|
||||
isLoading: isQueryRunning,
|
||||
error: queryError,
|
||||
refetch: refetchQuery,
|
||||
} = datasetApi.useGetAdhocQueryQuery(currentQuery || skipToken, {
|
||||
skip: !currentQuery,
|
||||
});
|
||||
|
||||
const datasetToUse =
|
||||
queryResult || (!hasExecutedQuery ? initialDataset : null);
|
||||
const sqlError = isFailedDataset(datasetToUse) ? datasetToUse : null;
|
||||
|
||||
const isDraftCard = card.id < 0;
|
||||
const { data: adhocMetadata, isLoading: isAdhocMetadataLoading } =
|
||||
useGetAdhocQueryMetadataQuery(
|
||||
isDraftCard && card.dataset_query ? card.dataset_query : skipToken,
|
||||
{ skip: !isDraftCard || !card.dataset_query },
|
||||
);
|
||||
|
||||
const metadataState = useAsync(async () => {
|
||||
if (isOpen && card && !isDraftCard) {
|
||||
await dispatch(loadMetadataForCard(card));
|
||||
}
|
||||
}, [isOpen, card, dispatch, isDraftCard]);
|
||||
|
||||
// Question initialization
|
||||
const question = useMemo(() => {
|
||||
const isMetadataLoading = isDraftCard
|
||||
? isAdhocMetadataLoading
|
||||
: metadataState.loading;
|
||||
const hasMetadataError = isDraftCard ? false : metadataState.error;
|
||||
const hasMetadata = isDraftCard ? !!adhocMetadata : !!metadata;
|
||||
|
||||
if (
|
||||
isMetadataLoading ||
|
||||
hasMetadataError ||
|
||||
!card ||
|
||||
!hasMetadata ||
|
||||
!isOpen
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseQuestion = new Question(card, metadata);
|
||||
if (!modifiedQuestion) {
|
||||
setModifiedQuestion(baseQuestion);
|
||||
}
|
||||
return baseQuestion;
|
||||
}, [
|
||||
isDraftCard,
|
||||
isAdhocMetadataLoading,
|
||||
metadataState.loading,
|
||||
metadataState.error,
|
||||
adhocMetadata,
|
||||
card,
|
||||
metadata,
|
||||
isOpen,
|
||||
modifiedQuestion,
|
||||
]);
|
||||
|
||||
// Reset query execution state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCurrentQuery(null);
|
||||
setHasExecutedQuery(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Dataset query handler following QB patterns
|
||||
const setDatasetQuery = useCallback(
|
||||
async (query: NativeQuery | DatasetQuery) => {
|
||||
if (!modifiedQuestion) {
|
||||
return modifiedQuestion;
|
||||
}
|
||||
|
||||
try {
|
||||
let datasetQuery: DatasetQuery;
|
||||
const isNativeQueryObject =
|
||||
"queryText" in query && typeof query.queryText === "function";
|
||||
if (isNativeQueryObject) {
|
||||
// Handle NativeQuery object
|
||||
const nativeQuery = query as NativeQuery;
|
||||
datasetQuery = {
|
||||
type: "native",
|
||||
native: {
|
||||
query: nativeQuery.queryText() || "",
|
||||
"template-tags": nativeQuery.templateTagsMap() || {},
|
||||
},
|
||||
database: modifiedQuestion.databaseId(),
|
||||
} as DatasetQuery;
|
||||
} else {
|
||||
// Handle dataset_query object (including database changes)
|
||||
datasetQuery = query as DatasetQuery;
|
||||
}
|
||||
|
||||
const newQuestion = modifiedQuestion.setDatasetQuery(datasetQuery);
|
||||
setModifiedQuestion(newQuestion);
|
||||
return newQuestion;
|
||||
} catch (error) {
|
||||
console.error("Failed to update dataset query:", error);
|
||||
return modifiedQuestion;
|
||||
}
|
||||
},
|
||||
[modifiedQuestion],
|
||||
);
|
||||
|
||||
// Query execution handler
|
||||
const handleRunQuery = useCallback(() => {
|
||||
if (!modifiedQuestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const datasetQuery = modifiedQuestion.datasetQuery();
|
||||
|
||||
// Check if query has changed from what we currently have
|
||||
const queryChanged =
|
||||
!currentQuery ||
|
||||
JSON.stringify(currentQuery) !== JSON.stringify(datasetQuery);
|
||||
|
||||
if (queryChanged) {
|
||||
// Query changed, set new query which will trigger RTK Query
|
||||
setCurrentQuery(datasetQuery);
|
||||
} else {
|
||||
// Same query, use refetch for proper loading state
|
||||
refetchQuery();
|
||||
}
|
||||
|
||||
setHasExecutedQuery(true);
|
||||
}, [modifiedQuestion, currentQuery, refetchQuery]);
|
||||
|
||||
// Save handler
|
||||
const handleSave = async () => {
|
||||
if (!modifiedQuestion || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const modifiedData = {
|
||||
dataset_query: modifiedQuestion.datasetQuery(),
|
||||
display: modifiedQuestion.display(),
|
||||
visualization_settings:
|
||||
modifiedQuestion.card().visualization_settings ?? {},
|
||||
};
|
||||
|
||||
const newCardId = generateDraftCardId();
|
||||
|
||||
documentsDispatch(
|
||||
createDraftCard({
|
||||
originalCard: card,
|
||||
modifiedData,
|
||||
draftId: newCardId,
|
||||
}),
|
||||
);
|
||||
|
||||
onSave({ card_id: newCardId, name: card.name });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save modified question:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Transform query results to visualization series following QB patterns
|
||||
const rawSeries = useMemo(() => {
|
||||
if (!modifiedQuestion || !datasetToUse || sqlError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = datasetToUse.data || datasetToUse;
|
||||
return [
|
||||
{
|
||||
card: {
|
||||
...modifiedQuestion.card(),
|
||||
display: "table" as const, // Force table display for results
|
||||
visualization_settings: {
|
||||
...modifiedQuestion.card().visualization_settings,
|
||||
"table.pivot": false,
|
||||
"table.columns": undefined,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
rows: data.rows || [],
|
||||
cols: data.cols || [],
|
||||
results_metadata: data.results_metadata || [],
|
||||
insights: data.insights,
|
||||
rows_truncated: data.rows_truncated || 0,
|
||||
},
|
||||
json_query: datasetToUse.json_query,
|
||||
started_at: datasetToUse.started_at || new Date().toISOString(),
|
||||
running_time: datasetToUse.running_time,
|
||||
row_count: datasetToUse.row_count,
|
||||
status: "completed",
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error("Error formatting series data:", error);
|
||||
return null;
|
||||
}
|
||||
}, [modifiedQuestion, datasetToUse, sqlError]);
|
||||
|
||||
const modalStyles = {
|
||||
content: {
|
||||
height: "90vh",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
padding: 0,
|
||||
minHeight: 0,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
};
|
||||
|
||||
const editorContainerStyle = {
|
||||
flexShrink: 0,
|
||||
borderBottom: "1px solid var(--mb-color-border)",
|
||||
position: "relative" as const,
|
||||
};
|
||||
|
||||
if (isDraftCard ? isAdhocMetadataLoading : metadataState.loading) {
|
||||
return (
|
||||
<LoadingModal isOpen={isOpen} onClose={onClose}>
|
||||
<Loader size="lg" />
|
||||
</LoadingModal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!question || !modifiedQuestion) {
|
||||
return (
|
||||
<LoadingModal isOpen={isOpen} onClose={onClose}>
|
||||
<Text>{t`Failed to load question data`}</Text>
|
||||
</LoadingModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
size="95%"
|
||||
title={t`Edit SQL Query`}
|
||||
padding="lg"
|
||||
styles={modalStyles}
|
||||
>
|
||||
<Box style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{/* Main content area with horizontal layout */}
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{/* Left side - Editor and Results */}
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Box style={editorContainerStyle}>
|
||||
{/* NativeQueryEditor with minimal configuration for modal context */}
|
||||
{(modifiedQuestion?.legacyNativeQuery() ||
|
||||
question?.legacyNativeQuery()) && (
|
||||
<NativeQueryEditor
|
||||
question={modifiedQuestion}
|
||||
query={
|
||||
modifiedQuestion?.legacyNativeQuery() ||
|
||||
question?.legacyNativeQuery() ||
|
||||
({} as any)
|
||||
}
|
||||
isNativeEditorOpen
|
||||
isInitiallyOpen
|
||||
hasTopBar
|
||||
hasEditingSidebar
|
||||
hasParametersList={false}
|
||||
sidebarFeatures={MODAL_SIDEBAR_FEATURES}
|
||||
viewHeight={400}
|
||||
isRunnable
|
||||
isRunning={isQueryRunning}
|
||||
isResultDirty={false}
|
||||
isShowingDataReference={isShowingDataReference}
|
||||
isShowingTemplateTagsEditor={isShowingTemplateTagsEditor}
|
||||
isShowingSnippetSidebar={false}
|
||||
setDatasetQuery={setDatasetQuery}
|
||||
runQuestionQuery={handleRunQuery}
|
||||
runQuery={handleRunQuery}
|
||||
toggleTemplateTagsEditor={() =>
|
||||
setIsShowingTemplateTagsEditor(!isShowingTemplateTagsEditor)
|
||||
}
|
||||
toggleDataReference={() => {
|
||||
if (!isShowingDataReference && modifiedQuestion) {
|
||||
const databaseId = modifiedQuestion.databaseId();
|
||||
if (databaseId) {
|
||||
setDataReferenceStack([
|
||||
{ type: "database", item: { id: databaseId } },
|
||||
]);
|
||||
}
|
||||
}
|
||||
setIsShowingDataReference(!isShowingDataReference);
|
||||
}}
|
||||
toggleSnippetSidebar={() => {}}
|
||||
setNativeEditorSelectedRange={() => {}}
|
||||
openDataReferenceAtQuestion={() => {}}
|
||||
openSnippetModalWithSelectedText={() => {}}
|
||||
insertSnippet={() => {}}
|
||||
setParameterValue={() => {}}
|
||||
onOpenModal={() => {}}
|
||||
cancelQuery={() => {}}
|
||||
closeSnippetModal={() => {}}
|
||||
handleResize={() => {}}
|
||||
canChangeDatabase
|
||||
toggleEditor={() => {}}
|
||||
onSetDatabaseId={(databaseId: DatabaseId) => {
|
||||
if (modifiedQuestion) {
|
||||
const updatedDatasetQuery = {
|
||||
...modifiedQuestion.datasetQuery(),
|
||||
database: databaseId,
|
||||
};
|
||||
const newQuestion =
|
||||
modifiedQuestion.setDatasetQuery(updatedDatasetQuery);
|
||||
setModifiedQuestion(newQuestion);
|
||||
}
|
||||
}}
|
||||
resizable
|
||||
resizableBoxProps={{
|
||||
height: Math.max(
|
||||
EDITOR_MIN_HEIGHT,
|
||||
Math.floor(
|
||||
window.innerHeight * EDITOR_HEIGHT_RATIO -
|
||||
EDITOR_HEIGHT_OFFSET,
|
||||
),
|
||||
),
|
||||
style: { border: "none" },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Results area */}
|
||||
<Box
|
||||
id="results-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
paddingTop: "0.25rem",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{!isQueryRunning && (sqlError || queryError) ? (
|
||||
<ErrorMessage
|
||||
type="serverError"
|
||||
title={t`Query execution failed`}
|
||||
message={getErrorMessage(sqlError, queryError)}
|
||||
action={null}
|
||||
/>
|
||||
) : !isQueryRunning && hasEmptyResults(datasetToUse) ? (
|
||||
<QueryExecutionEmptyState hasInitialData={!!datasetToUse} />
|
||||
) : !isQueryRunning && rawSeries ? (
|
||||
<Box style={{ flex: 1, minHeight: "300px" }}>
|
||||
<Visualization
|
||||
rawSeries={rawSeries}
|
||||
metadata={metadata}
|
||||
onChangeCardAndRun={() => {}}
|
||||
getExtraDataForClick={() => ({})}
|
||||
isEditing={false}
|
||||
isDashboard={false}
|
||||
showTitle={false}
|
||||
/>
|
||||
</Box>
|
||||
) : !isQueryRunning ? (
|
||||
<QueryExecutionEmptyState hasInitialData={false} />
|
||||
) : !datasetToUse ? (
|
||||
<Box
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Loading overlay for when rerunning queries with existing results */}
|
||||
{isQueryRunning && datasetToUse && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right side - Sidebars */}
|
||||
{/* Template tags sidebar */}
|
||||
{isShowingTemplateTagsEditor && modifiedQuestion && (
|
||||
<Box
|
||||
style={{
|
||||
width: "300px",
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
borderLeft: "1px solid var(--mb-color-border)",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<TagEditorSidebar
|
||||
question={modifiedQuestion}
|
||||
query={modifiedQuestion.legacyNativeQuery()!}
|
||||
onClose={() => setIsShowingTemplateTagsEditor(false)}
|
||||
setDatasetQuery={setDatasetQuery}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Data reference sidebar */}
|
||||
{isShowingDataReference && modifiedQuestion && (
|
||||
<Box
|
||||
style={{
|
||||
width: "350px",
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
borderLeft: "1px solid var(--mb-color-border)",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<DataReference
|
||||
dataReferenceStack={dataReferenceStack}
|
||||
onClose={() => setIsShowingDataReference(false)}
|
||||
popDataReferenceStack={() => {
|
||||
if (dataReferenceStack.length === 1) {
|
||||
setIsShowingDataReference(false);
|
||||
} else {
|
||||
setDataReferenceStack(dataReferenceStack.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
pushDataReferenceStack={(item: DataReferenceStackItem) => {
|
||||
setDataReferenceStack([...dataReferenceStack, item]);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer with actions */}
|
||||
<Box
|
||||
style={{
|
||||
position: "sticky",
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
borderTop: "1px solid var(--mb-color-border)",
|
||||
padding: "1rem",
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={handleSave}
|
||||
disabled={!modifiedQuestion}
|
||||
>
|
||||
{t`Save and use`}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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 @@
|
||||
.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,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,15 @@
|
||||
.codeBlockTextArea {
|
||||
background-color: var(--mb-color-bg-light);
|
||||
color: var(--mb-color-text-primary);
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-lg);
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import {
|
||||
type NodeViewProps,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
} from "@tiptap/react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { Button, Flex, Icon, Text, Tooltip } from "metabase/ui";
|
||||
import MetabotThinkingStyles from "metabase-enterprise/metabot/components/MetabotChat/MetabotThinking.module.css";
|
||||
|
||||
import { useMetabotDocumentQuery } from "../../hooks/useMetabotDocumentQuery";
|
||||
|
||||
import Styles from "./MetabotEmbed.module.css";
|
||||
|
||||
const createTextNode = (str: string) => ({
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: str }],
|
||||
});
|
||||
|
||||
export const MetabotNode = Node.create<{
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}>({
|
||||
name: "metabot",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
text: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-text"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-type="metabot"]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MetabotComponent);
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-type": "metabot",
|
||||
"data-text": node.attrs.text,
|
||||
},
|
||||
this.options.HTMLAttributes,
|
||||
),
|
||||
`{{ metabot:${node.attrs.text} }}`,
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return `{{ metabot:${node.attrs.text} }}`;
|
||||
},
|
||||
});
|
||||
|
||||
export const MetabotComponent = memo(
|
||||
({ editor, getPos, deleteNode, updateAttributes, node }: NodeViewProps) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const queryMetabot = useMetabotDocumentQuery();
|
||||
const { text: prompt } = node.attrs;
|
||||
|
||||
const handleRunMetabot = useCallback(async () => {
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
setIsLoading(true);
|
||||
setErrorText("");
|
||||
editor.commands.focus();
|
||||
|
||||
const { cardId, description, error } = await queryMetabot({
|
||||
prompt: prompt.trim(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (error) {
|
||||
setErrorText(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodePosition = getPos();
|
||||
|
||||
if (nodePosition == null) {
|
||||
setErrorText(t`Could not find Metabot block`);
|
||||
return;
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(nodePosition, {
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: cardId,
|
||||
},
|
||||
})
|
||||
.insertContentAt(
|
||||
nodePosition + 1,
|
||||
createTextNode(`🤖 Created with Metabot 💙`),
|
||||
)
|
||||
.insertContentAt(nodePosition + 1, createTextNode(description))
|
||||
.run();
|
||||
|
||||
deleteNode();
|
||||
}, [prompt, editor, queryMetabot, deleteNode, getPos]);
|
||||
|
||||
const handleStopMetabot = () => {
|
||||
controllerRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
setErrorText("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Grab focus from TipTap editor and put it in textarea when component mounts
|
||||
if (inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
const unfocus = (e: KeyboardEvent) => {
|
||||
if (e.key === "Tab") {
|
||||
inputRef.current?.blur();
|
||||
editor.commands.focus();
|
||||
}
|
||||
};
|
||||
|
||||
inputRef.current?.addEventListener("keydown", unfocus);
|
||||
return () =>
|
||||
inputRef.current?.removeEventListener("keydown", unfocus);
|
||||
}, 50); // Small delay to ensure focus is set after rendering
|
||||
}
|
||||
}, [inputRef, editor.commands]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentInput = inputRef.current;
|
||||
if (currentInput) {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleRunMetabot();
|
||||
}
|
||||
};
|
||||
currentInput.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
currentInput.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleRunMetabot]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
<Flex
|
||||
bg="bg-light"
|
||||
bd="1px solid var(--border-color)"
|
||||
style={{ borderRadius: "4px" }}
|
||||
pos="relative"
|
||||
h={180}
|
||||
direction="column"
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
pos="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
p="sm"
|
||||
m="sm"
|
||||
size="sm"
|
||||
opacity="0.5"
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
<Flex flex={1} direction="column" style={{ overflow: "auto" }}>
|
||||
<textarea
|
||||
disabled={isLoading}
|
||||
ref={inputRef}
|
||||
className={Styles.codeBlockTextArea}
|
||||
value={prompt || ""}
|
||||
onChange={(e) => updateAttributes({ text: e.target.value })}
|
||||
placeholder={t`Can you show me monthly sales data for the past year in a line chart?`}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex px="md" pb="md" pt="xs" gap="sm">
|
||||
<Flex flex={1} my="auto">
|
||||
{isLoading ? (
|
||||
<Text
|
||||
flex={1}
|
||||
className={MetabotThinkingStyles.toolCallStarted}
|
||||
>
|
||||
{t`Working on it...`}
|
||||
</Text>
|
||||
) : errorText ? (
|
||||
<Flex gap="sm" c="text-secondary">
|
||||
<Icon name="warning" h="1.2rem" style={{ flexShrink: 0 }} />
|
||||
<Text c="text-secondary" lh={1.4}>
|
||||
{errorText}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Tooltip
|
||||
label={t`Stop generating`}
|
||||
disabled={!isLoading}
|
||||
position="bottom"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
isLoading ? handleStopMetabot() : handleRunMetabot()
|
||||
}
|
||||
classNames={{
|
||||
label: CS.flex, // ensures icon is vertically centered
|
||||
}}
|
||||
>
|
||||
{isLoading ? <Icon name="close" /> : t`Run`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.node.attrs.text === nextProps.node.attrs.text &&
|
||||
prevProps.selected === nextProps.selected
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MetabotComponent.displayName = "MetabotComponent";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./MetabotEmbed";
|
||||
@@ -0,0 +1,20 @@
|
||||
.smartLink {
|
||||
color: var(--mb-color-brand);
|
||||
font-weight: 700;
|
||||
padding: 0.125rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: 200ms background-color;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mb-color-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: -0.125rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.smartLinkInner {
|
||||
border-bottom: 1px solid var(--mb-color-brand-light);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
|
||||
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { memo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { cardApi } from "metabase/api";
|
||||
import { collectionApi } from "metabase/api/collection";
|
||||
import { dashboardApi } from "metabase/api/dashboard";
|
||||
import { databaseApi } from "metabase/api/database";
|
||||
import { tableApi } from "metabase/api/table";
|
||||
import { getIcon } from "metabase/lib/icon";
|
||||
import { modelToUrl } from "metabase/lib/urls/modelToUrl";
|
||||
import { extractEntityId } from "metabase/lib/urls/utils";
|
||||
import { Icon } from "metabase/ui";
|
||||
import type { SearchModel } from "metabase-types/api";
|
||||
|
||||
import styles from "./SmartLinkNode.module.css";
|
||||
|
||||
export interface SmartLinkAttributes {
|
||||
entityId: number;
|
||||
model: string;
|
||||
}
|
||||
|
||||
// Utility function to parse entity URLs and extract entityId and model
|
||||
export function parseEntityUrl(
|
||||
url: string,
|
||||
siteUrl?: string,
|
||||
): { entityId: number; model: SearchModel } | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Validate URL matches the site-url if provided
|
||||
if (siteUrl) {
|
||||
const siteUrlObj = new URL(siteUrl);
|
||||
|
||||
// Check if origins match
|
||||
if (urlObj.origin !== siteUrlObj.origin) {
|
||||
return null; // URL is not from this Metabase instance
|
||||
}
|
||||
|
||||
// Check if the path starts with the site-url path
|
||||
const siteUrlPath = siteUrlObj.pathname;
|
||||
if (!urlObj.pathname.startsWith(siteUrlPath)) {
|
||||
return null; // URL is not under the correct path
|
||||
}
|
||||
} else {
|
||||
// Fallback: validate against current window origin
|
||||
if (urlObj.origin !== window.location.origin) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove site-url path prefix if present to get the clean pathname
|
||||
let cleanPathname = urlObj.pathname;
|
||||
if (siteUrl) {
|
||||
const siteUrlPath = new URL(siteUrl).pathname;
|
||||
// Remove the site-url path prefix, ensuring we don't create double slashes
|
||||
cleanPathname = urlObj.pathname.replace(siteUrlPath, "");
|
||||
// Ensure the pathname starts with /
|
||||
if (!cleanPathname.startsWith("/")) {
|
||||
cleanPathname = "/" + cleanPathname;
|
||||
}
|
||||
}
|
||||
|
||||
// Match different entity URL patterns
|
||||
const patterns = [
|
||||
{ pattern: /^\/question\/(\d+)/, model: "card" as SearchModel },
|
||||
{ pattern: /^\/model\/(\d+)/, model: "dataset" as SearchModel },
|
||||
{ pattern: /^\/dashboard\/(\d+)/, model: "dashboard" as SearchModel },
|
||||
{ pattern: /^\/collection\/(\d+)/, model: "collection" as SearchModel },
|
||||
{
|
||||
pattern: /^\/browse\/(\d+)\/table\/(\d+)/,
|
||||
model: "table" as SearchModel,
|
||||
idIndex: 2,
|
||||
},
|
||||
{ pattern: /^\/browse\/(\d+)/, model: "database" as SearchModel },
|
||||
];
|
||||
|
||||
for (const { pattern, model, idIndex = 1 } of patterns) {
|
||||
const match = cleanPathname.match(pattern);
|
||||
if (match) {
|
||||
const entityId = extractEntityId(match[idIndex]);
|
||||
if (entityId) {
|
||||
return { entityId, model };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const SmartLinkNode = Node.create<{
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
siteUrl?: string;
|
||||
}>({
|
||||
name: "smartLink",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
priority: 1000, // Higher priority than Link extension (default 100)
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entityId: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const id = element.getAttribute("data-entity-id");
|
||||
return id ? parseInt(id, 10) : null;
|
||||
},
|
||||
},
|
||||
model: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-model"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-type="smart-link"]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const { entityId, model } = node.attrs;
|
||||
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-type": "smart-link",
|
||||
"data-entity-id": entityId,
|
||||
"data-model": model,
|
||||
},
|
||||
this.options.HTMLAttributes,
|
||||
),
|
||||
`{% entity id="${entityId}" model="${model}" %}`,
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
const { entityId, model } = node.attrs;
|
||||
|
||||
return `{% entity id="${entityId}" model="${model}" %}`;
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
nodePasteRule({
|
||||
find: /https?:\/\/[^\s]+/g,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const url = match[0];
|
||||
const parsedEntity = parseEntityUrl(url, this.options.siteUrl);
|
||||
|
||||
if (parsedEntity) {
|
||||
return {
|
||||
entityId: parsedEntity.entityId,
|
||||
model: parsedEntity.model,
|
||||
};
|
||||
}
|
||||
|
||||
return null; // Return null to prevent node creation
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const useEntityData = (entityId: number | null, model: SearchModel | null) => {
|
||||
const cardQuery = cardApi.useGetCardQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || (model !== "card" && model !== "dataset") },
|
||||
);
|
||||
|
||||
const dashboardQuery = dashboardApi.useGetDashboardQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || model !== "dashboard" },
|
||||
);
|
||||
|
||||
const collectionQuery = collectionApi.useGetCollectionQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || model !== "collection" },
|
||||
);
|
||||
|
||||
const tableQuery = tableApi.useGetTableQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || model !== "table" },
|
||||
);
|
||||
|
||||
const databaseQuery = databaseApi.useGetDatabaseQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || model !== "database" },
|
||||
);
|
||||
|
||||
// Determine which query is active and return its state
|
||||
if (model === "card" || model === "dataset") {
|
||||
return {
|
||||
entity: cardQuery.data,
|
||||
isLoading: cardQuery.isLoading,
|
||||
error: cardQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (model === "dashboard") {
|
||||
return {
|
||||
entity: dashboardQuery.data,
|
||||
isLoading: dashboardQuery.isLoading,
|
||||
error: dashboardQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (model === "collection") {
|
||||
return {
|
||||
entity: collectionQuery.data,
|
||||
isLoading: collectionQuery.isLoading,
|
||||
error: collectionQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (model === "table") {
|
||||
return {
|
||||
entity: tableQuery.data,
|
||||
isLoading: tableQuery.isLoading,
|
||||
error: tableQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (model === "database") {
|
||||
return {
|
||||
entity: databaseQuery.data,
|
||||
isLoading: databaseQuery.isLoading,
|
||||
error: databaseQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
return { entity: null, isLoading: false, error: null };
|
||||
};
|
||||
|
||||
export const SmartLinkComponent = memo(
|
||||
({ node }: NodeViewProps) => {
|
||||
const { entityId, model } = node.attrs;
|
||||
const { entity, isLoading, error } = useEntityData(entityId, model);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
<span className={styles.smartLink}>
|
||||
<span className={styles.smartLinkInner}>
|
||||
<Icon name="hourglass" className={styles.icon} />
|
||||
{t`Loading ${model}...`}
|
||||
</span>
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entity) {
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
<span className={styles.smartLink}>
|
||||
<span className={styles.smartLinkInner}>
|
||||
<Icon name="warning" className={styles.icon} />
|
||||
{error ? t`Failed to load` : t`Unknown`} {model}
|
||||
</span>
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const entityUrl = modelToUrl({
|
||||
id: entity.id,
|
||||
model: entity.model || model,
|
||||
name: entity.name,
|
||||
database: entity.db_id
|
||||
? { id: entity.db_id }
|
||||
: entity.database_id
|
||||
? { id: entity.database_id }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const iconData = getIcon({
|
||||
model: entity.model || model,
|
||||
display: entity.display,
|
||||
is_personal: entity.is_personal,
|
||||
});
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
<a
|
||||
href={entityUrl || "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onMouseUp={(e) => {
|
||||
// Stop tiptap from opening this link twice
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={styles.smartLink}
|
||||
>
|
||||
<span className={styles.smartLinkInner}>
|
||||
<Icon name={iconData.name} className={styles.icon} />
|
||||
{entity.name}
|
||||
</span>
|
||||
</a>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function to prevent re-renders
|
||||
// Only re-render if these specific props change
|
||||
return (
|
||||
prevProps.node.attrs.entityId === nextProps.node.attrs.entityId &&
|
||||
prevProps.node.attrs.model === nextProps.node.attrs.model &&
|
||||
prevProps.selected === nextProps.selected
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SmartLinkComponent.displayName = "SmartLinkComponent";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import { SmartLinkComponent, SmartLinkNode } from "./SmartLinkNode";
|
||||
|
||||
export * from "./SmartLinkNode";
|
||||
|
||||
export const SmartLinkEmbed = SmartLinkNode.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(SmartLinkComponent);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Shared utility for generating card embed markdown format
|
||||
export function formatCardEmbed(attrs: { id: number; name?: string }): string {
|
||||
if (attrs.name) {
|
||||
return `{% card id=${attrs.id} name="${attrs.name}" %}`;
|
||||
} else {
|
||||
return `{% card id=${attrs.id} %}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared utility for generating smart link markdown format
|
||||
export function formatSmartLink(attrs: {
|
||||
entityId?: number;
|
||||
model?: string;
|
||||
}): string {
|
||||
return `{% entity id="${attrs.entityId}" model="${attrs.model}" %}`;
|
||||
}
|
||||
|
||||
// Shared utility for generating spacer markdown format
|
||||
export function formatSpacer(lines: number): string {
|
||||
return `{% spacer lines=${lines} %}`;
|
||||
}
|
||||
|
||||
// Regex pattern for matching card embeds in markdown
|
||||
export const CARD_EMBED_PATTERN =
|
||||
/{%\s*card\s+id=(\d+)(?:\s+name="([^"]*)")?\s*%}/g;
|
||||
|
||||
// Regex pattern for matching smart links in markdown
|
||||
export const SMART_LINK_PATTERN =
|
||||
/{%\s*entity\s+id="(\d+)"\s+model="([^"]+)"\s*%}/g;
|
||||
|
||||
// Regex pattern for matching spacers in markdown
|
||||
export const SPACER_PATTERN = /{%\s*spacer\s+lines=(\d+)\s*%}/g;
|
||||
@@ -0,0 +1,84 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type { ParentType, StateBlock } from "markdown-it";
|
||||
|
||||
export function cardPlugin(md: MarkdownIt): void {
|
||||
md.block.ruler.before("paragraph", "card", cardBlock, {
|
||||
alt: ["paragraph", "reference", "blockquote", "list"],
|
||||
});
|
||||
}
|
||||
|
||||
function cardBlock(
|
||||
state: StateBlock,
|
||||
startLine: number,
|
||||
_endLine: number,
|
||||
silent: boolean,
|
||||
): boolean {
|
||||
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
// Check if line starts with {% card
|
||||
if (pos + 7 > max) {
|
||||
return false;
|
||||
}
|
||||
if (state.src.slice(pos, pos + 7) !== "{% card") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the closing %}
|
||||
const closePos = state.src.indexOf("%}", pos + 7);
|
||||
if (closePos === -1 || closePos > max) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract attributes
|
||||
const content = state.src.slice(pos + 7, closePos).trim();
|
||||
const attrs = parseCardAttributes(content);
|
||||
|
||||
if (!attrs.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldParent = state.parentType;
|
||||
const oldLineMax = state.lineMax;
|
||||
state.parentType = "card" as ParentType;
|
||||
|
||||
// Create card token
|
||||
const token = state.push("card", "div", 0);
|
||||
token.attrSet("id", attrs.id);
|
||||
if (attrs.name) {
|
||||
token.attrSet("name", attrs.name);
|
||||
}
|
||||
|
||||
token.map = [startLine, startLine + 1];
|
||||
|
||||
state.parentType = oldParent;
|
||||
state.lineMax = oldLineMax;
|
||||
state.line = startLine + 1;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseCardAttributes(content: string): {
|
||||
id?: string;
|
||||
name?: string;
|
||||
} {
|
||||
const attrs: { id?: string; name?: string } = {};
|
||||
|
||||
// Match id=123
|
||||
const idMatch = content.match(/id=(\d+)/);
|
||||
if (idMatch) {
|
||||
attrs.id = idMatch[1];
|
||||
}
|
||||
|
||||
// Match name="..." (with proper quote handling)
|
||||
const nameMatch = content.match(/name="([^"]*)"/);
|
||||
if (nameMatch) {
|
||||
attrs.name = nameMatch[1];
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import {
|
||||
MarkdownParser,
|
||||
MarkdownSerializer,
|
||||
type ParseSpec,
|
||||
} from "prosemirror-markdown";
|
||||
|
||||
import { cardPlugin } from "./cardPlugin";
|
||||
import { linkPlugin } from "./linkPlugin";
|
||||
import { createParserTokens } from "./markdown-to-prosemirror";
|
||||
import {
|
||||
createMarkSerializers,
|
||||
createNodeSerializers,
|
||||
} from "./prosemirror-to-markdown";
|
||||
import { spacerPlugin } from "./spacerPlugin";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
markdown: {
|
||||
getMarkdown: () => string extends ReturnType ? string : ReturnType;
|
||||
setMarkdown: (markdown: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
|
||||
interface Storage {
|
||||
markdown: MarkdownStorage;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarkdownStorage {
|
||||
getMarkdown: () => string;
|
||||
setMarkdown: (markdown: string, emit?: boolean) => void;
|
||||
parser: MarkdownParser | null;
|
||||
serializer: MarkdownSerializer | null;
|
||||
}
|
||||
|
||||
export const Markdown = Extension.create<
|
||||
{
|
||||
extraNodeSerializers?: MarkdownSerializer["nodes"];
|
||||
extraTokenSpecs?: Record<string, ParseSpec>;
|
||||
extraMarkSerializers?: MarkdownSerializer["marks"];
|
||||
},
|
||||
MarkdownStorage
|
||||
>({
|
||||
name: "markdown",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
extraNodeSerializers: {},
|
||||
extraTokenSpecs: {},
|
||||
extraMarkSerializers: {},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
parser: null,
|
||||
serializer: null,
|
||||
getMarkdown: () => "",
|
||||
setMarkdown: () => {},
|
||||
} as MarkdownStorage;
|
||||
},
|
||||
|
||||
onBeforeCreate() {
|
||||
const getSerializer = () => {
|
||||
if (this.storage.serializer) {
|
||||
return this.storage.serializer;
|
||||
}
|
||||
|
||||
const schema = this.editor.state.schema;
|
||||
const nodeSerializers = createNodeSerializers(
|
||||
this.options.extraNodeSerializers,
|
||||
);
|
||||
const markSerializers = createMarkSerializers(
|
||||
this.options.extraMarkSerializers,
|
||||
);
|
||||
|
||||
// Validate that all nodes in schema have serializers
|
||||
const missingNodeSerializers = Object.keys(schema.nodes).filter(
|
||||
(nodeName) => !nodeSerializers[nodeName],
|
||||
);
|
||||
if (missingNodeSerializers.length > 0) {
|
||||
console.error(
|
||||
`Missing serializers for nodes: ${missingNodeSerializers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that all marks in schema have serializers
|
||||
const missingMarkSerializers = Object.keys(schema.marks).filter(
|
||||
(markName) => !markSerializers[markName],
|
||||
);
|
||||
if (missingMarkSerializers.length > 0) {
|
||||
throw new Error(
|
||||
`Missing serializers for marks: ${missingMarkSerializers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.storage.serializer = new MarkdownSerializer(
|
||||
nodeSerializers,
|
||||
markSerializers,
|
||||
);
|
||||
|
||||
return this.storage.serializer;
|
||||
};
|
||||
|
||||
const getParser = () => {
|
||||
if (this.storage.parser) {
|
||||
return this.storage.parser;
|
||||
}
|
||||
|
||||
const schema = this.editor.state.schema;
|
||||
const md = new MarkdownIt("commonmark", {
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: false,
|
||||
});
|
||||
|
||||
md.use(spacerPlugin);
|
||||
md.use(cardPlugin);
|
||||
md.use(linkPlugin);
|
||||
|
||||
// Add a rule to expand spacer tokens to multiple empty paragraphs
|
||||
md.core.ruler.after("block", "expand_spacers", (state) => {
|
||||
const tokens = state.tokens;
|
||||
const newTokens = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token.type === "spacer") {
|
||||
const lines = parseInt(token.attrGet("lines") ?? "1", 10);
|
||||
|
||||
// Create multiple empty paragraph tokens
|
||||
for (let j = 0; j < lines; j++) {
|
||||
const pOpen = new state.Token("paragraph_open", "p", 1);
|
||||
pOpen.block = true;
|
||||
pOpen.map = token.map; // Preserve source mapping
|
||||
|
||||
const inline = new state.Token("inline", "", 0);
|
||||
inline.content = "";
|
||||
inline.children = [];
|
||||
inline.nesting = 0;
|
||||
|
||||
const pClose = new state.Token("paragraph_close", "p", -1);
|
||||
pClose.block = true;
|
||||
|
||||
newTokens.push(pOpen, inline, pClose);
|
||||
}
|
||||
} else {
|
||||
newTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
state.tokens = newTokens;
|
||||
});
|
||||
|
||||
const tokens = createParserTokens(this.options.extraTokenSpecs);
|
||||
|
||||
this.storage.parser = new MarkdownParser(schema, md, tokens);
|
||||
return this.storage.parser;
|
||||
};
|
||||
|
||||
this.storage.getMarkdown = () => {
|
||||
return getSerializer().serialize(this.editor.state.doc, {
|
||||
tightLists: false, // Allow loose lists to preserve spacing
|
||||
});
|
||||
};
|
||||
|
||||
this.storage.setMarkdown = (markdown: string) => {
|
||||
try {
|
||||
if (!markdown || typeof markdown !== "string") {
|
||||
console.warn("Invalid markdown content provided:", markdown);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = getParser().parse(markdown);
|
||||
if (!doc) {
|
||||
console.warn("Failed to parse markdown into document");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.editor || this.editor.isDestroyed) {
|
||||
console.warn("Editor is not available or destroyed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the editor is ready and has a valid schema
|
||||
if (!this.editor.state || !this.editor.state.schema) {
|
||||
console.warn("Editor not ready for content update");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that the document matches the schema
|
||||
try {
|
||||
const docJSON = doc.toJSON();
|
||||
this.editor.chain().setContent(docJSON).run();
|
||||
} catch (validationError) {
|
||||
console.error("Document validation failed:", validationError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse and set markdown content:", error);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setMarkdown: (markdown: string) => () => {
|
||||
this.storage.setMarkdown(markdown);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type { StateInline } from "markdown-it";
|
||||
|
||||
export function linkPlugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.before("text", "smartlink", smartLinkInline);
|
||||
}
|
||||
|
||||
function smartLinkInline(state: StateInline, silent: boolean): boolean {
|
||||
const pos = state.pos;
|
||||
const max = state.posMax;
|
||||
|
||||
// Check if we have enough characters for {% entity
|
||||
if (pos + 9 > max) {
|
||||
return false;
|
||||
}
|
||||
if (state.src.slice(pos, pos + 9) !== "{% entity") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the closing %}
|
||||
const closePos = state.src.indexOf("%}", pos + 9);
|
||||
if (closePos === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract attributes
|
||||
const content = state.src.slice(pos + 9, closePos).trim();
|
||||
const attrs = parseEntityAttributes(content);
|
||||
|
||||
if (!attrs.id || !attrs.model) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
const token = state.push("smartlink", "", 0);
|
||||
token.attrSet("id", attrs.id);
|
||||
token.attrSet("model", attrs.model);
|
||||
}
|
||||
|
||||
state.pos = closePos + 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseEntityAttributes(content: string): {
|
||||
id?: string;
|
||||
model?: string;
|
||||
} {
|
||||
const attrs: { id?: string; model?: string } = {};
|
||||
|
||||
// Match id="..."
|
||||
const idMatch = content.match(/id="([^"]*)"/);
|
||||
if (idMatch) {
|
||||
attrs.id = idMatch[1];
|
||||
}
|
||||
|
||||
// Match model="..."
|
||||
const modelMatch = content.match(/model="([^"]*)"/);
|
||||
if (modelMatch) {
|
||||
attrs.model = modelMatch[1];
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
import Bold from "@tiptap/extension-bold";
|
||||
import BulletList from "@tiptap/extension-bullet-list";
|
||||
import Code from "@tiptap/extension-code";
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Italic from "@tiptap/extension-italic";
|
||||
import ListItem from "@tiptap/extension-list-item";
|
||||
import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Strike from "@tiptap/extension-strike";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import type { Token } from "markdown-it";
|
||||
import type { ParseSpec } from "prosemirror-markdown";
|
||||
|
||||
import { CardEmbedNode } from "../CardEmbed/CardEmbedNode";
|
||||
import { SmartLinkNode } from "../SmartLink/SmartLinkNode";
|
||||
|
||||
// Parser tokens - how markdown tokens are converted to ProseMirror nodes/marks
|
||||
export const PARSER_TOKENS: Record<string, ParseSpec> = {
|
||||
// Basic blocks
|
||||
paragraph: { block: Paragraph.name },
|
||||
blockquote: { block: Blockquote.name },
|
||||
heading: {
|
||||
block: Heading.name,
|
||||
getAttrs: (tok: Token) => ({ level: +tok.tag.slice(1) }),
|
||||
},
|
||||
|
||||
// Lists
|
||||
bullet_list: { block: BulletList.name },
|
||||
ordered_list: {
|
||||
block: OrderedList.name,
|
||||
getAttrs: (tok: Token) => ({ order: +(tok.attrGet("start") ?? 1) }),
|
||||
},
|
||||
list_item: { block: ListItem.name },
|
||||
|
||||
// Code
|
||||
code_block: { block: CodeBlock.name, noCloseToken: true },
|
||||
fence: {
|
||||
block: CodeBlock.name,
|
||||
getAttrs: (tok: Token) => ({ params: tok.info || "" }),
|
||||
noCloseToken: true,
|
||||
},
|
||||
|
||||
// Other nodes
|
||||
hr: { node: HorizontalRule.name },
|
||||
hardbreak: { node: HardBreak.name },
|
||||
image: {
|
||||
node: Image.name,
|
||||
getAttrs: (tok: Token) => ({
|
||||
src: tok.attrGet("src"),
|
||||
alt: tok.content || null,
|
||||
title: tok.attrGet("title") || null,
|
||||
}),
|
||||
},
|
||||
|
||||
// Marks
|
||||
strong: { mark: Bold.name },
|
||||
em: { mark: Italic.name },
|
||||
code_inline: { mark: Code.name, noCloseToken: true },
|
||||
link: {
|
||||
mark: "link",
|
||||
getAttrs: (tok: Token) => ({
|
||||
href: tok.attrGet("href"),
|
||||
title: tok.attrGet("title") || null,
|
||||
}),
|
||||
},
|
||||
s: { mark: Strike.name },
|
||||
u: { mark: Underline.name },
|
||||
|
||||
// Custom nodes
|
||||
card: {
|
||||
node: CardEmbedNode.name,
|
||||
getAttrs: (tok: Token) => ({
|
||||
id: parseInt(tok.attrGet("id") || "0", 10),
|
||||
name: tok.attrGet("name") || null,
|
||||
}),
|
||||
},
|
||||
smartlink: {
|
||||
node: SmartLinkNode.name,
|
||||
getAttrs: (tok: Token) => ({
|
||||
entityId: parseInt(tok.attrGet("id") || "0", 10),
|
||||
model: tok.attrGet("model"),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export function createParserTokens(
|
||||
extraTokens: Record<string, ParseSpec> = {},
|
||||
): Record<string, ParseSpec> {
|
||||
return { ...PARSER_TOKENS, ...extraTokens };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
import Bold from "@tiptap/extension-bold";
|
||||
import BulletList from "@tiptap/extension-bullet-list";
|
||||
import Code from "@tiptap/extension-code";
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Italic from "@tiptap/extension-italic";
|
||||
import ListItem from "@tiptap/extension-list-item";
|
||||
import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Strike from "@tiptap/extension-strike";
|
||||
import Text from "@tiptap/extension-text";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import {
|
||||
type MarkdownSerializer,
|
||||
defaultMarkdownSerializer,
|
||||
} from "prosemirror-markdown";
|
||||
|
||||
import { CardEmbedNode } from "../CardEmbed/CardEmbedNode";
|
||||
import { SmartLinkNode } from "../SmartLink/SmartLinkNode";
|
||||
|
||||
import {
|
||||
formatCardEmbed,
|
||||
formatSmartLink,
|
||||
formatSpacer,
|
||||
} from "./card-embed-format";
|
||||
|
||||
export const NODE_SERIALIZERS: MarkdownSerializer["nodes"] = {
|
||||
doc: (state, node) => {
|
||||
state.renderContent(node);
|
||||
},
|
||||
|
||||
// Text nodes
|
||||
[Text.name]: defaultMarkdownSerializer.nodes.text,
|
||||
[Paragraph.name]: (state, node, parent, index) => {
|
||||
// Handle empty paragraphs at document level
|
||||
if (parent.type.name === "doc" && node.content.size === 0) {
|
||||
// Look ahead to find consecutive empty paragraphs
|
||||
let emptyCount = 1; // Current paragraph is empty
|
||||
let nextIndex = index + 1;
|
||||
|
||||
while (nextIndex < parent.childCount) {
|
||||
const nextChild = parent.child(nextIndex);
|
||||
if (
|
||||
nextChild.type.name === "paragraph" &&
|
||||
nextChild.content.size === 0
|
||||
) {
|
||||
emptyCount++;
|
||||
nextIndex++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only output spacer for the FIRST empty paragraph in a sequence
|
||||
// The rest will be skipped by checking if we're in the middle of a sequence
|
||||
let isFirstInSequence = true;
|
||||
if (index > 0) {
|
||||
const prevChild = parent.child(index - 1);
|
||||
if (
|
||||
prevChild.type.name === "paragraph" &&
|
||||
prevChild.content.size === 0
|
||||
) {
|
||||
isFirstInSequence = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirstInSequence) {
|
||||
state.write(formatSpacer(emptyCount));
|
||||
state.closeBlock(node);
|
||||
}
|
||||
// Skip subsequent empty paragraphs in the sequence
|
||||
} else {
|
||||
// Handle non-empty paragraphs or paragraphs in nested contexts normally
|
||||
defaultMarkdownSerializer.nodes.paragraph(state, node, parent, index);
|
||||
}
|
||||
},
|
||||
|
||||
// Headings
|
||||
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
|
||||
|
||||
// Lists
|
||||
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
|
||||
[OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
|
||||
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
|
||||
|
||||
// Blocks
|
||||
[Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
|
||||
[CodeBlock.name]: defaultMarkdownSerializer.nodes.code_block,
|
||||
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
|
||||
|
||||
// Breaks
|
||||
[HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
|
||||
|
||||
// Image (from TipTap Image extension)
|
||||
[Image.name]: defaultMarkdownSerializer.nodes.image,
|
||||
|
||||
// Card embed nodes
|
||||
[CardEmbedNode.name]: (state, node) => {
|
||||
state.write(formatCardEmbed(node.attrs));
|
||||
state.closeBlock(node);
|
||||
},
|
||||
|
||||
// Smart link nodes
|
||||
[SmartLinkNode.name]: (state, node) => {
|
||||
state.write(formatSmartLink(node.attrs));
|
||||
},
|
||||
};
|
||||
|
||||
// Mark serializers - how ProseMirror marks are converted to markdown
|
||||
export const MARK_SERIALIZERS: MarkdownSerializer["marks"] = {
|
||||
[Bold.name]: defaultMarkdownSerializer.marks.strong,
|
||||
[Italic.name]: defaultMarkdownSerializer.marks.em,
|
||||
[Code.name]: defaultMarkdownSerializer.marks.code,
|
||||
link: defaultMarkdownSerializer.marks.link,
|
||||
[Strike.name]: {
|
||||
open: "~~",
|
||||
close: "~~",
|
||||
mixable: true,
|
||||
expelEnclosingWhitespace: true,
|
||||
},
|
||||
[Underline.name]: {
|
||||
open: "<u>",
|
||||
close: "</u>",
|
||||
mixable: true,
|
||||
expelEnclosingWhitespace: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function createNodeSerializers(
|
||||
extraSerializers: MarkdownSerializer["nodes"] = {},
|
||||
): MarkdownSerializer["nodes"] {
|
||||
return { ...NODE_SERIALIZERS, ...extraSerializers };
|
||||
}
|
||||
|
||||
export function createMarkSerializers(
|
||||
extraSerializers: MarkdownSerializer["marks"] = {},
|
||||
): MarkdownSerializer["marks"] {
|
||||
return { ...MARK_SERIALIZERS, ...extraSerializers };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type { StateBlock } from "markdown-it";
|
||||
|
||||
export const spacerPlugin = (md: MarkdownIt) => {
|
||||
const RE = /^\{%[ \t]*spacer[ \t]+lines=(\d+)[ \t]*%}[ \t]*$/;
|
||||
|
||||
function spacerRule(
|
||||
state: StateBlock,
|
||||
startLine: number,
|
||||
_endLine: number,
|
||||
silent: boolean,
|
||||
) {
|
||||
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
const lineStr = state.src.slice(pos, max);
|
||||
|
||||
const m = RE.exec(lineStr);
|
||||
if (!m) {
|
||||
return false;
|
||||
}
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = state.push("spacer", "", 0);
|
||||
token.block = true;
|
||||
token.map = [startLine, startLine + 1];
|
||||
token.attrSet("lines", m[1]);
|
||||
|
||||
state.line = startLine + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
md.block.ruler.before("fence", "spacer", spacerRule, {
|
||||
alt: ["paragraph", "blockquote", "list"],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useCardEmbedSelection as useQuestionSelection } from "./useCardEmbedSelection";
|
||||
export { useCardEmbedsTracking } from "./useCardEmbedsTracking";
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook that tracks question selection in the Tiptap editor.
|
||||
*
|
||||
* This hook monitors the editor's selection state and detects when:
|
||||
* - A question embed node is directly selected
|
||||
* - A question static node is directly selected
|
||||
* - The cursor is positioned inside a question embed
|
||||
* - The selection is cleared (ESC key or selection outside questions)
|
||||
*
|
||||
* @param editor - The Tiptap editor instance
|
||||
* @param onCardSelect - Callback function that receives the selected question ID or null
|
||||
*/
|
||||
export const useCardEmbedSelection = (
|
||||
editor: TiptapEditor | null,
|
||||
onCardSelect:
|
||||
| ((cardId: number | null, embedIndex?: number | null) => void)
|
||||
| undefined,
|
||||
): void => {
|
||||
useEffect(() => {
|
||||
if (!editor || !onCardSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the current selection and determines if a question is selected.
|
||||
* Handles both direct node selection and cursor position within question embeds.
|
||||
*/
|
||||
const updateSelection = () => {
|
||||
const { selection } = editor.state;
|
||||
const { doc } = editor.state;
|
||||
|
||||
const nodeAtCursor = doc.nodeAt(selection.from);
|
||||
|
||||
if (nodeAtCursor && nodeAtCursor.type.name === "cardEmbed") {
|
||||
// Find which embed index this node corresponds to
|
||||
let embedIndex = -1;
|
||||
let nodeCount = 0;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === "cardEmbed") {
|
||||
if (pos === selection.from) {
|
||||
embedIndex = nodeCount;
|
||||
return false;
|
||||
}
|
||||
nodeCount++;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
onCardSelect(nodeAtCursor.attrs.id, embedIndex);
|
||||
} else {
|
||||
onCardSelect(null, null);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial selection check
|
||||
updateSelection();
|
||||
|
||||
// Listen for selection changes
|
||||
editor.on("selectionUpdate", updateSelection);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
editor.off("selectionUpdate", updateSelection);
|
||||
};
|
||||
}, [editor, onCardSelect]);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { CardEmbedRef } from "../types";
|
||||
|
||||
/**
|
||||
* Extracts all question references from the editor document.
|
||||
*
|
||||
* This function traverses the entire document tree and collects references to
|
||||
* question embeds.
|
||||
*
|
||||
* @param editor - The Tiptap editor instance
|
||||
* @returns Array of question embed references found in the document
|
||||
*/
|
||||
const extractCardEmbeds = (editor: TiptapEditor): CardEmbedRef[] => {
|
||||
const refs: CardEmbedRef[] = [];
|
||||
|
||||
editor.state.doc.descendants((node: any) => {
|
||||
// Handle regular question embeds
|
||||
if (node.type.name === "cardEmbed") {
|
||||
refs.push({
|
||||
id: node.attrs.id,
|
||||
name: node.attrs.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return refs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that tracks all question references in the Tiptap editor document.
|
||||
*
|
||||
* This hook monitors the editor content and maintains an up-to-date list of all
|
||||
* question embeds. It automatically updates whenever the document changes and
|
||||
* notifies the parent component through the provided callback.
|
||||
*
|
||||
* Features:
|
||||
* - Tracks question embeds
|
||||
* - Updates on every document change
|
||||
*
|
||||
* @param editor - The Tiptap editor instance
|
||||
* @param onCardEmbedsChange - Callback function that receives the current list of question references
|
||||
*/
|
||||
export const useCardEmbedsTracking = (
|
||||
editor: TiptapEditor | null,
|
||||
onCardEmbedsChange: ((refs: CardEmbedRef[]) => void) | undefined,
|
||||
): void => {
|
||||
useEffect(() => {
|
||||
if (!editor || !onCardEmbedsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the question references by scanning the entire document.
|
||||
* Called initially and on every document update.
|
||||
*/
|
||||
const updateCardEmbeds = () => {
|
||||
const refs = extractCardEmbeds(editor);
|
||||
onCardEmbedsChange(refs);
|
||||
};
|
||||
|
||||
// Listen for document updates
|
||||
editor.on("update", updateCardEmbeds);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
editor.off("update", updateCardEmbeds);
|
||||
};
|
||||
}, [editor, onCardEmbedsChange]);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useCreateCardMutation } from "metabase/api";
|
||||
import { uuid } from "metabase/lib/uuid";
|
||||
import { aiStreamingQuery } from "metabase-enterprise/api/ai-streaming";
|
||||
|
||||
const ENDPOINT = "/api/ee/metabot-v3/v2/agent-streaming"; // TODO: we should get our own endpoint
|
||||
const EXTRA_PROMPT = ", do not ask me any further clarifying questions";
|
||||
|
||||
export const useMetabotDocumentQuery = () => {
|
||||
const [createQuestion] = useCreateCardMutation();
|
||||
|
||||
const queryMetabot = useCallback(
|
||||
async ({ prompt, signal }: { prompt: string; signal?: AbortSignal }) => {
|
||||
const messageId = uuid();
|
||||
|
||||
const response = await aiStreamingQuery(
|
||||
{
|
||||
url: ENDPOINT,
|
||||
signal,
|
||||
body: {
|
||||
message: prompt + EXTRA_PROMPT,
|
||||
conversation_id: messageId,
|
||||
context: { user_is_viewing: [] },
|
||||
history: [],
|
||||
state: {},
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
// FIXME: get the backend to do all this processing - this is just a hacky prototype
|
||||
const description = response?.text;
|
||||
|
||||
const navigateTo = response.data.find(
|
||||
(d: any) => d.type === "navigate_to",
|
||||
);
|
||||
|
||||
if (!navigateTo) {
|
||||
return { error: description };
|
||||
}
|
||||
|
||||
const encodedQuery = navigateTo?.value?.replace?.("/question#", "");
|
||||
|
||||
let query = {};
|
||||
|
||||
try {
|
||||
query = JSON.parse(atob(encodedQuery));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse query", e, encodedQuery);
|
||||
return { error: description };
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return { error: description };
|
||||
}
|
||||
|
||||
const createQuestionResponse = await createQuestion({
|
||||
visualization_settings: {},
|
||||
...(query as any),
|
||||
type: "question",
|
||||
name: "Exploration",
|
||||
});
|
||||
|
||||
const questionId = createQuestionResponse?.data?.id;
|
||||
|
||||
if (!questionId) {
|
||||
return { error: t`Failed to create question from Metabot response` };
|
||||
}
|
||||
|
||||
return {
|
||||
cardId: questionId,
|
||||
description: description || t`Data exploration`,
|
||||
};
|
||||
},
|
||||
[createQuestion],
|
||||
);
|
||||
|
||||
return queryMetabot;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Editor";
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface CardEmbedRef {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Icon,
|
||||
Loader,
|
||||
Menu,
|
||||
Stack,
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import { QuestionChartSettings } from "metabase/visualizations/components/ChartSettings";
|
||||
import type {
|
||||
CardDisplayType,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import {
|
||||
closeSidebar,
|
||||
updateVisualizationType,
|
||||
updateVizSettings,
|
||||
} from "../documents.slice";
|
||||
import { useCardWithDataset } from "../hooks/useCardWithDataset";
|
||||
import { useDraftCardOperations } from "../hooks/useDraftCardOperations";
|
||||
import { useDocumentsSelector } from "../redux-utils";
|
||||
import { getSelectedEmbedIndex } from "../selectors";
|
||||
import { useVisualizationOptions } from "../utils/visualizationUtils";
|
||||
|
||||
interface EmbedQuestionSettingsSidebarProps {
|
||||
cardId: number;
|
||||
onClose: () => void;
|
||||
editorInstance?: Editor;
|
||||
}
|
||||
|
||||
export const EmbedQuestionSettingsSidebar = ({
|
||||
cardId,
|
||||
editorInstance,
|
||||
}: EmbedQuestionSettingsSidebarProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const selectedEmbedIndex = useDocumentsSelector(getSelectedEmbedIndex);
|
||||
|
||||
// Use extracted hook for card and dataset fetching
|
||||
const {
|
||||
cardWithDraft,
|
||||
dataset,
|
||||
isResultsLoading,
|
||||
series,
|
||||
question,
|
||||
isCardLoading,
|
||||
draftCard,
|
||||
card,
|
||||
regularDataset,
|
||||
} = useCardWithDataset(cardId);
|
||||
|
||||
// Use extracted hook for visualization options
|
||||
const { sensibleItems, nonsensibleItems, selectedElem } =
|
||||
useVisualizationOptions(dataset, cardWithDraft?.display as CardDisplayType);
|
||||
|
||||
// Use extracted hook for draft card operations
|
||||
const { ensureDraftCard } = useDraftCardOperations(
|
||||
draftCard,
|
||||
card,
|
||||
cardId,
|
||||
editorInstance,
|
||||
selectedEmbedIndex,
|
||||
regularDataset,
|
||||
);
|
||||
|
||||
const handleSettingsChange = (settings: VisualizationSettings) => {
|
||||
if (selectedEmbedIndex !== null) {
|
||||
// If no draft exists, create one with the current settings change
|
||||
if (!draftCard) {
|
||||
const baseCard = card;
|
||||
const newSettings = {
|
||||
...baseCard?.visualization_settings,
|
||||
...settings,
|
||||
};
|
||||
const actualCardId = ensureDraftCard(
|
||||
{ visualization_settings: newSettings },
|
||||
true,
|
||||
);
|
||||
// Use the returned ID (might be different if this was a duplicate)
|
||||
dispatch(updateVizSettings({ cardId: actualCardId, settings }));
|
||||
} else {
|
||||
// Draft already exists, just update it
|
||||
dispatch(updateVizSettings({ cardId, settings }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisualizationTypeChange = (display: CardDisplayType) => {
|
||||
if (selectedEmbedIndex !== null) {
|
||||
// If no draft exists, create one with the current display change
|
||||
if (!draftCard) {
|
||||
const actualCardId = ensureDraftCard({ display }, true);
|
||||
// Use the returned ID (might be different if this was a duplicate)
|
||||
dispatch(updateVisualizationType({ cardId: actualCardId, display }));
|
||||
} else {
|
||||
// Draft already exists, just update it
|
||||
dispatch(updateVisualizationType({ cardId, display }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = useCallback(() => {
|
||||
// Simply close the sidebar - draft changes are kept in Redux
|
||||
dispatch(closeSidebar());
|
||||
}, [dispatch]);
|
||||
|
||||
if (isCardLoading || isResultsLoading || !series) {
|
||||
return (
|
||||
<Stack gap="lg" p="lg" style={{ height: "100%" }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
<Text>{t`Loading question settings...`}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cardWithDraft || !series) {
|
||||
return (
|
||||
<Stack gap="lg" p="lg" style={{ height: "100%" }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Text color="error">{t`Failed to load question`}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "stretch",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mb-color-border)",
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
}}
|
||||
>
|
||||
<Group w="100%" justify="space-between" align="flex-start">
|
||||
<Group align="center" p="md">
|
||||
<Text size="md" fw="bold">{t`Visualize as`}</Text>
|
||||
<Menu position="bottom-start">
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!selectedElem}
|
||||
rightSection={<Icon ml="xs" size={10} name="chevrondown" />}
|
||||
leftSection={
|
||||
selectedElem?.iconName ? (
|
||||
<Icon name={selectedElem.iconName} />
|
||||
) : null
|
||||
}
|
||||
justify="space-between"
|
||||
>
|
||||
{selectedElem?.label}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{sensibleItems.map(({ iconName, label, value }, index) => (
|
||||
<Menu.Item
|
||||
key={`${value}/${index}`}
|
||||
onClick={() => handleVisualizationTypeChange(value)}
|
||||
leftSection={iconName ? <Icon name={iconName} /> : null}
|
||||
>
|
||||
{label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
|
||||
{nonsensibleItems.length > 0 && (
|
||||
<>
|
||||
<Menu.Label>{t`Other charts`}</Menu.Label>
|
||||
{nonsensibleItems.map(
|
||||
({ iconName, label, value }, index) => (
|
||||
<Menu.Item
|
||||
key={`${value}/${index}`}
|
||||
onClick={() => handleVisualizationTypeChange(value)}
|
||||
leftSection={
|
||||
iconName ? <Icon name={iconName} /> : null
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Menu.Item>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
|
||||
<ActionIcon
|
||||
mt="1rem"
|
||||
mr="1rem"
|
||||
color="text-dark"
|
||||
onClick={handleDone}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
<Box style={{ flex: 1, overflow: "auto" }}>
|
||||
<QuestionChartSettings
|
||||
question={question}
|
||||
series={series}
|
||||
onChange={handleSettingsChange}
|
||||
computedSettings={cardWithDraft.visualization_settings || {}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
borderTop: "1px solid var(--mb-color-border)",
|
||||
padding: "1rem",
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
}}
|
||||
>
|
||||
<Button fullWidth variant="filled" onClick={handleDone}>
|
||||
{t`Done`}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
getChartImagePngDataUri,
|
||||
getChartSelector,
|
||||
} from "metabase/visualizations/lib/image-exports";
|
||||
|
||||
import {
|
||||
CARD_EMBED_PATTERN,
|
||||
SMART_LINK_PATTERN,
|
||||
SPACER_PATTERN,
|
||||
} from "./Editor/extensions/markdown/card-embed-format";
|
||||
import type { CardEmbedRef } from "./Editor/types";
|
||||
|
||||
// Helper function to convert a question to PNG data URL using existing Metabase utilities
|
||||
export const convertQuestionToPng = async (
|
||||
cardId: number,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
// Use the existing chart selection and PNG generation system
|
||||
const cardSelector = getChartSelector({ cardId });
|
||||
const pngDataUrl = await getChartImagePngDataUri(cardSelector);
|
||||
return pngDataUrl || null;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to convert question ${cardId} to PNG:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDownloadableMarkdown = async (
|
||||
markdown: string,
|
||||
cardEmbeds: CardEmbedRef[],
|
||||
): Promise<string> => {
|
||||
let processedMarkdown = markdown;
|
||||
|
||||
// First, remove all spacers
|
||||
processedMarkdown = processedMarkdown.replace(SPACER_PATTERN, "");
|
||||
|
||||
// Replace smart links with regular markdown links
|
||||
processedMarkdown = processedMarkdown.replace(
|
||||
SMART_LINK_PATTERN,
|
||||
(match, url, text) => `[${text}](${url})`,
|
||||
);
|
||||
|
||||
// Extract card references from markdown using shared regex pattern
|
||||
const cardMatches = Array.from(
|
||||
processedMarkdown.matchAll(CARD_EMBED_PATTERN),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Processing cards found in markdown:", cardMatches.length);
|
||||
|
||||
if (cardMatches.length === 0) {
|
||||
return processedMarkdown;
|
||||
}
|
||||
|
||||
for (let i = 0; i < cardMatches.length; i++) {
|
||||
const match = cardMatches[i];
|
||||
const [fullMatch, cardIdStr, customName] = match;
|
||||
const cardId = parseInt(cardIdStr, 10);
|
||||
|
||||
try {
|
||||
// Convert question to PNG using existing Metabase utilities
|
||||
const pngDataUrl = await convertQuestionToPng(cardId);
|
||||
|
||||
if (!pngDataUrl) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Failed to generate PNG for question ${cardId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get question name from cardEmbed for fallback
|
||||
const cardEmbed = cardEmbeds.find((ref: any) => ref.id === cardId);
|
||||
const displayName = customName || cardEmbed?.name || `Question ${cardId}`;
|
||||
|
||||
// Replace the specific card reference in markdown with embedded image
|
||||
const specificPattern = new RegExp(
|
||||
fullMatch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
"g",
|
||||
);
|
||||
processedMarkdown = processedMarkdown.replace(
|
||||
specificPattern,
|
||||
``,
|
||||
);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to process card ${cardId}:`, error);
|
||||
// Keep the original card reference if conversion fails
|
||||
}
|
||||
}
|
||||
|
||||
return processedMarkdown;
|
||||
};
|
||||
|
||||
export const downloadFile = (markdown: string) => {
|
||||
// Create and download the file
|
||||
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `document-${new Date().toISOString().split("T")[0]}.md`;
|
||||
document.body.appendChild(link);
|
||||
|
||||
link.click();
|
||||
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user