Compare commits
792 Commits
python-run
...
internal-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0268c72f | ||
|
|
0c45a4825b | ||
|
|
d866195767 | ||
|
|
20c80c7487 | ||
|
|
a2079a7457 | ||
|
|
f28514dc04 | ||
|
|
f38d8ab010 | ||
|
|
aeceb52b9b | ||
|
|
827eb5cc78 | ||
|
|
6cf08f7807 | ||
|
|
858dd15f78 | ||
|
|
9ade8b5be4 | ||
|
|
386f998c04 | ||
|
|
a156cf761a | ||
|
|
ef969ecebe | ||
|
|
49485ca97e | ||
|
|
7165514cb9 | ||
|
|
76989f91b0 | ||
|
|
8bcb334e1b | ||
|
|
754b4c4883 | ||
|
|
d89ba49521 | ||
|
|
4241f7d9d9 | ||
|
|
a5b1145e25 | ||
|
|
3da57fdbb2 | ||
|
|
5f10a1efcf | ||
|
|
a231cd2f93 | ||
|
|
21524d6805 | ||
|
|
6a4a68e78e | ||
|
|
926029c4be | ||
|
|
c325ffe379 | ||
|
|
883cdfada7 | ||
|
|
375a7bbd27 | ||
|
|
a0cbd14db7 | ||
|
|
71b9593384 | ||
|
|
219e7a6aa6 | ||
|
|
d128868267 | ||
|
|
998a746bad | ||
|
|
3dc5340671 | ||
|
|
3bb6ab0ba6 | ||
|
|
73c836a050 | ||
|
|
5d3ff06293 | ||
|
|
6b784c9621 | ||
|
|
426091f623 | ||
|
|
ee2316fa8f | ||
|
|
684f01123d | ||
|
|
51b4bea29d | ||
|
|
945858fe98 | ||
|
|
f63435ec31 | ||
|
|
d9f52578d6 | ||
|
|
bae15c2bd1 | ||
|
|
75ced50409 | ||
|
|
f69d51e50d | ||
|
|
273899c43e | ||
|
|
30d94a2312 | ||
|
|
3266f58969 | ||
|
|
c322db81ce | ||
|
|
a04752468f | ||
|
|
61762e09bb | ||
|
|
e3b4f60079 | ||
|
|
7144bee336 | ||
|
|
8d2696696a | ||
|
|
688ad8d9df | ||
|
|
8b5d57137c | ||
|
|
16c29e4e5f | ||
|
|
7f0916af55 | ||
|
|
fca011822e | ||
|
|
a08603a176 | ||
|
|
30cdc28d52 | ||
|
|
c1e3980c6a | ||
|
|
863d65dfb6 | ||
|
|
173c8dc443 | ||
|
|
bffdcb2f12 | ||
|
|
b86c0c9252 | ||
|
|
68b32f45ee | ||
|
|
64107c8c1d | ||
|
|
9184bbb103 | ||
|
|
40615ca63e | ||
|
|
8e3a948b00 | ||
|
|
2abb8eb370 | ||
|
|
9985a18220 | ||
|
|
8f819a54a2 | ||
|
|
28fb784714 | ||
|
|
8b0d09a3c1 | ||
|
|
3dcf9cd270 | ||
|
|
62274b6b94 | ||
|
|
14292aed20 | ||
|
|
a3cc471254 | ||
|
|
702042cfa0 | ||
|
|
4ddead9bad | ||
|
|
95e347b71d | ||
|
|
86f7cd33e5 | ||
|
|
ae4157cc6c | ||
|
|
9430f874c9 | ||
|
|
e1ecc1f8ae | ||
|
|
4676257489 | ||
|
|
e63161b4aa | ||
|
|
cece23b2c6 | ||
|
|
0d7aaa8db0 | ||
|
|
59f49e7a11 | ||
|
|
9050af4f05 | ||
|
|
5a7eb8f53b | ||
|
|
62b541669d | ||
|
|
b7f9388cb8 | ||
|
|
2c5e1de8f4 | ||
|
|
f9fc6021dc | ||
|
|
2e4bda9314 | ||
|
|
357a629698 | ||
|
|
fcf68b4cbc | ||
|
|
07d01f0dde | ||
|
|
03606130ff | ||
|
|
e5ae6e507e | ||
|
|
72b37bf055 | ||
|
|
7e13bed9bf | ||
|
|
b5603b457f | ||
|
|
d0da8d0bfb | ||
|
|
bdab21ab81 | ||
|
|
14c7c49e85 | ||
|
|
4f161d0522 | ||
|
|
cb2cf88fef | ||
|
|
fb84db5724 | ||
|
|
95a07ac365 | ||
|
|
6b68d0cd19 | ||
|
|
d5f9ed74c6 | ||
|
|
9c5785a1dc | ||
|
|
ce78bfb768 | ||
|
|
15d067acea | ||
|
|
d8f662ad78 | ||
|
|
b756a1f5a4 | ||
|
|
c68f94c444 | ||
|
|
8879a74291 | ||
|
|
cef86207a9 | ||
|
|
c3478d6736 | ||
|
|
bce3de22fd | ||
|
|
f26fc559d2 | ||
|
|
e379f1e5ae | ||
|
|
a6c2703a17 | ||
|
|
1619fcc222 | ||
|
|
9adf04f65b | ||
|
|
113311369b | ||
|
|
c0d300b20a | ||
|
|
b6019ad000 | ||
|
|
498739105e | ||
|
|
5b1d891712 | ||
|
|
795bc60e59 | ||
|
|
6d333b367c | ||
|
|
4e5a23cc0f | ||
|
|
45f9294bd0 | ||
|
|
9fa4adc15e | ||
|
|
7b14efe0fb | ||
|
|
c624556460 | ||
|
|
43968a0476 | ||
|
|
ca5266b0b9 | ||
|
|
bd813d6699 | ||
|
|
63b546c447 | ||
|
|
cb37f854d4 | ||
|
|
fb84045d5e | ||
|
|
e0bd3b9647 | ||
|
|
ddb0333f84 | ||
|
|
8165d11457 | ||
|
|
1849156e2f | ||
|
|
4350a95708 | ||
|
|
0eb3cc9691 | ||
|
|
26d47c1e34 | ||
|
|
bf10f6e1ee | ||
|
|
b3551813b1 | ||
|
|
49656d7c59 | ||
|
|
875601dfb4 | ||
|
|
d1b6396932 | ||
|
|
340cf95f51 | ||
|
|
52bcf275fa | ||
|
|
8a505fd419 | ||
|
|
2c96a49b21 | ||
|
|
26d45c26e8 | ||
|
|
93cc97f072 | ||
|
|
53ecaaf7a5 | ||
|
|
a4a9a31107 | ||
|
|
b58bff6dd7 | ||
|
|
9006121624 | ||
|
|
de077d3943 | ||
|
|
ec7ef98f66 | ||
|
|
2a42f773cf | ||
|
|
faccd69795 | ||
|
|
aa9005b782 | ||
|
|
203b6d513c | ||
|
|
5d207dff7b | ||
|
|
54acb4a30e | ||
|
|
2fbd7d3b1e | ||
|
|
ad3df585bd | ||
|
|
d3fdca112d | ||
|
|
90adf26588 | ||
|
|
3784750e6d | ||
|
|
f49cdaf878 | ||
|
|
98287f0d89 | ||
|
|
967d0a3eb5 | ||
|
|
e9a86902bc | ||
|
|
e4821f591f | ||
|
|
36fc93d4e1 | ||
|
|
e3001a3e31 | ||
|
|
ba82a8a02e | ||
|
|
5a11b1b796 | ||
|
|
3c0a312444 | ||
|
|
597bf6b60c | ||
|
|
efccfe909d | ||
|
|
7df714f9db | ||
|
|
26fd776b30 | ||
|
|
cbb5a47b96 | ||
|
|
f95dcb6be5 | ||
|
|
be4b723e1f | ||
|
|
152aaef104 | ||
|
|
d892d8dbc8 | ||
|
|
27c58d1157 | ||
|
|
5dccbe138e | ||
|
|
25ed5d9b8a | ||
|
|
73563f787b | ||
|
|
db62348247 | ||
|
|
4137c2555e | ||
|
|
17353f3dbd | ||
|
|
d383b53923 | ||
|
|
0444520362 | ||
|
|
a82c646cb3 | ||
|
|
51a5f4f2fc | ||
|
|
7905b7357b | ||
|
|
f5539cdad7 | ||
|
|
135ea16706 | ||
|
|
36f8ee0342 | ||
|
|
107acc35f0 | ||
|
|
33b5e1f905 | ||
|
|
01884fd049 | ||
|
|
4f84e33f77 | ||
|
|
5ff1fe73fc | ||
|
|
f0583eaba5 | ||
|
|
b2302e5263 | ||
|
|
b5b58ebbcb | ||
|
|
c7af65b686 | ||
|
|
d0f20309a1 | ||
|
|
6548d02c35 | ||
|
|
f6154fd684 | ||
|
|
20c44b1793 | ||
|
|
21f4335bea | ||
|
|
544e703049 | ||
|
|
757b650588 | ||
|
|
375a42a89c | ||
|
|
9f8adeaadb | ||
|
|
c6825b0461 | ||
|
|
7d2f608ffc | ||
|
|
660f3f0561 | ||
|
|
32771e9841 | ||
|
|
7a17ed55f5 | ||
|
|
d9c75d3c8c | ||
|
|
fb61b66323 | ||
|
|
aa38d7d06d | ||
|
|
ec4124b006 | ||
|
|
de589ea335 | ||
|
|
be24965e3f | ||
|
|
11aad82749 | ||
|
|
419c02eb25 | ||
|
|
b2e931b612 | ||
|
|
68ada98f9a | ||
|
|
305f102ef9 | ||
|
|
fcd9cc8878 | ||
|
|
f3211e4cf2 | ||
|
|
254383fb97 | ||
|
|
a496f9de7d | ||
|
|
c9422c9545 | ||
|
|
3da6ed1c81 | ||
|
|
b25e89ca5b | ||
|
|
7651509551 | ||
|
|
a176d39320 | ||
|
|
ad81d702ac | ||
|
|
f58136e811 | ||
|
|
81c056481a | ||
|
|
ca35ee3de6 | ||
|
|
a1d44313a2 | ||
|
|
c7959fab0a | ||
|
|
941e5e47f1 | ||
|
|
cb7c93beec | ||
|
|
8777d4b850 | ||
|
|
15ae46d9f4 | ||
|
|
1c2195c4ed | ||
|
|
8d122e8436 | ||
|
|
266be7245e | ||
|
|
9dcfebd40c | ||
|
|
5facc8557c | ||
|
|
6c4bfe30ee | ||
|
|
53738cdba3 | ||
|
|
86b00192dc | ||
|
|
3a91374f4e | ||
|
|
0d51788f5d | ||
|
|
77da831305 | ||
|
|
d2607b2ddd | ||
|
|
34492caa8a | ||
|
|
e14994af04 | ||
|
|
05cd7b8ed9 | ||
|
|
c7b8187d23 | ||
|
|
7ab6f20abb | ||
|
|
3442355cf3 | ||
|
|
6e040a3fc5 | ||
|
|
644a2f194f | ||
|
|
d711427901 | ||
|
|
da6dac0e92 | ||
|
|
15ab6f7131 | ||
|
|
850e448dc8 | ||
|
|
e2bd53b114 | ||
|
|
1ef5d1f278 | ||
|
|
e431d62b3d | ||
|
|
0cadbdf007 | ||
|
|
fe64dfcf63 | ||
|
|
8c42162c79 | ||
|
|
6aabb54d0a | ||
|
|
9ad7d6702e | ||
|
|
930f9b6fa0 | ||
|
|
4dfc9674aa | ||
|
|
e83f0988c0 | ||
|
|
27ab7793c1 | ||
|
|
7b010b498a | ||
|
|
de6ebdbe39 | ||
|
|
dcf05ced7f | ||
|
|
20e4d5a1e4 | ||
|
|
4674825133 | ||
|
|
fefc9d666f | ||
|
|
17abfc7280 | ||
|
|
8cff30217e | ||
|
|
f64cf10930 | ||
|
|
b21424cdf6 | ||
|
|
47eb63c7c9 | ||
|
|
250f6ab7cf | ||
|
|
7c6bfbe30b | ||
|
|
b25bbc7b8e | ||
|
|
f0aad84238 | ||
|
|
577a9d4c7b | ||
|
|
d5edfa8d07 | ||
|
|
eaf598f851 | ||
|
|
71988bd3e3 | ||
|
|
c7ef444736 | ||
|
|
9ad2b82c88 | ||
|
|
629a30327e | ||
|
|
463e8d9d77 | ||
|
|
d623c486e2 | ||
|
|
dee2f6bfc7 | ||
|
|
f1c8f4f882 | ||
|
|
2bfd1a477a | ||
|
|
982ec1052f | ||
|
|
db35b1f042 | ||
|
|
ff6fc7de4c | ||
|
|
8b1fa2daf7 | ||
|
|
c3423cb458 | ||
|
|
89c23637b2 | ||
|
|
bbf805ffa0 | ||
|
|
4b36020f6d | ||
|
|
de7c7fd6c5 | ||
|
|
3e01e467ce | ||
|
|
67b75dc583 | ||
|
|
3d89c4633f | ||
|
|
784a71e12b | ||
|
|
d5733763ad | ||
|
|
49e0e36c6a | ||
|
|
c554f3a572 | ||
|
|
9b4f21d974 | ||
|
|
dd2b87ccbe | ||
|
|
2add884349 | ||
|
|
4b6e6e7faa | ||
|
|
4f99436879 | ||
|
|
4e127f0487 | ||
|
|
11a22e0703 | ||
|
|
2ffee29111 | ||
|
|
3f93d87c28 | ||
|
|
75d3bda5a0 | ||
|
|
a8bc711f08 | ||
|
|
27d987ef19 | ||
|
|
afef2fc531 | ||
|
|
84904235f2 | ||
|
|
354b2ec14d | ||
|
|
611b8a4036 | ||
|
|
b0df8a12ce | ||
|
|
ab575facbc | ||
|
|
4d9d1378cb | ||
|
|
652657666c | ||
|
|
a44014d694 | ||
|
|
8e668e526b | ||
|
|
7ead7d96e3 | ||
|
|
6d518e9db9 | ||
|
|
79a96f7064 | ||
|
|
5e04b07f2f | ||
|
|
e15f9226f1 | ||
|
|
531bcff427 | ||
|
|
bc50d8441e | ||
|
|
833718d160 | ||
|
|
2839918ff8 | ||
|
|
8661631b79 | ||
|
|
fd5a3175b1 | ||
|
|
c56b10ce28 | ||
|
|
105e5c0691 | ||
|
|
87b07be973 | ||
|
|
84f644dc3d | ||
|
|
265ca5ab39 | ||
|
|
0b848b3edd | ||
|
|
7b619a3efa | ||
|
|
4a8986e4ba | ||
|
|
9040d58e26 | ||
|
|
879d56b0ec | ||
|
|
3ce75ecdf2 | ||
|
|
fed4186a72 | ||
|
|
ddaef972cd | ||
|
|
0932acd6de | ||
|
|
b5280e8e3e | ||
|
|
ad620a1b09 | ||
|
|
93b04ae99c | ||
|
|
4388967791 | ||
|
|
5d9a5d4d38 | ||
|
|
e9593f467d | ||
|
|
d28d12c2cb | ||
|
|
9713b842c9 | ||
|
|
2c2b7a495b | ||
|
|
3b056514d6 | ||
|
|
f15299d54a | ||
|
|
e579fe45da | ||
|
|
a49cab944b | ||
|
|
5de4b5694b | ||
|
|
12f7aa1781 | ||
|
|
b3e9875fb0 | ||
|
|
d1e0492261 | ||
|
|
8b3c1a554b | ||
|
|
79400e908a | ||
|
|
3a2e02af2c | ||
|
|
c172e149b9 | ||
|
|
a6db8a7811 | ||
|
|
9ded97a09c | ||
|
|
7c2e666316 | ||
|
|
ff4112a40e | ||
|
|
a98fce71de | ||
|
|
25148746d1 | ||
|
|
1499357b2b | ||
|
|
2dca593474 | ||
|
|
5b96c1e14c | ||
|
|
682fa130c7 | ||
|
|
9b615220df | ||
|
|
c28b50e49e | ||
|
|
4c20c9d092 | ||
|
|
4f852f2d17 | ||
|
|
0e764cd4ef | ||
|
|
5e0fe4bec5 | ||
|
|
a89bfd8f58 | ||
|
|
53dd4236bd | ||
|
|
c4a27f325e | ||
|
|
2e826dd116 | ||
|
|
5c59166ec4 | ||
|
|
a4cb42aed3 | ||
|
|
70fcb86aff | ||
|
|
7a7d8fedb7 | ||
|
|
68a54d9ced | ||
|
|
ed937d1266 | ||
|
|
e7c1195dc4 | ||
|
|
d57f4df6de | ||
|
|
07fb66d6e0 | ||
|
|
5aa96c0e88 | ||
|
|
072a76dd0c | ||
|
|
e0a4702041 | ||
|
|
c773383b99 | ||
|
|
1822d3485a | ||
|
|
04e1a28f4b | ||
|
|
39dd7c9c5b | ||
|
|
e6a79d85ec | ||
|
|
870435eba0 | ||
|
|
cc89947162 | ||
|
|
b4b29dcc19 | ||
|
|
1e316447ec | ||
|
|
c69b53babd | ||
|
|
4a5166a14b | ||
|
|
4453313745 | ||
|
|
0cab50cc6d | ||
|
|
8d0a374008 | ||
|
|
16d006630f | ||
|
|
09b8acf6d2 | ||
|
|
be23444e7e | ||
|
|
d3b12475b7 | ||
|
|
3c2ceb8193 | ||
|
|
6dbe326997 | ||
|
|
f5d9928791 | ||
|
|
7d70c66324 | ||
|
|
39d15ec509 | ||
|
|
16d2514200 | ||
|
|
73b27fcac0 | ||
|
|
08bb9ead73 | ||
|
|
43dcc145f3 | ||
|
|
5b4e18b303 | ||
|
|
59b27938b3 | ||
|
|
62dec0bc76 | ||
|
|
64a7e5a594 | ||
|
|
c638945787 | ||
|
|
9b8656be9e | ||
|
|
44bb373af1 | ||
|
|
e4bb84130d | ||
|
|
a40053fda1 | ||
|
|
5240e864e8 | ||
|
|
965d54cbc5 | ||
|
|
c8097bca83 | ||
|
|
6552ffdc69 | ||
|
|
9151d1abd4 | ||
|
|
a25be93c6a | ||
|
|
98ef854d78 | ||
|
|
d0c3c9a4ce | ||
|
|
6755248b2f | ||
|
|
df2d0d5061 | ||
|
|
b45b01d953 | ||
|
|
7ebe65686a | ||
|
|
b12115c8dc | ||
|
|
622553c018 | ||
|
|
14f54afb65 | ||
|
|
bf9f135f9f | ||
|
|
142b6d4903 | ||
|
|
7421a15eec | ||
|
|
d2b5cf6aa4 | ||
|
|
b78b470d1f | ||
|
|
c6023b0bec | ||
|
|
5a50378330 | ||
|
|
a520f31b42 | ||
|
|
9e48bfa0cb | ||
|
|
f73d65f010 | ||
|
|
812b9ec918 | ||
|
|
ce781e3e38 | ||
|
|
387c8f8829 | ||
|
|
2accf2d826 | ||
|
|
edffc2b5b1 | ||
|
|
9d266de7ca | ||
|
|
34fc016df0 | ||
|
|
2159d9c2e7 | ||
|
|
891d15deba | ||
|
|
3a5f0ce528 | ||
|
|
c388ba0c58 | ||
|
|
4deb77340d | ||
|
|
aecfe3eda9 | ||
|
|
0efe7bc010 | ||
|
|
01f7f5bcd9 | ||
|
|
bbf4d33a08 | ||
|
|
25e21ed9bf | ||
|
|
1fc31a99f7 | ||
|
|
3a5be51509 | ||
|
|
6cdc6008e9 | ||
|
|
b6072b7112 | ||
|
|
89c58b8f6f | ||
|
|
649caa0e44 | ||
|
|
8806e6a0f2 | ||
|
|
ea71481630 | ||
|
|
1e17df7eeb | ||
|
|
5d6dde74c5 | ||
|
|
e7b74d7d76 | ||
|
|
30f59f2e87 | ||
|
|
36a0db358b | ||
|
|
e43228e8fc | ||
|
|
765202249d | ||
|
|
9c50d21401 | ||
|
|
b215eddbf3 | ||
|
|
805b355989 | ||
|
|
17dc3b2444 | ||
|
|
b28251f8a1 | ||
|
|
7d87a73a22 | ||
|
|
ef74b7830f | ||
|
|
6c1ebb6d0e | ||
|
|
ed852b65c9 | ||
|
|
8969ae4baa | ||
|
|
94addf0055 | ||
|
|
ede9de2787 | ||
|
|
3e3a6aba14 | ||
|
|
b3fd8c2251 | ||
|
|
2273872f63 | ||
|
|
663a5d15d8 | ||
|
|
2e04af515e | ||
|
|
7f881461f3 | ||
|
|
0bdb7c1004 | ||
|
|
ac410d0c9f | ||
|
|
f9a9c6c06c | ||
|
|
c5ab376d8a | ||
|
|
dee486d3e1 | ||
|
|
ddf7bcd142 | ||
|
|
e8d96ac295 | ||
|
|
6778c3c66c | ||
|
|
eec2b53807 | ||
|
|
9a53ef0c47 | ||
|
|
569ba2a134 | ||
|
|
ca78a248e5 | ||
|
|
bf1f730534 | ||
|
|
76dde82500 | ||
|
|
d989e582c2 | ||
|
|
828be63863 | ||
|
|
4f9077f9b9 | ||
|
|
ec8bddf5a5 | ||
|
|
6bf89dc316 | ||
|
|
bca724818f | ||
|
|
7dc39fad70 | ||
|
|
fc4103637c | ||
|
|
b0189956b2 | ||
|
|
ad09dfa3c8 | ||
|
|
95c3693642 | ||
|
|
ddd3157d20 | ||
|
|
349d0503fc | ||
|
|
5a9265f2f2 | ||
|
|
5eca4d39df | ||
|
|
78e305cd93 | ||
|
|
5b2124da6e | ||
|
|
479a73b16b | ||
|
|
53873c22b1 | ||
|
|
d63e13f54d | ||
|
|
cc84410ac3 | ||
|
|
e2a77d6951 | ||
|
|
3f5333cd22 | ||
|
|
9b4c277b10 | ||
|
|
6269563254 | ||
|
|
431b5c78c1 | ||
|
|
b061cc3b9b | ||
|
|
21bd0c0fa5 | ||
|
|
71e72f6645 | ||
|
|
cd5ffe7902 | ||
|
|
60d8da092d | ||
|
|
f29c8d8c56 | ||
|
|
fc5a81afe5 | ||
|
|
bc26ee23ed | ||
|
|
46500e2157 | ||
|
|
04fa5a0f76 | ||
|
|
71437e758e | ||
|
|
87facf8751 | ||
|
|
a2ee4eaa22 | ||
|
|
0868aed492 | ||
|
|
91aa513d43 | ||
|
|
b85b88883f | ||
|
|
129510e174 | ||
|
|
806c311e90 | ||
|
|
4f997cf438 | ||
|
|
dbd2e511a7 | ||
|
|
b618a16569 | ||
|
|
a93079f0bb | ||
|
|
a1b21329ec | ||
|
|
24548bbf47 | ||
|
|
98aaa51b26 | ||
|
|
6a03e8b9c1 | ||
|
|
81dee319cb | ||
|
|
1198b27a1d | ||
|
|
e88dc44592 | ||
|
|
8294e4d176 | ||
|
|
86dffe61d9 | ||
|
|
87403bce30 | ||
|
|
429ade95bd | ||
|
|
11f85281f0 | ||
|
|
c228954ffe | ||
|
|
4ba89a98f8 | ||
|
|
1f867c6e64 | ||
|
|
e4e1812349 | ||
|
|
aef83abf34 | ||
|
|
bef8c011f5 | ||
|
|
94ca368631 | ||
|
|
8eeeb81741 | ||
|
|
da350584e8 | ||
|
|
a00201596f | ||
|
|
6b3d024d9d | ||
|
|
5d0b95be60 | ||
|
|
37a9a6c0f3 | ||
|
|
b5cb4c62e3 | ||
|
|
ca30fb63a1 | ||
|
|
63b91a1ef9 | ||
|
|
1719ebc9c7 | ||
|
|
ec79b8320f | ||
|
|
d9b2b96474 | ||
|
|
55a1df1048 | ||
|
|
79db162e9e | ||
|
|
5726032b94 | ||
|
|
5189a40ce4 | ||
|
|
e070e949d1 | ||
|
|
51ad937543 | ||
|
|
f2e680e0d4 | ||
|
|
1035825e94 | ||
|
|
43b1f2c182 | ||
|
|
6f009d56ce | ||
|
|
1859f2275d | ||
|
|
44d2658f13 | ||
|
|
ec729b05df | ||
|
|
a92fca3ef5 | ||
|
|
d57217d006 | ||
|
|
4e37127051 | ||
|
|
944a1382fa | ||
|
|
f70ca33c0a | ||
|
|
c8e77402dd | ||
|
|
f47d072729 | ||
|
|
6ba5dcf09d | ||
|
|
7e138cf216 | ||
|
|
ae6591cc07 | ||
|
|
e9de85bd37 | ||
|
|
b6cde470a7 | ||
|
|
3d489581dd | ||
|
|
6ec702642f | ||
|
|
ce87540743 | ||
|
|
b1827d9a81 | ||
|
|
eaf1ad0085 | ||
|
|
ef8b482bbc | ||
|
|
506c665eb6 | ||
|
|
4d960706b1 | ||
|
|
e71e4d0ada | ||
|
|
e19b5e4dd3 | ||
|
|
a02d08fcfc | ||
|
|
49f1e4977c | ||
|
|
e2fd4fc37e | ||
|
|
7b10a2f2b0 | ||
|
|
4262ff5a3d | ||
|
|
524e2bc660 | ||
|
|
76d7d80ecb | ||
|
|
da4b2d4411 | ||
|
|
95c442b943 | ||
|
|
db8c20833c | ||
|
|
e285c32157 | ||
|
|
7cbe6fd0ca | ||
|
|
68927160c8 | ||
|
|
522a4120bb | ||
|
|
01222ff5e1 | ||
|
|
b9c4e305ea | ||
|
|
43c64db6dc | ||
|
|
021e0d080e | ||
|
|
a9169ea54e | ||
|
|
328dec6b84 | ||
|
|
a5c30576c3 | ||
|
|
c8345fb90a | ||
|
|
b2aa25a18e | ||
|
|
171ace8438 | ||
|
|
5ed7432f77 | ||
|
|
88c81b9087 | ||
|
|
4ee43ee057 | ||
|
|
7dba7ab0cb | ||
|
|
66e30a69c7 | ||
|
|
c21e9db004 | ||
|
|
a4d8cc425e | ||
|
|
22553220b9 | ||
|
|
4bee28d643 | ||
|
|
878d94cb9c | ||
|
|
58ac9f8dc0 | ||
|
|
7a40a48c09 | ||
|
|
ba47dca45f | ||
|
|
c31f0dd803 | ||
|
|
741d492778 | ||
|
|
2490c818d4 | ||
|
|
4073da9974 | ||
|
|
56f473c104 | ||
|
|
4bdbeccdd9 | ||
|
|
730d1eeb2a | ||
|
|
2fff1e2c4a | ||
|
|
c918c65e13 | ||
|
|
defba4bc0e | ||
|
|
96240e06db | ||
|
|
4cdbafc70f | ||
|
|
089ffba272 | ||
|
|
5fb6bcb4d5 | ||
|
|
c0d49e788a | ||
|
|
d04b760b32 | ||
|
|
e5dd56ce96 | ||
|
|
b257e020f6 | ||
|
|
51db0565b9 | ||
|
|
9f2e1cb040 | ||
|
|
caed3e4365 | ||
|
|
e5023e9e11 | ||
|
|
c9eafb04f8 | ||
|
|
b817729e1d | ||
|
|
ade045d156 | ||
|
|
759da5a5c8 | ||
|
|
530293e059 | ||
|
|
398bacf6ed | ||
|
|
69a0753553 | ||
|
|
b7cb067b6c | ||
|
|
ba3edbacd8 | ||
|
|
cccf5a8348 | ||
|
|
7d49eaa300 | ||
|
|
b4b84e778a | ||
|
|
d7018ec8ca | ||
|
|
06c9675131 | ||
|
|
733ad0f409 | ||
|
|
e67139aa3f | ||
|
|
9e3368041c | ||
|
|
7696b5e16c | ||
|
|
f0320be9d7 | ||
|
|
177cc26508 | ||
|
|
03b1ec8678 | ||
|
|
b158037771 | ||
|
|
34e97a9970 | ||
|
|
5c162716e5 | ||
|
|
37b07ebb40 | ||
|
|
b52a3b6d34 | ||
|
|
df2f922342 | ||
|
|
57bbf5b224 | ||
|
|
9edb2f2442 | ||
|
|
e235783fc4 | ||
|
|
3c954431f9 | ||
|
|
5e406269a4 | ||
|
|
a68c2f475e | ||
|
|
b26d24c90f | ||
|
|
3beb60de0b | ||
|
|
430d3ab213 | ||
|
|
59dd7e7dc3 |
@@ -401,9 +401,10 @@
|
||||
clojure.test.check.properties/for-all clojure.core/let
|
||||
clojurewerkz.quartzite.jobs/defjob clojure.core/defn
|
||||
instaparse.core/defparser clojure.core/def
|
||||
metabase-enterprise.data-editing.test-util/with-test-tables! clojure.core/let
|
||||
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.content-translation.routes-test/with-new-secret-key! clojure.core/fn
|
||||
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
|
||||
@@ -462,6 +463,9 @@ metabase-enterprise.content-translation.routes-test/with-new-secret-key!
|
||||
metabase.test/with-temp-file clojure.core/let
|
||||
metabase.test/with-user-in-groups clojure.core/let
|
||||
metabase.upload.impl-test/with-upload-table! clojure.core/let
|
||||
metabase.upload-test/with-upload-table! clojure.core/let
|
||||
metabase.util/for-map clojure.core/for
|
||||
metabase.util/with-timer-ms clojure.core/fn
|
||||
metabase.util.files/with-open-path-to-resource clojure.core/let
|
||||
metabase.util.malli.defn/defn schema.core/defn
|
||||
metabase.util.malli.defn/defn- schema.core/defn
|
||||
@@ -471,7 +475,6 @@ metabase-enterprise.content-translation.routes-test/with-new-secret-key!
|
||||
metabase.util.malli/defn- schema.core/defn
|
||||
metabase.util.malli/fn schema.core/fn
|
||||
metabase.util.namespaces/import-fns potemkin/import-vars
|
||||
metabase.util/with-timer-ms clojure.core/fn
|
||||
metabase.xrays.domain-entities.malli/defn schema.core/defn
|
||||
metabuild-common.core/with-open-jar-file-system clojure.core/let
|
||||
metabuild-common.files/with-open-jar-file-system clojure.core/let
|
||||
|
||||
@@ -48,14 +48,18 @@
|
||||
{actions
|
||||
{:team "Workflows"
|
||||
:api #{metabase.actions.api
|
||||
metabase.actions.args
|
||||
metabase.actions.core
|
||||
metabase.actions.init}
|
||||
metabase.actions.init
|
||||
metabase.actions.types}
|
||||
:uses #{analytics
|
||||
api
|
||||
collections
|
||||
driver
|
||||
events
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
model-persistence
|
||||
models
|
||||
permissions
|
||||
@@ -509,8 +513,16 @@
|
||||
{:team "DevEx"
|
||||
:api #{metabase.events.core
|
||||
metabase.events.init}
|
||||
:uses #{models
|
||||
util}}
|
||||
:uses #{api
|
||||
audit-app
|
||||
channel
|
||||
config
|
||||
driver
|
||||
models
|
||||
notification
|
||||
premium-features
|
||||
util
|
||||
view-log}}
|
||||
|
||||
formatter
|
||||
{:team "DashViz"
|
||||
@@ -703,7 +715,8 @@
|
||||
metabase.notification.init
|
||||
metabase.notification.models
|
||||
metabase.notification.payload.core}
|
||||
:uses #{analytics
|
||||
:uses #{actions
|
||||
analytics
|
||||
api
|
||||
appearance
|
||||
channel
|
||||
@@ -711,6 +724,7 @@
|
||||
dashboards
|
||||
driver
|
||||
events
|
||||
lib
|
||||
models
|
||||
parameters
|
||||
permissions
|
||||
@@ -723,8 +737,10 @@
|
||||
system
|
||||
task
|
||||
task-history
|
||||
types
|
||||
users
|
||||
util}}
|
||||
util
|
||||
warehouse-schema}}
|
||||
|
||||
;; TODO -- too many API namespaces
|
||||
parameters
|
||||
@@ -864,7 +880,8 @@
|
||||
metabase.queries.init
|
||||
metabase.queries.models.query ; TODO FIXME
|
||||
metabase.queries.schema} ; TODO FIXME
|
||||
:uses #{analytics
|
||||
:uses #{actions
|
||||
analytics
|
||||
analyze
|
||||
api
|
||||
app-db
|
||||
@@ -1572,10 +1589,33 @@
|
||||
|
||||
enterprise/data-editing
|
||||
{:team "Workflows"
|
||||
:api #{metabase-enterprise.data-editing.api}
|
||||
:api #{metabase-enterprise.data-editing.api
|
||||
metabase-enterprise.data-editing.core
|
||||
metabase-enterprise.data-editing.init}
|
||||
:uses #{actions
|
||||
api
|
||||
util}}
|
||||
driver
|
||||
events
|
||||
lib
|
||||
models
|
||||
notification
|
||||
query-processor
|
||||
upload
|
||||
util
|
||||
warehouse-schema}}
|
||||
|
||||
enterprise/data-editing-public
|
||||
{:team "Workflows"
|
||||
:api #{}
|
||||
:uses #{actions
|
||||
api
|
||||
driver
|
||||
events
|
||||
lib
|
||||
query-processor
|
||||
upload
|
||||
util
|
||||
enterprise/data-editing}}
|
||||
|
||||
enterprise/database-routing
|
||||
{:team "Admin Webapp"
|
||||
|
||||
@@ -278,6 +278,7 @@
|
||||
metabase.upload.impl-test/with-upload-table! [[:inner 0]]
|
||||
metabase.upload.types/with-currency [[:default]]
|
||||
metabase.upload.types/with-parens [[:default]]
|
||||
metabase.util/for-map [[:block 1]]
|
||||
metabase.util.async/cancelable-thread [[:block 0]]
|
||||
metabase.util.date-2/with-time-zone-same-instant [[:default]]
|
||||
metabase.util.encryption-test/with-secret-key [[:block 1]]
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -50,6 +50,7 @@
|
||||
/lein-plugins/*/target
|
||||
/local
|
||||
/locales/metabase-*.pot
|
||||
/logs
|
||||
/modules/drivers/*/resources/namespaces.edn
|
||||
/modules/drivers/*/target
|
||||
/node_modules/
|
||||
@@ -146,6 +147,7 @@ llm-payloads
|
||||
.aider*
|
||||
/config.yml
|
||||
CLAUDE.local.md
|
||||
.claude/settings.local.json
|
||||
|
||||
.eslintcache
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ VERSION_PROPERTY_NAME="src_hash"
|
||||
source-hash() {
|
||||
# hash all the files that might change a backend-only uberjar build (for integration tests)
|
||||
(
|
||||
find src deps.edn resources/sample-database.db.mv.db -type f -print0 | xargs -0 shasum ;
|
||||
find src enterprise/backend/src deps.edn resources/sample-database.db.mv.db -type f -print0 | xargs -0 shasum ;
|
||||
find resources -type f \( -iname \*.clj -o -iname \*.edn -o -iname \*.yaml -o -iname \*.properties -o -iname \*.html \) -not -name "version.properties" -print0 | xargs -0 shasum ;
|
||||
) | shasum | awk '{ print $1 }'
|
||||
}
|
||||
|
||||
22
deps.edn
22
deps.edn
@@ -36,7 +36,7 @@
|
||||
com.clearspring.analytics/stream {:mvn/version "2.9.8" ; Various sketching algorithms
|
||||
:exclusions [it.unimi.dsi/fastutil
|
||||
org.slf4j/slf4j-api]}
|
||||
com.clojure-goes-fast/jvm-alloc-rate-meter{:mvn/version "0.1.4"} ; Monitor heap allocation rate.
|
||||
com.clojure-goes-fast/jvm-alloc-rate-meter {:mvn/version "0.1.4"} ; Monitor heap allocation rate.
|
||||
com.clojure-goes-fast/jvm-hiccup-meter {:mvn/version "0.1.1"} ; Monitor GC pauses and other system-induced pauses.
|
||||
com.draines/postal {:mvn/version "2.0.5"} ; SMTP library
|
||||
com.fasterxml.woodstox/woodstox-core {:mvn/version "7.1.0"} ; trans dep of commons-codec
|
||||
@@ -44,7 +44,7 @@
|
||||
com.github.albfernandez/juniversalchardet {:mvn/version "2.5.0"}
|
||||
com.github.blagerweij/liquibase-sessionlock {:mvn/version "1.6.9"} ; session lock for liquibase migrations
|
||||
com.github.jknack/handlebars ^:antq/exclude ; 4.4.0 requires java 17+
|
||||
{:mvn/version "4.3.1"} ; templating engine
|
||||
{:mvn/version "4.3.1"} ; templating engine
|
||||
com.github.pangloss/transducers {:git/sha "c91d39046c8c64dc31b84ebb27154a8084fcda4c" ; like medley for transducers
|
||||
:git/url "https://github.com/pangloss/transducers"}
|
||||
com.github.seancorfield/honeysql {:mvn/version "2.7.1310"} ; Honey SQL 2. SQL generation from Clojure data maps
|
||||
@@ -53,15 +53,14 @@
|
||||
com.github.vertical-blank/sql-formatter {:mvn/version "2.0.5"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter
|
||||
com.google.guava/guava {:mvn/version "33.4.8-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697
|
||||
com.h2database/h2 ^:antq/exclude ; https://metaboat.slack.com/archives/CKZEMT1MJ/p1727191010259979
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
com.ibm.icu/icu4j {:mvn/version "77.1"} ; Used to transliterate Unicode characters into Latin characters
|
||||
com.mchange/c3p0 {:mvn/version "0.10.1"} ; DB connection pool
|
||||
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
|
||||
{:mvn/version "1.0.1"} ; Snowplow analytics
|
||||
com.taoensso/nippy {:mvn/version "3.5.0"} ; Fast serialization (i.e., GZIP) library for Clojure
|
||||
com.vladsch.flexmark/flexmark {:mvn/version "0.64.8"} ; Markdown parsing
|
||||
com.vladsch.flexmark/flexmark-ext-autolink
|
||||
{:mvn/version "0.64.8"} ; Flexmark extension for auto-linking bare URLs
|
||||
com.vladsch.flexmark/flexmark-ext-autolink {:mvn/version "0.64.8"} ; Flexmark extension for auto-linking bare URLs
|
||||
commons-codec/commons-codec {:mvn/version "1.18.0"} ; Apache Commons -- useful codec util fns
|
||||
commons-io/commons-io {:mvn/version "2.19.0"} ; Apache Commons -- useful IO util fns
|
||||
commons-net/commons-net {:mvn/version "3.11.1"} ; Apache Commons -- useful network utils. Transitive dep of Snowplow, pinned due to CVE-2021-37533
|
||||
@@ -87,7 +86,7 @@
|
||||
;; TODO: replace back to normal artifact
|
||||
io.github.metabase/macaw {:mvn/version "0.2.27"} ; Parse native SQL queries
|
||||
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8
|
||||
{:mvn/version "1.7.1"} ; Support for retrying operations
|
||||
{:mvn/version "1.7.1"} ; Support for retrying operations
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
|
||||
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
|
||||
kixi/stats {:mvn/version "0.5.7" ; Various statistic measures implemented as transducers
|
||||
@@ -843,7 +842,16 @@
|
||||
;; clj -X:dev:ee:ee-dev:test:test/notification
|
||||
:test/notification
|
||||
{:exec-args {:only ["test/metabase/notification"
|
||||
"test/metabase/channel"]}}
|
||||
"test/metabase/channel"
|
||||
"test/metabase/pulse"
|
||||
"enterprise/backend/test/metabase_enterprise/notification"
|
||||
metabase-enterprise.data-editing.notification-test]}}
|
||||
|
||||
;; clj -X:dev:ee:ee-dev:test:test/workflow
|
||||
:test/workflow
|
||||
{:exec-args {:only ["test/metabase/actions"
|
||||
"enterprise/backend/test/metabase_enterprise/data_editing"
|
||||
metabase.driver.sql-jdbc.actions-test]}}
|
||||
|
||||
;; test all MBQL related stuff: MLv2, the legacy shared `metabase.legacy-mbql` code, and the QP
|
||||
;;
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
[clojure.test]
|
||||
[dev.debug-qp :as debug-qp]
|
||||
[dev.explain :as dev.explain]
|
||||
[dev.malli :as dev.malli]
|
||||
[dev.memory :as dev.memory]
|
||||
[dev.migrate :as dev.migrate]
|
||||
[dev.model-tracking :as model-tracking]
|
||||
@@ -106,6 +107,8 @@
|
||||
pprint-sql]
|
||||
[dev.explain
|
||||
explain-query]
|
||||
[dev.malli
|
||||
visualize-schema!]
|
||||
[dev.migrate
|
||||
migrate!
|
||||
rollback!
|
||||
|
||||
19
dev/src/dev/malli.clj
Normal file
19
dev/src/dev/malli.clj
Normal file
@@ -0,0 +1,19 @@
|
||||
(ns dev.malli
|
||||
(:require
|
||||
[clojure.java.shell :as sh]
|
||||
[malli.dot :as md]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[ring.util.codec :as codec]))
|
||||
|
||||
(defn- graphviz-visualizer
|
||||
[schema]
|
||||
(format "https://dreampuf.github.io/GraphvizOnline/?engine=dot#%s"
|
||||
(-> schema mr/resolve-schema md/transform codec/url-encode)))
|
||||
|
||||
(defn visualize-schema!
|
||||
"Given a schema, visualize it using Graphviz.
|
||||
(visualize-schema! [:map [:a :int] [:b :string]])
|
||||
(visualize-schema! ::my/schema)"
|
||||
[schema]
|
||||
(let [url (graphviz-visualizer schema)]
|
||||
(sh/sh "open" url)))
|
||||
@@ -38,7 +38,6 @@ export const createQuestionAlert = ({
|
||||
subscriptions: [
|
||||
{
|
||||
type: "notification-subscription/cron",
|
||||
event_name: null,
|
||||
cron_schedule,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -58,7 +58,9 @@ export const viewEmailPage = (emailSubject) => {
|
||||
const webmailInterface = `http://localhost:${WEB_PORT}`;
|
||||
|
||||
cy.window().then((win) => (win.location.href = webmailInterface));
|
||||
cy.findByText(emailSubject).click();
|
||||
if (emailSubject) {
|
||||
cy.findByText(emailSubject).click();
|
||||
}
|
||||
};
|
||||
|
||||
export const openEmailPage = (emailSubject) => {
|
||||
@@ -128,6 +130,24 @@ export function sendEmailAndVisitIt() {
|
||||
});
|
||||
}
|
||||
|
||||
export function checkEmailContent(subject, body) {
|
||||
viewEmailPage(subject);
|
||||
|
||||
cy.get(".main-container").within(() => {
|
||||
cy.get(".subject").should("contain", subject);
|
||||
cy.get(".preview-iframe")
|
||||
.its("0.contentDocument.body")
|
||||
.should("not.be.empty")
|
||||
.then(($body) => {
|
||||
if (Array.isArray(body)) {
|
||||
body.forEach((str) => expect($body).to.contain(str));
|
||||
} else {
|
||||
expect($body).to.contain(body);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function sendAlertAndVisitIt() {
|
||||
cy.intercept("POST", "/api/notification/send").as("testAlert");
|
||||
cy.findByLabelText("New alert")
|
||||
|
||||
7
e2e/support/helpers/e2e-internal-tools-helpers.ts
Normal file
7
e2e/support/helpers/e2e-internal-tools-helpers.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function setTableEditingEnabledForDB(dbId: number, enabled = true) {
|
||||
return cy.request("PUT", `/api/database/${dbId}`, {
|
||||
settings: {
|
||||
"database-enable-table-editing": enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,11 @@ export const addNotificationHandlerChannel = (
|
||||
if (hasNoChannelsAdded) {
|
||||
modal().findByText("Add a destination").should("be.visible").click();
|
||||
} else {
|
||||
modal().findByText("Add another destination").should("be.visible").click();
|
||||
modal()
|
||||
.findByText("Add another destination")
|
||||
.scrollIntoView()
|
||||
.should("be.visible")
|
||||
.click();
|
||||
}
|
||||
|
||||
popover().findByText(channel).click();
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from "./e2e-embedding-sdk-assertion-helpers";
|
||||
export * from "./e2e-element-visibility-helpers";
|
||||
export * from "./e2e-enterprise-helpers";
|
||||
export * from "./e2e-filter-helpers";
|
||||
export * from "./e2e-internal-tools-helpers";
|
||||
export * from "./e2e-ldap-helpers";
|
||||
export * from "./e2e-misc-helpers";
|
||||
export * from "./e2e-mock-app-settings-helpers";
|
||||
|
||||
@@ -22,7 +22,7 @@ describe(
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");
|
||||
cy.intercept("POST", "/api/action/*/execute").as("executeAction");
|
||||
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
|
||||
cy.intercept("GET", "/api/action/*/execute?parameters=*").as(
|
||||
"prefetchValues",
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ const MODEL_NAME = "Test Action Model";
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
|
||||
cy.intercept("GET", "/api/action").as("getActions");
|
||||
cy.intercept("GET", "/api/action/v2/tmp-action").as("getActions");
|
||||
cy.intercept("PUT", "/api/action/*").as("updateAction");
|
||||
cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");
|
||||
|
||||
@@ -27,9 +27,7 @@ const MODEL_NAME = "Test Action Model";
|
||||
"/api/dashboard/*/dashcard/*/execute?parameters=*",
|
||||
).as("prefetchValues");
|
||||
|
||||
cy.intercept("POST", "/api/dashboard/*/dashcard/*/execute").as(
|
||||
"executeAction",
|
||||
);
|
||||
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
|
||||
});
|
||||
|
||||
H.describeWithSnowplow("adding and executing actions", () => {
|
||||
@@ -1028,9 +1026,7 @@ const MODEL_NAME = "Test Action Model";
|
||||
|
||||
cy.wait("@executeAction").then((interception) => {
|
||||
expect(
|
||||
Object.values(interception.request.body.parameters)
|
||||
.sort()
|
||||
.join(","),
|
||||
Object.values(interception.request.body.input).sort().join(","),
|
||||
).to.equal("1,2020-01-01");
|
||||
});
|
||||
|
||||
@@ -1056,14 +1052,12 @@ describe("action error handling", { tags: ["@external", "@actions"] }, () => {
|
||||
modelName: MODEL_NAME,
|
||||
});
|
||||
|
||||
cy.intercept("GET", "/api/action").as("getActions");
|
||||
cy.intercept("GET", "/api/action/v2/tmp-action").as("getActions");
|
||||
cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
|
||||
cy.intercept("GET", "/api/dashboard/*/dashcard/*/execute?parameters=*").as(
|
||||
"prefetchValues",
|
||||
);
|
||||
cy.intercept("POST", "/api/dashboard/*/dashcard/*/execute").as(
|
||||
"executeAction",
|
||||
);
|
||||
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
|
||||
});
|
||||
|
||||
it("should show detailed form errors for constraint violations when executing model actions", () => {
|
||||
@@ -1107,7 +1101,7 @@ describe(
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
|
||||
cy.intercept("GET", "/api/action").as("getActions");
|
||||
cy.intercept("GET", "/api/action/v2/tmp-action").as("getActions");
|
||||
cy.intercept("PUT", "/api/action/*").as("updateAction");
|
||||
cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");
|
||||
|
||||
@@ -1227,6 +1221,7 @@ describe(
|
||||
waitForValidActions();
|
||||
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Models").click();
|
||||
cy.findByText(MODEL_NAME).click();
|
||||
cy.findByText(ACTION_NAME).click();
|
||||
|
||||
@@ -1289,6 +1284,7 @@ function createDashboardWithActionButton({
|
||||
waitForValidActions();
|
||||
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByText("Models").click();
|
||||
cy.findByText(modelName).click();
|
||||
cy.findByText(actionName).click();
|
||||
});
|
||||
@@ -1376,7 +1372,9 @@ function getActionParametersInputModal() {
|
||||
|
||||
function waitForValidActions() {
|
||||
cy.wait("@getActions").then(({ response }) => {
|
||||
const { body: actions } = response;
|
||||
const {
|
||||
body: { actions },
|
||||
} = response;
|
||||
|
||||
actions.forEach((action) => {
|
||||
expect(action.parameters).to.have.length.gt(0);
|
||||
|
||||
@@ -252,6 +252,7 @@ describe("issue 51020", () => {
|
||||
cy.findByLabelText("Add action").click();
|
||||
cy.button("Pick an action").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Models").click();
|
||||
cy.findByText(modelName).click();
|
||||
cy.findByText("Update").click();
|
||||
cy.findAllByText("Ask the user").eq(0).click();
|
||||
@@ -430,7 +431,7 @@ describe("issue 32840", () => {
|
||||
H.visitModel(modelId);
|
||||
});
|
||||
|
||||
cy.intercept("POST", "/api/action/*/execute").as("executeAction");
|
||||
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
|
||||
});
|
||||
|
||||
it("uses correct timestamp when executing implicit update action (metabase#32840)", () => {
|
||||
|
||||
@@ -80,7 +80,7 @@ describe(
|
||||
cy.intercept("GET", "/api/action/*").as("getAction");
|
||||
cy.intercept("GET", "/api/action?model-id=*").as("getModelAction");
|
||||
cy.intercept("PUT", "/api/action/*").as("updateAction");
|
||||
cy.intercept("POST", "/api/action/*/execute").as("executeAction");
|
||||
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
|
||||
cy.intercept("POST", "/api/action").as("createAction");
|
||||
cy.intercept("GET", "/api/table/*/query_metadata*").as("fetchMetadata");
|
||||
cy.intercept("GET", "/api/search?archived=true").as("getArchived");
|
||||
|
||||
@@ -343,10 +343,11 @@ describe("issues 55617, 55618", () => {
|
||||
cy.findAllByPlaceholderText("Select a target")
|
||||
.should("have.length", 2)
|
||||
.eq(0)
|
||||
.should("have.value", "People → ID");
|
||||
.should("have.value", "Products → ID");
|
||||
cy.findAllByPlaceholderText("Select a target")
|
||||
.eq(1)
|
||||
.should("have.value", "Products → ID");
|
||||
.should("have.value", "People → ID");
|
||||
|
||||
cy.findAllByPlaceholderText("Select a target").eq(0).click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Orders → ID").should("be.visible");
|
||||
@@ -378,10 +379,10 @@ describe("issues 55617, 55618", () => {
|
||||
.should("have.value", "People → ID")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Orders → ID").should("be.visible");
|
||||
cy.findByText("People → ID").should("be.visible");
|
||||
cy.findByText("Products → ID").should("be.visible");
|
||||
cy.findByText("Reviews → ID").should("be.visible").click();
|
||||
cy.findByText("Orders → ID").should("exist");
|
||||
cy.findByText("People → ID").should("exist");
|
||||
cy.findByText("Products → ID").should("exist");
|
||||
cy.findByText("Reviews → ID").should("exist").click();
|
||||
});
|
||||
cy.findByPlaceholderText("Select a target").should(
|
||||
"have.value",
|
||||
|
||||
@@ -2187,12 +2187,12 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
|
||||
],
|
||||
expressions: ["Net"],
|
||||
filters: [
|
||||
"Product → Title is Doohickey",
|
||||
"Product → Vendor is Doohickey",
|
||||
"ID is 7021",
|
||||
"Net is equal to -80",
|
||||
"Reviews - Product → Reviewer is Doohickey",
|
||||
"User → Longitude is equal to -80",
|
||||
"Product → Vendor is Doohickey",
|
||||
"Reviews - Product → Reviewer is Doohickey",
|
||||
"ID is 7021",
|
||||
"Product → Title is Doohickey",
|
||||
"Net is equal to -80",
|
||||
],
|
||||
aggregations: ["Count", "Sum of Total"],
|
||||
breakouts: [
|
||||
|
||||
@@ -1529,6 +1529,7 @@ describe("issue 48878", () => {
|
||||
cy.button("Add action").click();
|
||||
cy.button("Pick an action").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Models").click();
|
||||
cy.findByText("SQL Model").click();
|
||||
cy.findByText("Test action").click();
|
||||
cy.button("Done").click();
|
||||
|
||||
230
e2e/test/scenarios/data-editing/data-editing-actions.cy.spec.ts
Normal file
230
e2e/test/scenarios/data-editing/data-editing-actions.cy.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { getDashboardCard } from "e2e/support/helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("scenarios > data editing > actions", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.setTokenFeatures("all");
|
||||
|
||||
cy.intercept("PUT", "/api/dashboard/*").as("saveDashboard");
|
||||
|
||||
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
|
||||
});
|
||||
|
||||
it("should allow to assign a table action to a editable table dashcard", () => {
|
||||
H.createDashboard().then(({ body: { id } }) => {
|
||||
H.visitDashboard(id);
|
||||
});
|
||||
H.createDashboard({ name: "13736 Dashboard" }).then(
|
||||
({ body: { id: dashboardId } }) => {
|
||||
cy.wrap(dashboardId).as("dashboardId");
|
||||
},
|
||||
);
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
cy.findByLabelText("Add editable table").click();
|
||||
cy.findByTestId("add-table-sidebar").findByText("Orders").click();
|
||||
|
||||
getDashboardCard(0)
|
||||
.realHover()
|
||||
.within(() => {
|
||||
cy.findByLabelText("Configure").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("configure-editable-table-sidebar").within(() => {
|
||||
cy.findByText("Actions").click();
|
||||
|
||||
cy.findByText("Add new connected action").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-action-settings-modal").should("be.visible");
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("People").click();
|
||||
cy.findByText("Update").click();
|
||||
|
||||
cy.findByText("Choose a new action").should("be.visible");
|
||||
cy.findByText(
|
||||
"Where should the values for this row action come from?",
|
||||
).should("be.visible");
|
||||
|
||||
cy.findByTestId("editable-text")
|
||||
.click()
|
||||
.type("{SelectAll}My Update Action");
|
||||
|
||||
cy.button("Done").scrollIntoView().click();
|
||||
});
|
||||
|
||||
cy.findByTestId("editable-table-connected-actions-list")
|
||||
.findByText("My Update Action")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByTestId("edit-bar").findByText("Save").click();
|
||||
|
||||
cy.wait("@saveDashboard");
|
||||
|
||||
getDashboardCard(0).within(() => {
|
||||
cy.findByText("Row Actions").should("be.visible");
|
||||
cy.findAllByText("My Update Action").first().should("be.visible");
|
||||
|
||||
cy.findAllByText("My Update Action").first().click();
|
||||
});
|
||||
|
||||
cy.findByTestId("data-grid-action-execute-modal")
|
||||
.findByText("My Update Action")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe(
|
||||
"scenarios > data editing > action picker",
|
||||
{ tags: ["@external"] },
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsAdmin();
|
||||
H.setTokenFeatures("all");
|
||||
|
||||
cy.intercept("PUT", "/api/dashboard/*").as("saveDashboard");
|
||||
cy.intercept("GET", `/api/action/v2/database/${SAMPLE_DB_ID}/table`).as(
|
||||
"getSampleDbTables",
|
||||
);
|
||||
cy.intercept("GET", `/api/action/v2/database/${WRITABLE_DB_ID}/table`).as(
|
||||
"getPostgresTables",
|
||||
);
|
||||
|
||||
H.setTableEditingEnabledForDB(WRITABLE_DB_ID);
|
||||
H.setActionsEnabledForDB(WRITABLE_DB_ID);
|
||||
});
|
||||
|
||||
it("should allow to pick an action for editable table dashcard", () => {
|
||||
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
|
||||
|
||||
H.createModelFromTableName({
|
||||
tableName: "products",
|
||||
modelName: "Products Model Test",
|
||||
});
|
||||
|
||||
H.createDashboard().then(({ body: { id } }) => {
|
||||
H.visitDashboard(id);
|
||||
});
|
||||
H.createDashboard({ name: "13736 Dashboard" }).then(
|
||||
({ body: { id: dashboardId } }) => {
|
||||
cy.wrap(dashboardId).as("dashboardId");
|
||||
},
|
||||
);
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
cy.findByLabelText("Add editable table").click();
|
||||
cy.findByTestId("add-table-sidebar").within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
|
||||
getDashboardCard(0)
|
||||
.realHover()
|
||||
.within(() => {
|
||||
cy.findByLabelText("Configure").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("configure-editable-table-sidebar").within(() => {
|
||||
cy.findByText("Actions").click();
|
||||
|
||||
cy.findByText("Add new connected action").click();
|
||||
});
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Pick an action to add").should("be.visible");
|
||||
|
||||
cy.log("should display list of databases");
|
||||
cy.findByText("QA Postgres12").should("be.visible");
|
||||
cy.findByText("Sample Database").should("be.visible");
|
||||
|
||||
cy.log("should have editable table db preselected");
|
||||
cy.findByText("Sample Database")
|
||||
.closest('[role="link"]')
|
||||
.should("have.attr", "data-active");
|
||||
|
||||
cy.log("should allow to pick an action");
|
||||
|
||||
cy.findByText("QA Postgres12").click();
|
||||
cy.wait("@getPostgresTables");
|
||||
|
||||
cy.findByText("Reviews").should("be.visible").click();
|
||||
cy.findByText("Create or Update").click();
|
||||
|
||||
cy.findByText(
|
||||
"Where should the values for this row action come from?",
|
||||
).should("be.visible");
|
||||
|
||||
cy.log(
|
||||
'should preselect current action parent entity on "choose new action"',
|
||||
);
|
||||
cy.findByText("Choose a new action").click();
|
||||
|
||||
cy.findByText("Reviews")
|
||||
.closest('[role="link"]')
|
||||
.should("have.attr", "data-active");
|
||||
cy.findByText("QA Postgres12")
|
||||
.closest('[role="link"]')
|
||||
.should("have.attr", "data-active");
|
||||
|
||||
cy.findByText("Models").click();
|
||||
|
||||
cy.findByText("Products Model Test").should("be.visible");
|
||||
cy.findByText("Orders Model").should("be.visible");
|
||||
|
||||
cy.findByText("Products Model Test").click();
|
||||
cy.findByText("There are no actions for this model").should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
cy.findByText("Create a new action").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("action-creator-modal")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
H.fillActionQuery("SELECT 1");
|
||||
|
||||
cy.findByText("Save").click();
|
||||
});
|
||||
|
||||
H.modal()
|
||||
.should("have.length", 3)
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByPlaceholderText("My new fantastic action")
|
||||
.type("New Action 3000")
|
||||
.blur();
|
||||
cy.findByText("Create").click();
|
||||
});
|
||||
|
||||
H.modal()
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByText("Pick an action to add").should("be.visible");
|
||||
|
||||
cy.findByText("New Action 3000").should("be.visible").click();
|
||||
});
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("This action has no parameters to map").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("New Action 3000").should("be.visible");
|
||||
|
||||
cy.findByText("Done").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("editable-table-connected-actions-list")
|
||||
.findByText("New Action 3000")
|
||||
.should("be.visible");
|
||||
});
|
||||
},
|
||||
);
|
||||
634
e2e/test/scenarios/data-editing/data-editing-alerts.cy.spec.ts
Normal file
634
e2e/test/scenarios/data-editing/data-editing-alerts.cy.spec.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
import { USERS, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
|
||||
import {
|
||||
createNotificationWithCustomTemplate,
|
||||
fillInCustomTemplate,
|
||||
verifyNotificationTemplateResponse,
|
||||
} from "./e2e-helpers/notification-helpers";
|
||||
import { setupAlert } from "./e2e-helpers/setup";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
const TABLE_NAME = "colors27745";
|
||||
const ADMIN_NAME = `${USERS.admin.first_name} ${USERS.admin.last_name}`;
|
||||
|
||||
describe("scenarios > data editing > setting alerts", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", "/api/notification").as("getNotification");
|
||||
cy.intercept("POST", "/api/notification").as("createNotification");
|
||||
|
||||
cy.intercept("POST", "/api/action/v2/execute-bulk").as("executeBulk");
|
||||
|
||||
cy.log("Setting up writable PostgresDB");
|
||||
H.restore("postgres-writable");
|
||||
H.resetTestTable({
|
||||
type: "postgres",
|
||||
table: TABLE_NAME,
|
||||
});
|
||||
cy.signInAsAdmin();
|
||||
H.resyncDatabase({
|
||||
dbId: WRITABLE_DB_ID,
|
||||
tableName: TABLE_NAME,
|
||||
});
|
||||
|
||||
H.setTokenFeatures("all");
|
||||
H.setupSMTP();
|
||||
|
||||
H.setTableEditingEnabledForDB(WRITABLE_DB_ID);
|
||||
});
|
||||
|
||||
it("should have icon to set alerts", () => {
|
||||
openDatabaseTable();
|
||||
cy.findByTestId("table-notifications-trigger").should("be.visible");
|
||||
});
|
||||
|
||||
describe("Show list of existing alerts", () => {
|
||||
it("should show list of existing alerts", () => {
|
||||
H.getTableId({
|
||||
name: TABLE_NAME,
|
||||
}).then((tableId) => {
|
||||
setupAlert(tableId, "event/row.created");
|
||||
setupAlert(tableId, "event/row.updated");
|
||||
setupAlert(tableId, "event/row.deleted");
|
||||
});
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.then(() => {
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("alert-list-modal")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Notify when new records are created").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("Notify when records are updated").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("Notify when records are deleted").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Trigger an alert for each event type", () => {
|
||||
describe("create event", () => {
|
||||
it("should create an alert for 'row created' events with default template", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when new records are created/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-data-view-header").within(() => {
|
||||
cy.findByText("New record").click();
|
||||
});
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByRole("combobox").eq(0).type("black");
|
||||
cy.document().findByRole("listbox").eq(0).click();
|
||||
|
||||
cy.findByRole("button", { name: "Create new record" }).click();
|
||||
cy.wait("@executeBulk");
|
||||
});
|
||||
|
||||
cy.log("Testing default email template");
|
||||
|
||||
H.checkEmailContent(
|
||||
`A new record was added to "${TABLE_NAME}" by ${ADMIN_NAME}`,
|
||||
`A new record was created in Table ${TABLE_NAME}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should create an alert for 'row created' events with custom template", () => {
|
||||
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
|
||||
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
createNotificationWithCustomTemplate(
|
||||
/when new records are created/i,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
).then((interception) => {
|
||||
verifyNotificationTemplateResponse(
|
||||
interception,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-data-view-header").within(() => {
|
||||
cy.findByText("New record").click();
|
||||
});
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByRole("combobox").eq(0).type("black");
|
||||
cy.document().findByRole("listbox").eq(0).click();
|
||||
|
||||
cy.findByRole("button", { name: "Create new record" }).click();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
cy.log("Testing default email template");
|
||||
|
||||
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
|
||||
"id",
|
||||
"black",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update event", () => {
|
||||
it("should trigger an alert for 'row updated' events with default template", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when any cell changes it's value/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.findByText("red").click();
|
||||
cy.focused().type("blue").blur();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
cy.log("Testing default email template");
|
||||
|
||||
H.checkEmailContent(
|
||||
`A record was updated in "${TABLE_NAME}" by ${ADMIN_NAME}`,
|
||||
[`A record was updated in Table ${TABLE_NAME}`, "redblue"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should trigger an alert for 'row updated' events with custom template", () => {
|
||||
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
|
||||
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
createNotificationWithCustomTemplate(
|
||||
/when any cell changes it's value/i,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
).then((interception) => {
|
||||
verifyNotificationTemplateResponse(
|
||||
interception,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.findByText("red").click();
|
||||
cy.focused().type("blue").blur();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
cy.log("Testing custom email template");
|
||||
|
||||
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
|
||||
"id",
|
||||
"name",
|
||||
"redblue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should trigger an alert for 'row updated' events when condition is met", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when any cell changes it's value/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.findByText("Add conditions for alerts").click();
|
||||
cy.findByPlaceholderText("Select column").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /name/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByPlaceholderText("Select value").click().type("white").blur();
|
||||
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.findByText("red").click();
|
||||
cy.focused().type("{selectall}{backspace}white").blur();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
cy.log("Testing custom email template");
|
||||
|
||||
H.checkEmailContent(
|
||||
`A record was updated in "${TABLE_NAME}" by ${ADMIN_NAME}`,
|
||||
[`A record was updated in Table ${TABLE_NAME}`, "white"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should not trigger an alert for 'row updated' events when condition is not met", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when any cell changes it's value/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.findByText("Add conditions for alerts").click();
|
||||
cy.findByPlaceholderText("Select column").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /name/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByPlaceholderText("Select value").click().type("white").blur();
|
||||
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.findByText("red").click();
|
||||
cy.focused().type("{selectall}{backspace}black").blur();
|
||||
});
|
||||
|
||||
cy.wait("@executeBulk");
|
||||
|
||||
// Alert on updated value was not sent, since it didn't meet the condition.
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body.length).to.equal(1);
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete event", () => {
|
||||
it("should trigger an alert for 'row deleted' events with default template", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when records are deleted/i,
|
||||
})
|
||||
.click();
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.get("[data-index=0]").within(() => {
|
||||
cy.findByRole("checkbox").click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.findByTestId("table-data-view-header").within(() => {
|
||||
cy.findByText("Delete").click();
|
||||
cy.document().findByText("Delete 1 record").click();
|
||||
cy.wait("@executeBulk");
|
||||
});
|
||||
|
||||
cy.log("Testing default email template");
|
||||
|
||||
H.checkEmailContent(
|
||||
`A record was deleted from "${TABLE_NAME}" by ${ADMIN_NAME}`,
|
||||
[`A record was deleted in Table ${TABLE_NAME}`],
|
||||
);
|
||||
});
|
||||
|
||||
it("should trigger an alert for 'row deleted' events with custom template", () => {
|
||||
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
|
||||
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
createNotificationWithCustomTemplate(
|
||||
/when records are deleted/i,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
).then((interception) => {
|
||||
verifyNotificationTemplateResponse(
|
||||
interception,
|
||||
CUSTOM_SUBJECT,
|
||||
CUSTOM_BODY,
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").should("not.exist");
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert created.").should("be.visible");
|
||||
});
|
||||
|
||||
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
|
||||
expect(body[0].subject).to.include("You set up a table notification");
|
||||
});
|
||||
|
||||
cy.findByTestId("table-body").within(() => {
|
||||
cy.get("[data-index=0]").within(() => {
|
||||
cy.findByRole("checkbox").click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.findByTestId("table-data-view-header").within(() => {
|
||||
cy.findByText("Delete").click();
|
||||
cy.document().findByText("Delete 1 record").click();
|
||||
cy.wait("@executeBulk");
|
||||
});
|
||||
|
||||
cy.log("Testing custom email template");
|
||||
|
||||
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
|
||||
"id",
|
||||
"name",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Update an existing alert", () => {
|
||||
it("should update an existing alert", () => {
|
||||
H.getTableId({
|
||||
name: TABLE_NAME,
|
||||
}).then((tableId) => {
|
||||
setupAlert(tableId, "event/row.created");
|
||||
});
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
cy.findByTestId("alert-list-modal")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Notify when new records are created")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
});
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByText("Edit alert").should("be.visible");
|
||||
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when any cell changes it's value/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.findByRole("button", { name: "Save changes" }).click();
|
||||
});
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("Alert updated.").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Delete an existing alert", () => {
|
||||
it("should delete an existing alert", () => {
|
||||
H.getTableId({
|
||||
name: TABLE_NAME,
|
||||
}).then((tableId) => {
|
||||
setupAlert(tableId, "event/row.created");
|
||||
});
|
||||
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
cy.findByTestId("alert-list-modal")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Notify when new records are created").realHover();
|
||||
cy.root().findByLabelText("Delete this alert").click();
|
||||
cy.document()
|
||||
.findByTestId("delete-confirm")
|
||||
.findByText("Delete it")
|
||||
.click();
|
||||
});
|
||||
|
||||
cy.findByTestId("toast-undo").within(() => {
|
||||
cy.findByText("The alert was successfully deleted.").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Template preview", () => {
|
||||
it("should display default template preview", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when new records are created/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.intercept("POST", "/api/notification/preview_template").as(
|
||||
"previewTemplate",
|
||||
);
|
||||
|
||||
cy.findByLabelText("Show preview").click();
|
||||
|
||||
cy.wait("@previewTemplate");
|
||||
cy.findByTestId("preview-template-panel").should("be.visible");
|
||||
cy.findByTestId("preview-template-panel").within(() => {
|
||||
cy.root().contains(`A new record was added to "${TABLE_NAME}"`);
|
||||
cy.root().contains(`A new record was created in Table ${TABLE_NAME}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should display custom template preview", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when new records are created/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
fillInCustomTemplate(
|
||||
"My custom subject for {{table.name}}",
|
||||
"{{#each record}} {{@key}}: {{@value}} {{/each}}",
|
||||
);
|
||||
|
||||
cy.intercept("POST", "/api/notification/preview_template").as(
|
||||
"previewTemplate",
|
||||
);
|
||||
|
||||
cy.findByLabelText("Show preview").click();
|
||||
|
||||
cy.wait("@previewTemplate");
|
||||
cy.findByTestId("preview-template-panel").should("be.visible");
|
||||
cy.findByTestId("preview-template-panel").within(() => {
|
||||
cy.root().contains(`My custom subject for ${TABLE_NAME}`);
|
||||
cy.root().should("not.contain", "a new record was created");
|
||||
cy.root().contains(/id.*name/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message if template is incorrect", () => {
|
||||
openDatabaseTable();
|
||||
|
||||
cy.findByTestId("table-notifications-trigger").click();
|
||||
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: /when new records are created/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
fillInCustomTemplate(
|
||||
"My custom subject for {{table.name}}",
|
||||
"{{/each}}",
|
||||
);
|
||||
|
||||
cy.intercept("POST", "/api/notification/preview_template").as(
|
||||
"previewTemplate",
|
||||
);
|
||||
|
||||
cy.findByLabelText("Show preview").click();
|
||||
|
||||
cy.wait("@previewTemplate");
|
||||
cy.findByTestId("preview-template-panel").should("be.visible");
|
||||
cy.findByTestId("preview-template-panel").within(() => {
|
||||
cy.root().should("not.contain", "My custom subject for");
|
||||
cy.root().should("contain", "Failed to render template");
|
||||
cy.root().contains(/found:.*expected:/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function openDatabaseTable() {
|
||||
H.getTableId({
|
||||
name: TABLE_NAME,
|
||||
}).then((tableId) => {
|
||||
cy.visit(`/browse/databases/${WRITABLE_DB_ID}/tables/${tableId}/edit`);
|
||||
});
|
||||
}
|
||||
136
e2e/test/scenarios/data-editing/data-editing.cy.spec.ts
Normal file
136
e2e/test/scenarios/data-editing/data-editing.cy.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
const { ORDERS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
describe("scenarios > data-editing", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.setTokenFeatures("all");
|
||||
|
||||
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
|
||||
|
||||
cy.intercept("GET", "/api/database").as("getDatabases");
|
||||
cy.intercept("GET", "/api/table/*").as("getTable");
|
||||
});
|
||||
|
||||
it("should allow to open table data page", () => {
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.wait("@getTable");
|
||||
|
||||
cy.findByTestId("main-navbar-root").should("not.be.visible");
|
||||
|
||||
cy.findByTestId("table-data-view-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("People").should("be.visible");
|
||||
|
||||
cy.findByText("Explore").should("be.visible");
|
||||
cy.findByText("Edit").should("be.visible");
|
||||
|
||||
cy.findByTestId("table-root").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to open table data edit mode", () => {
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.wait("@getTable");
|
||||
|
||||
cy.findByTestId("table-data-view-root").findByText("Edit").click();
|
||||
|
||||
cy.findByTestId("edit-table-data-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("People").should("be.visible");
|
||||
cy.findByTestId("head-crumbs-container")
|
||||
.findByText("Edit")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByTestId("table-root").should("be.visible");
|
||||
});
|
||||
|
||||
cy.findByTestId("head-crumbs-container").findByText("People").click();
|
||||
|
||||
cy.findByTestId("table-data-view-root").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow to open table data explore mode - query builder", () => {
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.wait("@getTable");
|
||||
|
||||
cy.findByTestId("table-data-view-root").findByText("Explore").click();
|
||||
|
||||
cy.findByTestId("query-builder-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Sample Database");
|
||||
cy.findByText("People");
|
||||
});
|
||||
});
|
||||
|
||||
describe("db setting is disabled", () => {
|
||||
it("should not allow to open table data view by default", () => {
|
||||
H.setTableEditingEnabledForDB(SAMPLE_DB_ID, false);
|
||||
|
||||
openDatabaseTable("People");
|
||||
|
||||
cy.findByTestId("query-builder-root")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Sample Database");
|
||||
cy.findByText("People");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("table edit mode", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", `/api/table/${ORDERS_ID}/query_metadata`).as(
|
||||
"getOrdersTable",
|
||||
);
|
||||
|
||||
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
|
||||
|
||||
cy.wait("@getOrdersTable");
|
||||
});
|
||||
|
||||
it("should allow to sort", () => {
|
||||
cy.findByTestId("table-header").within(() => {
|
||||
cy.findByText("Quantity").click();
|
||||
|
||||
cy.findAllByTestId("header-sort-indicator").should("have.length", 1);
|
||||
|
||||
cy.findByText("Quantity")
|
||||
.closest("[role=button]")
|
||||
.findByLabelText("chevronup icon")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByText("Quantity").click();
|
||||
|
||||
cy.findByText("Quantity")
|
||||
.closest("[role=button]")
|
||||
.findByLabelText("chevrondown icon")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByText("Quantity").click();
|
||||
|
||||
cy.findAllByTestId("header-sort-indicator").should("have.length", 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function openDatabaseTable(tableName: string) {
|
||||
cy.visit("/browse/databases");
|
||||
|
||||
cy.wait("@getDatabases");
|
||||
|
||||
cy.findByTestId("database-browser").findByText("Sample Database").click();
|
||||
cy.findByTestId("browse-schemas").findByText(tableName).click();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Using type import to avoid runtime dependency on cypress types
|
||||
type Interception = {
|
||||
response?: {
|
||||
statusCode?: number;
|
||||
body?: {
|
||||
handlers?: Array<{
|
||||
template?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const fillInCustomTemplate = (subject: string, body: string) => {
|
||||
cy.findByTestId("email-template-subject")
|
||||
.find(".cm-content")
|
||||
.then(([contentElement]) => {
|
||||
const cm = contentElement.cmView?.view;
|
||||
if (cm) {
|
||||
cm.contentDOM.focus();
|
||||
cm.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: cm.state.doc.length,
|
||||
insert: subject,
|
||||
},
|
||||
});
|
||||
cm.contentDOM.blur();
|
||||
}
|
||||
|
||||
cy.wait(200);
|
||||
|
||||
cy.document()
|
||||
.findByTestId("email-template-body")
|
||||
.find(".cm-content")
|
||||
.then(([contentElement]) => {
|
||||
const cm = contentElement.cmView?.view;
|
||||
if (cm) {
|
||||
cm.contentDOM.focus();
|
||||
cm.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: cm.state.doc.length,
|
||||
insert: body,
|
||||
},
|
||||
});
|
||||
cm.contentDOM.blur();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const verifyNotificationTemplateResponse = (
|
||||
response: Interception,
|
||||
expectedSubject: string,
|
||||
expectedBody: string,
|
||||
) => {
|
||||
expect(response?.response?.statusCode).to.equal(200);
|
||||
const stringifiedTemplate = JSON.stringify(
|
||||
response?.response?.body?.handlers?.[0]?.template,
|
||||
);
|
||||
expect(stringifiedTemplate).to.contain(expectedSubject);
|
||||
expect(stringifiedTemplate).to.contain(expectedBody);
|
||||
};
|
||||
|
||||
export const createNotificationWithCustomTemplate = (
|
||||
eventName: string | RegExp,
|
||||
subject: string,
|
||||
body: string,
|
||||
) => {
|
||||
cy.findByTestId("table-notification-create").within(() => {
|
||||
cy.findByTestId("notification-event-select").click();
|
||||
cy.document()
|
||||
.findByRole("option", {
|
||||
name: eventName,
|
||||
})
|
||||
.click();
|
||||
|
||||
fillInCustomTemplate(subject, body);
|
||||
|
||||
cy.findByRole("button", { name: "Done" }).click();
|
||||
});
|
||||
|
||||
// Wait for the notification to be created and return the response
|
||||
return cy.wait("@createNotification");
|
||||
};
|
||||
26
e2e/test/scenarios/data-editing/e2e-helpers/setup.ts
Normal file
26
e2e/test/scenarios/data-editing/e2e-helpers/setup.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ADMIN_USER_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
export const setupAlert = (tableId: number, eventName: string) => {
|
||||
return cy.request("POST", "/api/notification", {
|
||||
payload_type: "notification/system-event",
|
||||
payload: { event_name: eventName, table_id: tableId },
|
||||
payload_id: null,
|
||||
handlers: [
|
||||
{
|
||||
channel_type: "channel/email",
|
||||
recipients: [
|
||||
{
|
||||
type: "notification-recipient/user",
|
||||
user_id: ADMIN_USER_ID,
|
||||
details: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
condition: [
|
||||
"and",
|
||||
["=", ["context", "table_id"], tableId],
|
||||
["=", ["context", "event_name"], eventName],
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -126,7 +126,7 @@ describe("scenarios > account > notifications", () => {
|
||||
.findByText("Successfully unsubscribed.")
|
||||
.should("exist");
|
||||
|
||||
cy.findByTestId("alert-delete").within(() => {
|
||||
cy.findByTestId("delete-confirm").within(() => {
|
||||
cy.findByText("You’re unsubscribed. Delete this alert as well?");
|
||||
cy.findByText("Delete it").click();
|
||||
});
|
||||
|
||||
@@ -53,7 +53,8 @@ describe("impersonated permission", { tags: "@external" }, () => {
|
||||
setImpersonatedPermission();
|
||||
});
|
||||
|
||||
it("have limited access", () => {
|
||||
// TODO [WRK]: refactor this test to check data editing
|
||||
it.skip("have limited access", () => {
|
||||
cy.signInAsImpersonatedUser();
|
||||
|
||||
cy.visit(`/browse/databases/${PG_DB_ID}`);
|
||||
|
||||
@@ -53,7 +53,7 @@ const SUM_OF_TOTAL_MONTH_EXCLUDE_MAY_AUG = {
|
||||
"!=",
|
||||
[
|
||||
"field",
|
||||
"CREATED_AT",
|
||||
ORDERS.CREATED_AT,
|
||||
{
|
||||
"base-type": "type/DateTime",
|
||||
"temporal-unit": "month-of-year",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
[metabase-enterprise.billing.api.routes]
|
||||
[metabase-enterprise.content-translation.routes]
|
||||
[metabase-enterprise.content-verification.api.routes]
|
||||
[metabase-enterprise.data-editing-public.api]
|
||||
[metabase-enterprise.data-editing.api]
|
||||
[metabase-enterprise.database-routing.api]
|
||||
[metabase-enterprise.gsheets.api :as gsheets.api]
|
||||
[metabase-enterprise.llm.api]
|
||||
@@ -43,6 +45,7 @@
|
||||
:collection-cleanup (deferred-tru "Collection Cleanup")
|
||||
:content-translation (deferred-tru "Content translation")
|
||||
:etl-connections (deferred-tru "ETL Connections")
|
||||
:table-data-editing (deferred-tru "Editing Table Data")
|
||||
:llm-autodescription (deferred-tru "LLM Auto-description")
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
:scim (deferred-tru "SCIM configuration")
|
||||
@@ -77,6 +80,8 @@
|
||||
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
|
||||
"/billing" metabase-enterprise.billing.api.routes/routes
|
||||
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
|
||||
"/data-editing" (premium-handler metabase-enterprise.data-editing.api/routes :table-data-editing)
|
||||
"/data-editing-public" (premium-handler metabase-enterprise.data-editing-public.api/routes :table-data-editing)
|
||||
"/database-routing" (premium-handler metabase-enterprise.database-routing.api/routes :database-routing)
|
||||
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
|
||||
(premium-handler :attached-dwh)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[metabase-enterprise.advanced-config.init]
|
||||
[metabase-enterprise.audit-app.init]
|
||||
[metabase-enterprise.cache.init]
|
||||
[metabase-enterprise.data-editing.init]
|
||||
[metabase-enterprise.gsheets.init]
|
||||
[metabase-enterprise.llm.init]
|
||||
[metabase-enterprise.metabot-v3.init]
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
(ns metabase-enterprise.data-editing.actions
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[metabase-enterprise.data-editing.models.undo :as undo]
|
||||
[metabase.actions.args :as actions.args]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[methodical.core :as methodical])
|
||||
(:import
|
||||
(clojure.lang ExceptionInfo)))
|
||||
|
||||
(derive :data-grid.row/create :data-grid.row/common)
|
||||
(derive :data-grid.row/create-or-update :data-grid.row/common)
|
||||
(derive :data-grid.row/update :data-grid.row/common)
|
||||
(derive :data-grid.row/delete :data-grid.row/common)
|
||||
|
||||
(defmethod actions/action-arg-map-schema :data-grid.row/common
|
||||
[_action]
|
||||
::actions.args/table.common)
|
||||
|
||||
(defmethod actions/normalize-action-arg-map :data-grid.row/common
|
||||
[_action input]
|
||||
(-> (assoc input :database (:id (actions/cached-database-via-table-id (:table-id input))))
|
||||
(update :row #(update-keys % u/qualified-name))))
|
||||
|
||||
(defn- perform-table-row-action!
|
||||
"Perform an action directly, skipping permissions checks etc, and generate outputs based on the table modifications."
|
||||
[action-kw context inputs xform-outputs]
|
||||
(update (actions/perform-nested-action! action-kw context inputs)
|
||||
:outputs
|
||||
xform-outputs))
|
||||
|
||||
(defn- post-process [outputs]
|
||||
(for [[table-id diffs] (u/group-by :table-id identity outputs)
|
||||
:let [pretty-rows (data-editing/invalidate-and-present! table-id (map :row diffs))]
|
||||
[op pretty-row] (map vector (map :op diffs) pretty-rows)]
|
||||
{:op op, :table-id table-id, :row pretty-row}))
|
||||
|
||||
(defn- coerce-inputs [inputs]
|
||||
(let [input->coerced (u/for-map [[table-id inputs] (group-by :table-id inputs)
|
||||
:let [coerced (data-editing/apply-coercions table-id (map :row inputs))]
|
||||
input+row (map vector inputs coerced)]
|
||||
input+row)]
|
||||
(for [input inputs]
|
||||
|
||||
(u/prog1 (assoc input :row (input->coerced input))
|
||||
(log/tracef "coerce row %s => %s" (:row input) (:row <>))))))
|
||||
|
||||
(defn- perform-data-grid-action! [action-kw context inputs]
|
||||
(let [next-inputs (coerce-inputs inputs)]
|
||||
(perform-table-row-action! action-kw context next-inputs
|
||||
(if (= :table.row/delete action-kw)
|
||||
identity
|
||||
post-process))))
|
||||
|
||||
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/create]
|
||||
[_action context inputs]
|
||||
(perform-data-grid-action! :table.row/create context inputs))
|
||||
|
||||
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/update]
|
||||
[_action context inputs]
|
||||
(perform-data-grid-action! :table.row/update context inputs))
|
||||
|
||||
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/delete]
|
||||
[_action context inputs]
|
||||
(perform-data-grid-action! :table.row/delete context inputs))
|
||||
|
||||
(mu/defmethod actions/default-mapping :data-grid.row/common
|
||||
[_ scope]
|
||||
(assoc (select-keys scope [:table-id]) :row :metabase-enterprise.data-editing.api/root))
|
||||
|
||||
(mu/defmethod actions/default-mapping :data-grid.row/delete
|
||||
[_ scope]
|
||||
(assoc (select-keys scope [:table-id])
|
||||
;; TODO yeah, a shorter namespace would be nice, that doesn't require importing from EE
|
||||
:row :metabase-enterprise.data-editing.api/input
|
||||
:delete-children [:metabase-enterprise.data-editing.api/param :delete-children]))
|
||||
|
||||
;; undo
|
||||
|
||||
(defmethod actions/action-arg-map-schema :data-editing/undo [_action] :map)
|
||||
(defmethod actions/action-arg-map-schema :data-editing/redo [_action] :map)
|
||||
|
||||
(defmethod actions/normalize-action-arg-map :data-editing/undo [_action _input] {})
|
||||
(defmethod actions/normalize-action-arg-map :data-editing/redo [_action _input] {})
|
||||
|
||||
(defn- translate-undo-error [e]
|
||||
(case (:error (ex-data e))
|
||||
:undo/none (ex-info (tru "Nothing to do") {:status-code 204} e)
|
||||
:undo/cannot-undelete (ex-info (tru "You cannot undo your previous change.") {:status-code 405} e)
|
||||
:undo/cannot-undo (ex-info (tru "Your previous change cannot be undone") {:status-code 405} e)
|
||||
:undo/conflict (ex-info (tru "Your previous change has a conflict with another edit") {:status-code 409} e)
|
||||
e))
|
||||
|
||||
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-editing/undo]
|
||||
[_action context _inputs]
|
||||
(try
|
||||
{:context context
|
||||
:outputs (undo/undo! context (:user-id context) (:scope context))}
|
||||
(catch ExceptionInfo e
|
||||
(throw (translate-undo-error e)))))
|
||||
|
||||
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-editing/redo]
|
||||
[_action context _inputs]
|
||||
(try
|
||||
{:context context
|
||||
:outputs (undo/redo! context (:user-id context) (:scope context))}
|
||||
(catch ExceptionInfo e
|
||||
(throw (translate-undo-error e)))))
|
||||
551
enterprise/backend/src/metabase_enterprise/data_editing/api.clj
Normal file
551
enterprise/backend/src/metabase_enterprise/data_editing/api.clj
Normal file
@@ -0,0 +1,551 @@
|
||||
(ns metabase-enterprise.data-editing.api
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[metabase-enterprise.data-editing.configure :as data-editing.configure]
|
||||
[metabase-enterprise.data-editing.data-editing :as data-editing]
|
||||
[metabase-enterprise.data-editing.describe :as data-editing.describe]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.actions.types :as types]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn require-authz?
|
||||
"Temporary hack to have auth be off by default, only on if MB_DATA_EDITING_AUTHZ=true.
|
||||
Remove once auth policy is fixed for dashboards."
|
||||
[]
|
||||
(= "true" (System/getenv "MB_DATA_EDITING_AUTHZ")))
|
||||
|
||||
(defn- check-permissions []
|
||||
(when (require-authz?)
|
||||
(api/check-superuser)))
|
||||
|
||||
(declare execute!*)
|
||||
|
||||
(api.macros/defendpoint :post "/table/:table-id"
|
||||
"Insert row(s) into the given table."
|
||||
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
|
||||
{}
|
||||
{:keys [rows]} :- [:map [:rows [:sequential {:min 1} :map]]]]
|
||||
(check-permissions)
|
||||
(let [scope {:table-id table-id}]
|
||||
{:created-rows (map :row (execute!* "data-grid.row/create" scope {} rows))}))
|
||||
|
||||
(api.macros/defendpoint :put "/table/:table-id"
|
||||
"Update row(s) within the given table."
|
||||
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
|
||||
{}
|
||||
{:keys [rows pks updates]}
|
||||
:- [:multi {:dispatch #(cond
|
||||
(:rows %) :mixed-updates
|
||||
(:pks %) :uniform-updates)}
|
||||
[:mixed-updates [:map [:rows [:sequential {:min 1} :map]]]]
|
||||
[:uniform-updates [:map
|
||||
[:pks [:sequential {:min 1} :map]]
|
||||
[:updates :map]]]]]
|
||||
(check-permissions)
|
||||
(if (empty? (or rows pks))
|
||||
{:updated []}
|
||||
(let [scope {:table-id table-id}
|
||||
rows (or rows
|
||||
;; For now, it's just a shim, because we haven't implemented an efficient bulk update action yet.
|
||||
;; This is a dumb shim; we're not checking that the pk maps are really (just) the pks.
|
||||
(map #(merge % updates) pks))]
|
||||
{:updated (map :row (execute!* "data-grid.row/update" scope nil rows))})))
|
||||
|
||||
;; This is a POST instead of DELETE as not all web proxies pass on the body of DELETE requests.
|
||||
(api.macros/defendpoint :post "/table/:table-id/delete"
|
||||
"Delete row(s) from the given table"
|
||||
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
|
||||
{}
|
||||
{:keys [rows]} :- [:map [:rows [:sequential {:min 1} :map]]]]
|
||||
(check-permissions)
|
||||
(let [scope {:table-id table-id}]
|
||||
(execute!* "data-grid.row/delete" scope nil rows)
|
||||
{:success true}))
|
||||
|
||||
;; might later be changed, or made driver specific, we might later drop the requirement depending on admin trust
|
||||
;; model (e.g are admins trusted with writing arbitrary SQL cases anyway, will non admins ever call this?)
|
||||
(def ^:private Identifier
|
||||
"A malli schema for strings that can be used as SQL identifiers"
|
||||
[:re #"^[\w\- ]+$"])
|
||||
|
||||
;; upload types are used temporarily, I expect this to change
|
||||
(def ^:private column-type->upload-type
|
||||
{"auto_incrementing_int_pk" :metabase.upload/auto-incrementing-int-pk
|
||||
"boolean" :metabase.upload/boolean
|
||||
"int" :metabase.upload/int
|
||||
"float" :metabase.upload/float
|
||||
"varchar255" :metabase.upload/varchar-255
|
||||
"text" :metabase.upload/text
|
||||
"date" :metabase.upload/date
|
||||
"datetime" :metabase.upload/datetime
|
||||
"offset_datetime" :metabase.upload/timestamp-with-time-zone})
|
||||
|
||||
(def ^:private ColumnType
|
||||
(into [:enum] (keys column-type->upload-type)))
|
||||
|
||||
(defn- ensure-database-type [driver column-type]
|
||||
(if-some [upload-type (column-type->upload-type column-type)]
|
||||
(driver/upload-type->database-type driver upload-type)
|
||||
(throw (ex-info (i18n/tru "Not a supported column type: {0}" column-type)
|
||||
{:status 400, :column-type column-type}))))
|
||||
|
||||
(api.macros/defendpoint :post "/database/:db-id/table"
|
||||
"Creates a new table in the given database"
|
||||
[{:keys [db-id]} :- [:map [:db-id ms/PositiveInt]]
|
||||
_
|
||||
{table-name :name
|
||||
:keys [primary_key columns]}
|
||||
:-
|
||||
[:map
|
||||
[:name Identifier]
|
||||
[:primary_key [:seqable {:min-count 1} Identifier]]
|
||||
[:columns [:seqable
|
||||
[:map
|
||||
[:name Identifier]
|
||||
[:type ColumnType]]]]]]
|
||||
(check-permissions)
|
||||
(let [{driver :engine :as database} (api/check-404 (t2/select-one :model/Database db-id))
|
||||
_ (actions/check-data-editing-enabled-for-database! database)
|
||||
column-map (->> (for [{column-name :name
|
||||
column-type :type} columns]
|
||||
[column-name (ensure-database-type driver column-type)])
|
||||
(into {}))]
|
||||
(driver/create-table! driver db-id table-name column-map :primary-key (map keyword primary_key))))
|
||||
|
||||
(api.macros/defendpoint :post "/webhook"
|
||||
"Creates a new webhook endpoint token.
|
||||
The token can be used with the unauthenticated ingestion endpoint.
|
||||
POST /ee/data-editing-public/webhook/{token} to insert rows."
|
||||
[_
|
||||
_
|
||||
{:keys [table-id]}] :- [:map [:table-id ms/PositiveInt]]
|
||||
(check-permissions)
|
||||
(let [_ (api/check-404 (t2/select-one :model/Table table-id))
|
||||
token (u/generate-nano-id)
|
||||
user-id api/*current-user-id*]
|
||||
(t2/insert! :table_webhook_token {:token token, :table_id table-id, :creator_id user-id})
|
||||
{:table_id table-id
|
||||
:token token}))
|
||||
|
||||
(api.macros/defendpoint :delete "/webhook/:token"
|
||||
"Deletes a webhook endpoint token."
|
||||
[{:keys [token]} :- [:map [:token [:string {:api/regex #"[0-9a-zA-Z-_]+"}]]]
|
||||
_
|
||||
_]
|
||||
(check-permissions)
|
||||
(let [deleted-count (t2/delete! :table_webhook_token :token token)]
|
||||
(api/check-404 (pos? deleted-count)))
|
||||
{})
|
||||
|
||||
(api.macros/defendpoint :get "/webhook"
|
||||
"Lists webhook endpoints tokens for a table.
|
||||
Behaviour is currently undefined if no table-id parameter is specified"
|
||||
[_
|
||||
{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]]
|
||||
(check-permissions)
|
||||
(api/check-404 (t2/select-one :model/Table table-id))
|
||||
(let [include-cols [:token :table_id :creator_id]]
|
||||
{:tokens (t2/select (into [:table_webhook_token] include-cols) :table_id table-id)}))
|
||||
|
||||
(defn- input->parameters
|
||||
"We support either named or id based parameters for invoking legacy actions. This converts keys to ids, if necessary."
|
||||
[parameters input]
|
||||
(let [slug-or-id->id (merge (u/index-by :slug :id parameters)
|
||||
(u/index-by :id :id parameters))]
|
||||
(if (not-every? #(contains? slug-or-id->id (name %)) (keys input))
|
||||
(throw (ex-info
|
||||
"Unexpected parameter(s) provided"
|
||||
{:status-code 400
|
||||
:input input
|
||||
:parameters parameters}))
|
||||
(update-keys input (comp slug-or-id->id name)))))
|
||||
|
||||
(defn- execute-saved-action! [action inputs]
|
||||
(doall
|
||||
(for [input inputs]
|
||||
(actions/execute-action! action (input->parameters (:parameters action) input)))))
|
||||
|
||||
(defn- execute-saved-action-from-id! [action-id inputs]
|
||||
(let [action (-> (actions/select-action :id action-id :archived false) (t2/hydrate :creator) api/read-check)]
|
||||
(execute-saved-action! action inputs)))
|
||||
|
||||
(def tmp-action
|
||||
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
|
||||
(api.macros/defendpoint :get "/tmp-action"
|
||||
"Returns all actions across all tables and models"
|
||||
[_
|
||||
_
|
||||
_]
|
||||
(check-permissions)
|
||||
(let [databases (t2/select [:model/Database :id :settings])
|
||||
editable-database? (comp boolean :database-enable-table-editing :settings)
|
||||
editable-databases (filter editable-database? databases)
|
||||
editable-tables (when (seq editable-databases)
|
||||
(t2/select :model/Table
|
||||
:db_id [:in (map :id editable-databases)]
|
||||
:active true))
|
||||
fields (when (seq editable-tables)
|
||||
(t2/select :model/Field :table_id [:in (map :id editable-tables)]))
|
||||
fields-by-table (group-by :table_id fields)
|
||||
table-actions (for [t editable-tables
|
||||
[op op-name] actions/enabled-table-actions
|
||||
:let [fields (fields-by-table (:id t))
|
||||
action (actions/table-primitive-action t fields op)]]
|
||||
(assoc action :table_name op-name))
|
||||
saved-actions (for [a (actions/select-actions nil :archived false)]
|
||||
(select-keys a [:name
|
||||
:model_id
|
||||
:type
|
||||
:database_id
|
||||
:id
|
||||
:visualization_settings
|
||||
:parameters]))]
|
||||
{:actions (vec (concat saved-actions table-actions))})))
|
||||
|
||||
(mr/def ::api-action-id-saved
|
||||
"Refers to a row in the actions table."
|
||||
ms/PositiveInt)
|
||||
|
||||
(mr/def ::api-action-id-primitive-or-dashboard-or-dashcard-action
|
||||
"We refer to primitive actions through their names.
|
||||
For now we also encode dashboard button and dashcard action ids as strings, but make we can stop that after WRK-483."
|
||||
:string)
|
||||
|
||||
(mr/def ::api-action-id-packed-mapping
|
||||
"The picker currently returns negative integers which encodes certain primitive actions with some config.
|
||||
This is just a poor man's ::api-action-expression, so maybe we can deprecate that."
|
||||
ms/NegativeInt)
|
||||
|
||||
(mr/def ::api-action-id
|
||||
"Primitive actions, saved actions, and packed encodings from the picker."
|
||||
[:or
|
||||
::api-action-id-saved
|
||||
::api-action-id-primitive-or-dashboard-or-dashcard-action
|
||||
::api-action-id-packed-mapping])
|
||||
|
||||
;; TODO this should become sequential so we can save the order.
|
||||
(mr/def ::action.config.param-map
|
||||
"Editable configuration used to transform the inputs passed to an action."
|
||||
[:map-of :keyword :any])
|
||||
|
||||
(mr/def ::action.config.mappings
|
||||
"Non -editable configuration used to transform the inputs passed to an action."
|
||||
[:map-of :keyword :any])
|
||||
|
||||
(mr/def ::unified-action.base
|
||||
[:or
|
||||
[:map {:closed true}
|
||||
[:action-id ms/PositiveInt]]
|
||||
[:map {:closed true}
|
||||
[:action-kw :keyword]
|
||||
[:mapping {:optional true} [:maybe :map]]]])
|
||||
|
||||
;; TODO Regret this name, let's rename it to something like ::action-expression once it's pure data.
|
||||
(mr/def ::unified-action
|
||||
"The internal representation used by our APIs, after we've parsed the relevant ids and fetched their configuration."
|
||||
[:or
|
||||
::unified-action.base
|
||||
[:map {:closed true}
|
||||
;; TODO Having an opaque id like this inside is not great.
|
||||
;; We eventually want to have fetched all relevant data already, so we can just dispatch.
|
||||
;; But, for now we're wanting to reuse legacy code which dispatches on the underlying toucan instances, and the
|
||||
;; time has not yet come to refactor those functions.
|
||||
;; TODO make this variant more self-describing, it's not clear that this integer is a dashcard id.
|
||||
[:dashboard-action ms/PositiveInt]]
|
||||
[:map {:closed true}
|
||||
[:name {:optional true} :string]
|
||||
[:inner-action ::unified-action.base]
|
||||
[:mapping {:optional true} [:maybe ::action.config.mappings]]
|
||||
[:param-map ::action.config.param-map]
|
||||
;; We will eventually want to generalize to support grids outside of dashboards.
|
||||
[:dashcard-id {:optional true} ms/PositiveInt]
|
||||
[:configurable {:optional true} :boolean]]])
|
||||
|
||||
(mr/def ::api-action-expression
|
||||
"A more relaxed version of ::unified-action that can still have opaque ::api-action-id expressions inside."
|
||||
;; TODO let's wait until we've written all our API tests and integrated the FE before typing this.
|
||||
:map)
|
||||
|
||||
(mr/def ::api-action-id-or-expression
|
||||
"All the various ways of referring to an action with the v2 APIs."
|
||||
[:or ::api-action-expression ::api-action-id])
|
||||
|
||||
(mu/defn- fetch-unified-action :- ::unified-action
|
||||
"Resolve various flavors of action-id into plain data, making it easier to dispatch on. Fetch config etc."
|
||||
[scope :- ::types/scope.hydrated
|
||||
raw-id :- ::api-action-id-or-expression]
|
||||
(cond
|
||||
(map? raw-id) (if-let [action-id (:action-id raw-id)]
|
||||
(if-let [parameters (:parameters raw-id)]
|
||||
(merge {:inner-action (fetch-unified-action scope action-id)}
|
||||
{:param-map (u/for-ordered-map [p parameters] [(keyword (:id p)) (dissoc p :id)])}
|
||||
(dissoc raw-id :action-id :parameters))
|
||||
raw-id)
|
||||
;; assume this is in the internal format already? probably don't want to support this.
|
||||
raw-id)
|
||||
(pos-int? raw-id) {:action-id raw-id}
|
||||
(neg-int? raw-id) (let [[op param] (actions/unpack-encoded-action-id raw-id)]
|
||||
(cond
|
||||
(isa? op :table.row/common)
|
||||
{:action-kw op
|
||||
:mapping {:table-id param :row ::root}}
|
||||
:else
|
||||
(throw (ex-info "Execution not supported for given encoded action" {:status 400
|
||||
:action-id raw-id
|
||||
:op op
|
||||
:param param}))))
|
||||
(string? raw-id) (if-let [[_ dashcard-id _nested-id] (re-matches #"^dashcard:([^:]+):(.*)$" raw-id)]
|
||||
;; There is a chicken-and-egg problem with creating actions inside dashcards.
|
||||
;; we need to put their action id (which references the dashcard id) inside the viz settings,
|
||||
;; before we save the dashcard for the first time, which only then generates its primary key.
|
||||
(let [dashcard-id (when (not= "unknown" dashcard-id) (parse-long dashcard-id))
|
||||
;; So, if we only have a placeholder for the dashcard id, get it from the scope.
|
||||
;; This hack always works since the frontend can't invoke row actions from anywhere else.
|
||||
;; From a semantic point of view, this hack still sucks. It'll be fixed by WRK-483.
|
||||
dashcard-id (if (pos-int? dashcard-id) dashcard-id (:dashcard-id scope))
|
||||
dashcard (api/check-404 (some->> dashcard-id (t2/select-one [:model/DashboardCard :visualization_settings])))
|
||||
;; TODO: this should belong to our configuration
|
||||
actions (-> dashcard :visualization_settings ((some-fn :editableTable.enabledActions :table.enabled_actions)))
|
||||
viz-action (api/check-404 (first (filter (comp #{raw-id} :id) actions)))
|
||||
inner-id (:actionId viz-action)
|
||||
inner (fetch-unified-action scope inner-id)
|
||||
action-type (:actionType viz-action "data-grid/custom-action")
|
||||
mapping (:mapping viz-action)
|
||||
;; TODO we should do this *later* because it's lossy - we lose the configured ordering.
|
||||
param-map (->> (:parameterMappings viz-action {})
|
||||
(u/index-by :parameterId #(dissoc % :parameterId))
|
||||
walk/keywordize-keys)]
|
||||
(assert (:enabled viz-action true) "Cannot call disabled actions")
|
||||
(case action-type
|
||||
("data-grid/built-in"
|
||||
"data-grid/custom-action"
|
||||
;; deprecated
|
||||
"data-grid/row-action")
|
||||
{:inner-action inner
|
||||
:mapping mapping
|
||||
:param-map param-map
|
||||
:dashcard-id dashcard-id
|
||||
:configurable (not= action-type "data-grid/built-in")}))
|
||||
(if-let [[_ dashcard-id] (re-matches #"^dashcard:(\d+)$" raw-id)]
|
||||
;; Dashboard buttons can only be invoked from dashboards
|
||||
;; We're not checking that the scope has the correct dashboard, but if it's incorrect, there
|
||||
;; will be a 404 thrown when we try to execute the action.
|
||||
;; This 404 here is not the best error to return, but we can polish this later.
|
||||
(let [dashboard-id (api/check-404 (:dashboard-id scope))]
|
||||
(api/read-check :model/Dashboard dashboard-id)
|
||||
{:dashboard-action (parse-long dashcard-id)})
|
||||
(if (re-matches u/uuid-regex raw-id)
|
||||
(throw (ex-info "Cannot execute an unsaved action given only its temporary id"
|
||||
{:status-code 400, :action-id raw-id}))
|
||||
;; Not a fancy encoded string, it must refer directly to a primitive.
|
||||
(let [kw (keyword raw-id)]
|
||||
{:action-kw kw
|
||||
:mapping (actions/default-mapping kw scope)}))))
|
||||
:else
|
||||
(throw (ex-info "Unexpected id value" {:status-code 400, :action-id raw-id}))))
|
||||
|
||||
(defn- hydrate-mapping [mapping]
|
||||
(walk/postwalk-replace
|
||||
{"::root" ::root
|
||||
"::key" ::key
|
||||
"::input" ::input
|
||||
"::params" ::params
|
||||
"::param" ::param}
|
||||
mapping))
|
||||
|
||||
(defn- augment-params
|
||||
[{:keys [dashcard-id param-map] :as _action} input params]
|
||||
;; TODO cool optimization where we don't fetch the row-data from the db if we only need the pk (or a subset of the pk)
|
||||
(let [row (delay (let [{:keys [table_id]} (actions/cached-value
|
||||
[:dashcard-viz dashcard-id]
|
||||
#(t2/select-one-fn :visualization_settings :model/DashboardCard dashcard-id))]
|
||||
(if-not table_id
|
||||
;; this is not an Editable, it must be a question - so we use the client-side row
|
||||
(update-keys input name)
|
||||
;; TODO batch fetching all these rows, across all inputs
|
||||
(let [fields (t2/select [:model/Field :id :name :semantic_type] :table_id table_id)
|
||||
pk-fields (filter #(= :type/PK (:semantic_type %)) fields)
|
||||
;; TODO we could restrict which fields we fetch in future
|
||||
[row] (data-editing/query-db-rows table_id pk-fields [input])
|
||||
_ (api/check-404 row)]
|
||||
;; TODO i would much prefer if we used field-ids and not names in the configuration
|
||||
(update-keys row name)))))]
|
||||
(if-not param-map
|
||||
(merge input params)
|
||||
(reduce-kv
|
||||
(fn [acc k v]
|
||||
(let [override (when-not (:visible v) (get params k))]
|
||||
(case (:sourceType v)
|
||||
;; I don't think the FE can send a value for ask-user, but our tests do it...
|
||||
"ask-user" (assoc acc k (if (contains? params k) override (:value v)))
|
||||
"constant" (assoc acc k (or override (:value v)))
|
||||
;; Hack for getting partially mapped data for /configure
|
||||
"row-data" (if-not (seq input)
|
||||
acc
|
||||
(assoc acc k (or override (get @row (:sourceValueTarget v)))))
|
||||
;; no mapping? omit the key
|
||||
nil acc)))
|
||||
{}
|
||||
param-map))))
|
||||
|
||||
(defn- apply-mapping [{:keys [mapping] :as action} params inputs]
|
||||
(let [mapping (hydrate-mapping mapping)
|
||||
tag? #(and (vector? %2) (= %1 (first %2)))]
|
||||
(if-not mapping
|
||||
(if (or params (:param-map action))
|
||||
(map #(augment-params action % params) inputs)
|
||||
inputs)
|
||||
(for [input inputs
|
||||
:let [root (augment-params action input params)]]
|
||||
(walk/postwalk
|
||||
(fn [x]
|
||||
(cond
|
||||
;; TODO handle the fact this stuff can be json-ified better
|
||||
(= ::root x) root
|
||||
(= ::input x) input
|
||||
(= ::params x) params
|
||||
;; specific key
|
||||
(tag? ::key x) (get root (keyword (second x)))
|
||||
(tag? ::param x) (get params (keyword (second x)))
|
||||
:else
|
||||
x))
|
||||
mapping)))))
|
||||
|
||||
(defn- apply-mapping-nested [{:keys [inner-action] :as outer-action} params inputs-before]
|
||||
(let [inputs-after (apply-mapping outer-action params inputs-before)]
|
||||
(if inner-action
|
||||
(recur inner-action nil inputs-after)
|
||||
inputs-after)))
|
||||
|
||||
(defn- execute!* [action-id scope params raw-inputs]
|
||||
(let [scope (actions/hydrate-scope scope)
|
||||
unified (fetch-unified-action scope action-id)
|
||||
inputs (apply-mapping-nested unified params raw-inputs)]
|
||||
(cond
|
||||
(:action-id unified)
|
||||
(let [action (api/read-check (actions/select-action :id (:action-id unified) :archived false))]
|
||||
(api/check-400 (= 1 (count inputs)) "Saved actions currently only support a single input")
|
||||
(execute-saved-action! action inputs))
|
||||
(:action-kw unified)
|
||||
(let [action-kw (keyword (:action-kw unified))]
|
||||
(:outputs (actions/perform-action! action-kw scope inputs)))
|
||||
(:dashboard-action unified)
|
||||
(do
|
||||
(api/check-400 (= 1 (count inputs)) "Saved actions currently only support a single input")
|
||||
[(actions/execute-dashcard! (:dashboard-id scope)
|
||||
(:dashboard-action unified)
|
||||
(walk/stringify-keys (first inputs)))])
|
||||
(:inner-action unified)
|
||||
;; use flat namespace for now, probably want to separate form inputs from pks
|
||||
(let [inner (:inner-action unified)
|
||||
saved-id (:action-id inner)
|
||||
action-kw (:action-kw inner)]
|
||||
(cond
|
||||
saved-id
|
||||
(execute-saved-action-from-id! saved-id inputs)
|
||||
action-kw
|
||||
(:outputs (actions/perform-action! action-kw scope inputs))))
|
||||
:else
|
||||
(throw (ex-info "Not able to execute given action yet" {:status-code 500 :scope scope :unified unified})))))
|
||||
|
||||
(def execute-single
|
||||
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
|
||||
(api.macros/defendpoint :post "/action/v2/execute"
|
||||
"The One True API for invoking actions.
|
||||
It doesn't care whether the action is saved or primitive, and whether it has been placed.
|
||||
In particular, it supports:
|
||||
- Custom model actions as well as primitive actions, and encoded hack actions which use negative ids.
|
||||
- Stand-alone actions, Dashboard actions, Row actions, and whatever else comes along.
|
||||
Since actions are free to return multiple outputs even for a single output, the response is always plural."
|
||||
[{}
|
||||
{}
|
||||
{:keys [action_id scope params input]}
|
||||
:- [:map
|
||||
;; TODO docstrings for these
|
||||
[:action_id ::api-action-id-or-expression]
|
||||
[:scope ::types/scope.raw]
|
||||
[:params {:optional true} :map]
|
||||
[:input :map]]]
|
||||
{:outputs (execute!* action_id scope params [input])}))
|
||||
|
||||
(def execute-bulk
|
||||
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
|
||||
(api.macros/defendpoint :post "/action/v2/execute-bulk"
|
||||
"The *other* One True API for invoking actions. The only difference is that it accepts multiple inputs."
|
||||
[{}
|
||||
{}
|
||||
{:keys [action_id scope inputs params]}
|
||||
:- [:map
|
||||
[:action_id ::api-action-id-or-expression]
|
||||
[:scope ::types/scope.raw]
|
||||
[:inputs [:sequential :map]]
|
||||
[:params {:optional true} [:map-of :keyword :any]]]]
|
||||
{:outputs (execute!* action_id scope params inputs)}))
|
||||
|
||||
(defn- row-data-mapping
|
||||
;; TODO get this working with arbitrary nesting of inner actions
|
||||
"HACK: create a placeholder unified action who will map to the values we need from row-data, if we need any"
|
||||
[{:keys [param-map] :as action}]
|
||||
;; We create a version of the action that will "map" to an input which is just the row data itself.
|
||||
(let [row-data-mapping (u/for-map [[_ {:keys [sourceType sourceValueTarget]}] param-map
|
||||
:when (= "row-data" sourceType)]
|
||||
[sourceValueTarget [::key (keyword sourceValueTarget)]])]
|
||||
(when (seq row-data-mapping)
|
||||
{:inner-action {:action-kw :placeholder}
|
||||
:dashcard-id (:dashcard-id action)
|
||||
:param-map (u/for-map [[_ {:keys [sourceType sourceValueTarget] :as param-setting}] param-map
|
||||
:when (= "row-data" sourceType)]
|
||||
[(keyword sourceValueTarget) param-setting])
|
||||
:mapping row-data-mapping})))
|
||||
|
||||
(defn- get-row-data
|
||||
"For a row or header action, fetch underlying database values that'll be used for specific action params in mapping."
|
||||
[action input]
|
||||
;; it would be nice to avoid using "apply-mapping" twice (once here on this stub action, once later on the real one)
|
||||
(some-> (row-data-mapping action)
|
||||
(apply-mapping-nested nil [input])
|
||||
first
|
||||
not-empty))
|
||||
|
||||
(def tmp-modal
|
||||
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
|
||||
(api.macros/defendpoint :post "/tmp-modal"
|
||||
"Temporary endpoint for describing an actions parameters
|
||||
such that they can be presented correctly in a modal ahead of execution."
|
||||
[{}
|
||||
{}
|
||||
;; TODO support for bulk actions
|
||||
{:keys [action_id scope input]}]
|
||||
(let [scope (actions/hydrate-scope scope)
|
||||
unified (fetch-unified-action scope action_id)
|
||||
row-data (get-row-data unified input)
|
||||
partial-input (first (apply-mapping-nested unified nil [input]))]
|
||||
(data-editing.describe/describe-unified-action unified scope row-data partial-input))))
|
||||
|
||||
(def config-form
|
||||
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
|
||||
(api.macros/defendpoint :post "/config-form"
|
||||
"This API returns a data representation of the form the FE will render. It does not update the configuration."
|
||||
[{}
|
||||
{}
|
||||
;; TODO pour some malli on me
|
||||
{:keys [action_id scope]}]
|
||||
(let [scope (actions/hydrate-scope scope)
|
||||
unified (fetch-unified-action scope action_id)
|
||||
input (first (apply-mapping-nested unified nil [nil]))]
|
||||
(data-editing.configure/configuration unified scope input))))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,120 @@
|
||||
(ns metabase-enterprise.data-editing.coerce
|
||||
(:require
|
||||
[metabase.util.date-2 :as u.date])
|
||||
(:import
|
||||
(clojure.lang BigInt)
|
||||
(java.time LocalDateTime ZonedDateTime ZoneId)
|
||||
(java.time.format DateTimeFormatter)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- json-zdt->unix-millis ^long [s]
|
||||
(let [zdt->milli (fn [^ZonedDateTime zdt]
|
||||
(-> zdt .toInstant .toEpochMilli))]
|
||||
(-> s u.date/parse zdt->milli)))
|
||||
|
||||
(defn- json-zdt->unix-seconds [s]
|
||||
(-> s json-zdt->unix-millis (quot 1000)))
|
||||
|
||||
(defn- json-zdt->unix-micros [s]
|
||||
(-> s json-zdt->unix-millis ^BigInt (* 1000N) .toBigInteger))
|
||||
|
||||
(defn- json-zdt->unix-nanos [s]
|
||||
(-> s json-zdt->unix-millis ^BigInt (* 1000000N) .toBigInteger))
|
||||
|
||||
(let [^DateTimeFormatter formatter (DateTimeFormatter/ofPattern "yyyyMMddHHmmss")
|
||||
format (fn [t]
|
||||
(.format ^DateTimeFormatter formatter t))]
|
||||
(defn- json-zdt->yyyymmddhhmmss [s]
|
||||
(-> s u.date/parse format))
|
||||
|
||||
(defn- yyyymmddhhmmss->json-zdt
|
||||
[yyyymmddhhmmss]
|
||||
(-> (LocalDateTime/parse yyyymmddhhmmss formatter)
|
||||
(.atZone (ZoneId/of "UTC"))
|
||||
str)))
|
||||
|
||||
;; the date/time coercions are highly ambigious
|
||||
;; in one direction one can make a decision (take the local date, or clock time)
|
||||
;; but how do you reverse it?
|
||||
;; Often the original SQL string might have been a full date, a zoned or unzoned ISO string,
|
||||
;; Needs to be considered properly at some later time.
|
||||
;; Idea: make the coercion dependent on previous database string, so if lossy - modify *just* the time, or the date component
|
||||
;; * still somewhat ambiguous, e.g did the user *intend* to discard the previous date/time components?
|
||||
|
||||
(defn- json-zdt->date [s]
|
||||
(-> s ZonedDateTime/parse .toLocalDate str))
|
||||
|
||||
(defn- json-zdt->time [s]
|
||||
(-> s ZonedDateTime/parse .toLocalTime str))
|
||||
|
||||
(defn- millis->json-zdt
|
||||
[millis]
|
||||
(-> millis
|
||||
java.time.Instant/ofEpochMilli
|
||||
(java.time.ZonedDateTime/ofInstant (java.time.ZoneId/systemDefault))
|
||||
str))
|
||||
|
||||
(defn- unix-seconds->json-zdt
|
||||
[unix-seconds]
|
||||
(millis->json-zdt (* unix-seconds 1000)))
|
||||
|
||||
(defn- unix-millis->json-zdt
|
||||
[unix-millis]
|
||||
(millis->json-zdt unix-millis))
|
||||
|
||||
(defn- unix-micros->json-zdt
|
||||
[unix-micros]
|
||||
(millis->json-zdt (quot unix-micros 1000)))
|
||||
|
||||
(defn- unix-nanos->json-zdt
|
||||
[unix-nanos]
|
||||
(millis->json-zdt (quot unix-nanos 1000000)))
|
||||
|
||||
(defn- date->json-zdt
|
||||
[date]
|
||||
(-> date
|
||||
java.time.LocalDate/parse
|
||||
(.atStartOfDay (java.time.ZoneId/systemDefault))
|
||||
str))
|
||||
|
||||
(defn- time->json-zdt
|
||||
[time]
|
||||
(-> time
|
||||
java.time.LocalTime/parse
|
||||
(.atDate (java.time.LocalDate/now))
|
||||
(.atZone (java.time.ZoneId/systemDefault))
|
||||
str))
|
||||
|
||||
(def coercion-fns
|
||||
"Maps a coercion strategy to a map containing both input and output conversion functions.
|
||||
|
||||
:in - Function to convert from input JSON to database format
|
||||
:out - Function to convert from database format to JSON output
|
||||
|
||||
Assumes the input/output values are valid for the coercion strategy and can fit within bounds."
|
||||
;; TODO: missing (second (clojure.data/diff (into #{} (keys coercion-fns)) (descendants :Coercion/*)))
|
||||
;;{:Coercion/Bytes->Temporal
|
||||
;; :Coercion/UNIXTime->Temporal
|
||||
;; :Coercion/String->Float
|
||||
;; :Coercion/Number->Temporal
|
||||
;; :Coercion/String->Number
|
||||
;; :Coercion/YYYYMMDDHHMMSSBytes->Temporal
|
||||
;; :Coercion/String->Temporal
|
||||
;; :Coercion/ISO8601->Temporal}
|
||||
{:Coercion/UNIXSeconds->DateTime {:in #'json-zdt->unix-seconds
|
||||
:out #'unix-seconds->json-zdt}
|
||||
:Coercion/UNIXMilliSeconds->DateTime {:in #'json-zdt->unix-millis
|
||||
:out #'unix-millis->json-zdt}
|
||||
:Coercion/UNIXMicroSeconds->DateTime {:in #'json-zdt->unix-micros
|
||||
:out #'unix-micros->json-zdt}
|
||||
:Coercion/UNIXNanoSeconds->DateTime {:in #'json-zdt->unix-nanos
|
||||
:out #'unix-nanos->json-zdt}
|
||||
:Coercion/YYYYMMDDHHMMSSString->Temporal {:in #'json-zdt->yyyymmddhhmmss
|
||||
:out #'yyyymmddhhmmss->json-zdt}
|
||||
:Coercion/ISO8601->DateTime {:in identity
|
||||
:out identity}
|
||||
:Coercion/ISO8601->Date {:in #'json-zdt->date
|
||||
:out #'date->json-zdt}
|
||||
:Coercion/ISO8601->Time {:in #'json-zdt->time
|
||||
:out #'time->json-zdt}})
|
||||
@@ -0,0 +1,168 @@
|
||||
(ns metabase-enterprise.data-editing.configure
|
||||
(:require
|
||||
[malli.core :as mc]
|
||||
[medley.core :as m]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.models.humanization :as humanization]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[metabase.warehouse-schema.models.table :as table]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- merge-option
|
||||
[entry-schema new-options]
|
||||
(if (= 2 (count entry-schema))
|
||||
(let [[k s] entry-schema]
|
||||
[k new-options s])
|
||||
(let [[k o s] entry-schema]
|
||||
[k (merge o new-options) s])))
|
||||
|
||||
;; This schema is Order important, as it'll be the order UI elments will be shown.
|
||||
(mr/def ::param-configuration
|
||||
;; TODO Yeah, gross. This is currently just mirroring the config in the FE.
|
||||
;; They at least have defined it using a nicer dependently typed way! See ConstantRowActionFieldSettings.
|
||||
;; We could do something like that with malli... but once this stuff is opaque to the frontend, we can go wild and
|
||||
;; make the shape more idiomatically Clojure, and maybe we should just wait until we've finalized the shape.
|
||||
[:map {:closed true}
|
||||
[:id :string]
|
||||
[:configure-details {:optional true} [:sequential [:tuple :keyword :map]]]
|
||||
;; omiited if we have visibility != null
|
||||
(merge-option [:display-name :string]
|
||||
{:optional true
|
||||
::display-name "Field Name"
|
||||
::input-type :text})
|
||||
(merge-option [:source-type [:enum "ask-user" "row-data" "constant"]]
|
||||
{:optional true
|
||||
::display-name "Source Type"
|
||||
::default "ask-user"
|
||||
::input-type :dropdown
|
||||
::options ["ask-user" "row-data" "constant"]})
|
||||
;; omitted if we have visibility != null
|
||||
(merge-option [:source-value :string]
|
||||
{:optional true
|
||||
::display-name "Pick column"
|
||||
::default nil
|
||||
::visible-if [:source-type "row-data"]
|
||||
::input-type :column-picker})
|
||||
(merge-option [:value [:or :string :int :boolean]]
|
||||
{:optional true
|
||||
::display-name "Value"
|
||||
::input-type :field-filter
|
||||
::visible-if [:source-type "constant"]
|
||||
::default nil})
|
||||
(merge-option [:editable :boolean] ;; only when visible is true
|
||||
{:optional true
|
||||
::descripion "Can you override"
|
||||
::display-name "Editable"
|
||||
::input-type :select
|
||||
::vislble-if [[:source-type "ask-user"] [:source-type "constant"]]
|
||||
::default nil})
|
||||
(merge-option [:required :boolean] ;; only show when the underline fields is nullable
|
||||
{:optional true
|
||||
::description "Whether this field is required" ;; can't turn off if the underlying field is required
|
||||
::display-name "Required"
|
||||
::input-type :select
|
||||
::vislble-if [[:source-type "ask-user"] [:source-type "constant"]]
|
||||
::default nil})
|
||||
(merge-option [:visible :boolean] ;; can't be false if the field is required
|
||||
{:optional true
|
||||
::description "Whether to show this field value in the form"
|
||||
::display-name "Visible"
|
||||
::input-type :select
|
||||
::default true})])
|
||||
|
||||
(def ^:private default-configuration-detais
|
||||
(for [[k p _s] (mc/children (mr/resolve-schema ::param-configuration))
|
||||
:let [namespaced-opts (m/filter-keys namespace p)]
|
||||
:when (seq namespaced-opts)]
|
||||
(-> namespaced-opts
|
||||
(update-keys (comp keyword name))
|
||||
(assoc :id k))))
|
||||
|
||||
(mr/def ::action-configuration
|
||||
[:map {:closed true}
|
||||
[:title :string]
|
||||
[:parameters [:sequential ::param-configuration]]])
|
||||
|
||||
(mu/defn- configuration-for-saved-action
|
||||
[action-id :- pos-int?]
|
||||
(let [action (-> (actions/select-action :id action-id
|
||||
:archived false
|
||||
{:where [:not [:= nil :model_id]]})
|
||||
api/read-check
|
||||
api/check-404)]
|
||||
{:title (:name action)
|
||||
:parameters (for [param (:parameters action)]
|
||||
{:id (case (:type action)
|
||||
:query (:slug param)
|
||||
:implicit (:id param))
|
||||
:source-type "ask-user"
|
||||
:configure-details default-configuration-detais})}))
|
||||
|
||||
;; TODO handle exposing new inputs required by the inner-action
|
||||
(defn- configuration-for-pending-action [{:keys [param-map] :as action}]
|
||||
;; TODO Delegate to get this
|
||||
{:title (:name action "TODO - depends on existing configuration if already saved, otherwise from the inner action as the default.")
|
||||
:parameters (for [[param-id param-settings] param-map]
|
||||
(-> param-settings
|
||||
(assoc :id (name param-id))
|
||||
(update :displayName #(or % (humanization/name->human-readable-name (name param-id))))))})
|
||||
|
||||
(mu/defn- configuration-for-table-action
|
||||
[table-id :- pos-int?
|
||||
action-kw :- :keyword]
|
||||
(let [table (t2/select-one [:model/Table :display_name :field_order] table-id)
|
||||
ordered-fields (table/ordered-fields table-id (:field_order table))]
|
||||
{:title (format "%s: %s" (:display_name table) (u/capitalize-en (name action-kw)))
|
||||
:parameters (for [field ordered-fields
|
||||
:when (case action-kw
|
||||
:table.row/create (not (:database_is_auto_increment field))
|
||||
:table.row/update true
|
||||
:table.row/delete (isa? (:semantic_type field) :type/PK))]
|
||||
{:id (:name field)
|
||||
:source "ask-user"
|
||||
:configure-details default-configuration-detais})}))
|
||||
|
||||
(defn- combine-configurations
|
||||
[saved-configuration raw-configuration]
|
||||
(let [existing-param-ids (set (map :id (:parameters saved-configuration)))]
|
||||
{:title (:title saved-configuration)
|
||||
:parameters (concat (:parameters saved-configuration) (remove #(existing-param-ids (:id %)) (:parameters raw-configuration)))}))
|
||||
|
||||
(mu/defn configuration :- [:or ::action-configuration [:map [:status ms/PositiveInt]]]
|
||||
"Returns configuration needed for a given action."
|
||||
[{:keys [action-id action-kw inner-action] :as action}
|
||||
scope
|
||||
input]
|
||||
(if (false? (:configurable action))
|
||||
{:status 400, :body "Cannot configure this action"}
|
||||
(cond
|
||||
;; Eventually will be put inside a nicely typed :configuration key
|
||||
(:param-map action)
|
||||
;; Dynamically incorporate any new options added since we last saved our configuration.
|
||||
(combine-configurations (configuration-for-pending-action action)
|
||||
(configuration (dissoc action :param-map) scope input))
|
||||
|
||||
(pos-int? action-id)
|
||||
(configuration-for-saved-action action-id)
|
||||
|
||||
(and action-kw (isa? action-kw :table.row/common))
|
||||
(configuration-for-table-action (:table-id input (:table-id scope)) action-kw)
|
||||
|
||||
inner-action
|
||||
(let [action-id (:action-id inner-action)
|
||||
action-kw (:action-kw inner-action)]
|
||||
(cond
|
||||
(pos-int? action-id)
|
||||
(configuration-for-saved-action action-id)
|
||||
(and action-kw (isa? action-kw :table.row/common))
|
||||
;; TODO remove assumption that all primitives are table actions
|
||||
(configuration-for-table-action (:table-id input (:table-id scope)) action-kw)
|
||||
:else (ex-info "Not a supported row action" {:status-code 500 :scope scope :unified action})))
|
||||
|
||||
;; TODO support data-grid.row and model.row actions (not important yet)
|
||||
:else
|
||||
(throw (ex-info "Don't know how to handle this action" {:action action, :scope scope})))))
|
||||
@@ -0,0 +1,6 @@
|
||||
(ns metabase-enterprise.data-editing.core
|
||||
(:require
|
||||
[potemkin :as p]))
|
||||
|
||||
(comment
|
||||
p/keep-me)
|
||||
@@ -0,0 +1,194 @@
|
||||
(ns metabase-enterprise.data-editing.data-editing
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[java-time.api :as t]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.data-editing.coerce :as data-editing.coerce]
|
||||
[metabase-enterprise.data-editing.models.undo :as undo]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.query-processor.store :as qp.store]
|
||||
[metabase.util :as u]
|
||||
[metabase.warehouse-schema.models.field-values :as field-values]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn select-table-pk-fields
|
||||
"Given a table-id, return the :model/Field instances corresponding to its PK columns. Do not assume any ordering."
|
||||
[table-id]
|
||||
(u/prog1 (api/check-404 (t2/select :model/Field :table_id table-id :semantic_type :type/PK :active true))
|
||||
(api/check-500 (pos? (count <>)))))
|
||||
|
||||
(defn get-row-pks
|
||||
"Given a row, strip it down to just its primary keys."
|
||||
[pk-fields row]
|
||||
(->> (map :name pk-fields)
|
||||
(select-keys (update-keys row u/qualified-name))
|
||||
;; Hack for now, pending discussion of the ideal fix
|
||||
;; https://linear.app/metabase/issue/WRK-281/undo-deletes-a-record-instead-of-reverting-the-edits
|
||||
;; See https://metaboat.slack.com/archives/C0641E4PB9B/p1744978660610899
|
||||
(m/map-vals #(if (uuid? %) (str %) %))))
|
||||
|
||||
(defn- valid-pks [pks]
|
||||
(every? some? (vals pks)))
|
||||
|
||||
(defn- apply*
|
||||
"Work around the fact that the lib logical operators don't have a 0 or 1 arity."
|
||||
[f clauses]
|
||||
(if (<= (count clauses) 1)
|
||||
(first clauses)
|
||||
(apply f clauses)))
|
||||
|
||||
(defn- qp-result->row-map
|
||||
[{:keys [rows cols]}]
|
||||
;; rows from the request are keywordized
|
||||
(let [col-names (map (comp keyword :name) cols)]
|
||||
(map #(zipmap col-names %) rows)))
|
||||
|
||||
(defn query-db-rows
|
||||
"Return the current representation of the given rows that we would return to the frontend, indexed by their pks."
|
||||
[table-id pk-fields rows]
|
||||
(assert (seq pk-fields) "Table must have at least one primary key column")
|
||||
;; TODO pass in the db-id from above rather
|
||||
(when (seq rows)
|
||||
(let [{:keys [db_id]} (api/check-404 (t2/select-one :model/Table table-id))
|
||||
row-pks (seq (map (partial get-row-pks pk-fields) rows))]
|
||||
(assert (every? valid-pks row-pks) "All rows must have valid primary keys")
|
||||
(qp.store/with-metadata-provider db_id
|
||||
(let [mp (qp.store/metadata-provider)
|
||||
query (lib/query mp (lib.metadata/table mp table-id))
|
||||
query (lib/filter
|
||||
query
|
||||
;; We can optimize the most common case considerably.
|
||||
(if (= 1 (count pk-fields))
|
||||
(apply lib/in
|
||||
(lib.metadata/field mp (:id (first pk-fields)))
|
||||
(map (comp val first) row-pks))
|
||||
;; Optimizing this could be done in many cases, but it would be complex.
|
||||
(apply* lib/or
|
||||
(for [row-pk row-pks]
|
||||
(apply* lib/and
|
||||
(for [field pk-fields]
|
||||
(lib/= (lib.metadata/field mp (:id field))
|
||||
(get row-pk (:name field)))))))))]
|
||||
(->> query
|
||||
qp/userland-query-with-default-constraints
|
||||
qp/process-query
|
||||
:data
|
||||
qp-result->row-map))))))
|
||||
|
||||
(defn apply-coercions
|
||||
"For fields that have a coercion_strategy, apply the coercion function (defined in data-editing.coerce) to the corresponding value in each row.
|
||||
Intentionally does not coerce primary key values (behaviour for pks with coercion strategies is undefined)."
|
||||
[table-id input-rows]
|
||||
(let [input-keys (into #{} (mapcat keys) input-rows)
|
||||
field-names (map name input-keys)
|
||||
fields (t2/select :model/Field :table_id table-id :name [:in field-names])
|
||||
coerce-fn (->> (for [{field-name :name, :keys [coercion_strategy, semantic_type]} fields
|
||||
:when (not (isa? semantic_type :type/PK))]
|
||||
[(keyword field-name)
|
||||
(or (when (nil? coercion_strategy) identity)
|
||||
(:in (data-editing.coerce/coercion-fns coercion_strategy))
|
||||
(throw (ex-info "Coercion strategy has no defined coercion function"
|
||||
{:status 400
|
||||
:field field-name
|
||||
:coercion_strategy coercion_strategy})))])
|
||||
(into {}))
|
||||
coerce (fn [k v] (some-> v ((coerce-fn (keyword k) identity))))]
|
||||
(for [row input-rows]
|
||||
(m/map-kv-vals coerce row))))
|
||||
|
||||
(defn- row-update-event
|
||||
"Given a :effect/row.modified diff, figure out what kind of mutation it was."
|
||||
[{:keys [before after]}]
|
||||
(case [(some? before) (some? after)]
|
||||
[false true] :event/row.created
|
||||
[true true] :event/row.updated
|
||||
[true false] :event/row.deleted
|
||||
;; should not happen
|
||||
[false false] ::no-op))
|
||||
|
||||
(defmethod actions/handle-effects!* :effect/row.modified
|
||||
[_ {:keys [user-id invocation-stack scope]} diffs]
|
||||
(let [table-ids (distinct (map :table-id diffs))
|
||||
table->pk-fields (u/group-by identity select-table-pk-fields concat table-ids)
|
||||
diff->pk-diff (u/for-map [{:keys [table-id before after] :as diff} diffs
|
||||
:when (or before after)]
|
||||
[diff {:pk (get-row-pks (table->pk-fields table-id) (or after before))
|
||||
:before before
|
||||
:after after}])]
|
||||
;; undo snapshots, but only if we're not executing an undo
|
||||
;; TODO fix tests that execute actions without a user scope
|
||||
(when user-id
|
||||
(when-not (some (comp #{:data-editing/undo :data-editing/redo} first) invocation-stack)
|
||||
(undo/track-change!
|
||||
user-id
|
||||
scope
|
||||
(u/for-map [[table-id diffs] (group-by :table-id diffs)]
|
||||
[table-id (u/for-map [{:keys [before after deleted-children] :as diff} diffs
|
||||
:when (or before after)
|
||||
:let [{:keys [pk before after]} (diff->pk-diff diff)]]
|
||||
[pk {:raw_before before
|
||||
:raw_after after
|
||||
:undoable (empty? deleted-children)}])]))))
|
||||
;; table notification system events
|
||||
(doseq [[event payloads] (->> diffs
|
||||
(group-by row-update-event)
|
||||
(remove (comp #{::no-op} key)))]
|
||||
(doseq [[table-id payloads] (group-by :table-id payloads)
|
||||
:let [db-id (:db-id (first payloads))
|
||||
row-changes (for [diff payloads]
|
||||
(diff->pk-diff diff))]]
|
||||
(doseq [row-change row-changes]
|
||||
(events/publish-event! event {:actor_id user-id
|
||||
:row_change row-change
|
||||
:scope (u/snake-keys (actions/hydrate-scope scope))
|
||||
:args {:table_id table-id
|
||||
:db_id db-id
|
||||
:timestamp (t/zoned-date-time (t/zone-id "UTC"))}}))))))
|
||||
|
||||
(defn- invalidate-field-values! [table-id rows]
|
||||
;; Be conservative with respect to case sensitivity, invalidate every field when there is ambiguity.
|
||||
(let [ln->values (u/group-by first second (for [row rows [k v] row] [(u/lower-case-en (name k)) v]))
|
||||
lower-names (keys ln->values)
|
||||
ln->ids (when (seq lower-names)
|
||||
(u/group-by
|
||||
:lower_name :id
|
||||
(t2/query {:select [:id [[:lower :name] :lower_name]]
|
||||
:from [(t2/table-name :model/Field)]
|
||||
:where [:and
|
||||
[:= :table_id table-id]
|
||||
[:in [:lower :name] lower-names]
|
||||
[:in :has_field_values ["list" "auto-list"]]
|
||||
[:= :semantic_type "type/Category"]]})))
|
||||
stale-fields (->> (for [[lower-name field-ids] ln->ids
|
||||
:let [new-values (into #{} (filter some?) (ln->values lower-name))
|
||||
old-values (into #{} cat (t2/select-fn-vec :values :model/FieldValues
|
||||
:field_id [:in field-ids]))]]
|
||||
(when (seq (set/difference new-values old-values))
|
||||
field-ids))
|
||||
(apply concat))]
|
||||
;; Note that for now we only rescan field values when values are *added* and not when they are *removed*.
|
||||
(when (seq stale-fields)
|
||||
;; Using a future is not ideal, it would be better to use a queue and a single worker, to avoid tying up threads.
|
||||
(future
|
||||
(->> (t2/select :model/Field :id [:in stale-fields])
|
||||
(run! field-values/create-or-update-full-field-values!))))))
|
||||
|
||||
(defn invalidate-and-present!
|
||||
"We invalidate the field-values, in case any new category values were added.
|
||||
The FE also expects data to be formatted according to PQ logic, e.g. considering semantic types.
|
||||
Actions, however, return raw values, since lossy coercions would limit composition.
|
||||
So, we apply the coercions here."
|
||||
[table-id rows]
|
||||
;; We could optimize this significantly:
|
||||
;; 1. Skip if no category fields were changes on update.
|
||||
;; 2. Check whether all corresponding categorical field values are already in the database.
|
||||
(invalidate-field-values! table-id rows)
|
||||
;; right now the FE works off qp outputs, which coerce output row data
|
||||
;; still feels messy, revisit this
|
||||
(let [pk-fields (select-table-pk-fields table-id)]
|
||||
(query-db-rows table-id pk-fields rows)))
|
||||
@@ -0,0 +1,219 @@
|
||||
(ns metabase-enterprise.data-editing.describe
|
||||
(:require
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(mr/def ::describe-param
|
||||
[:map #_{:closed true}
|
||||
[:id :string]
|
||||
[:display_name :string]
|
||||
[:field_id {:optional true} pos-int?]
|
||||
[:input_type [:enum "dropdown" "textarea" "date" "datetime" "text"]]
|
||||
[:semantic_type {:optional true} :keyword]
|
||||
[:optional :boolean]
|
||||
[:nullable :boolean]
|
||||
[:readonly :boolean]
|
||||
[:database_default {:optional true} :any]
|
||||
;; value can be nil, so this is optional to avoid confusion
|
||||
[:value {:optional true} :any]
|
||||
[:value_options {:optional true} [:sequential :any]]
|
||||
;; is it more useful if we have field_id instead of this?
|
||||
[:human_readable_field_id {:optional true} pos-int?]])
|
||||
|
||||
(mr/def ::action-description
|
||||
[:map {:closed true}
|
||||
[:title :string]
|
||||
[:parameters [:sequential ::describe-param]]])
|
||||
|
||||
(defn- field-input-type
|
||||
[field field-values]
|
||||
(case (:type field-values)
|
||||
(:list :auto-list :search) "dropdown"
|
||||
(condp #(isa? %2 %1) (:semantic_type field)
|
||||
:type/Description "textarea"
|
||||
:type/Category "dropdown"
|
||||
:type/FK "dropdown"
|
||||
(condp #(isa? %2 %1) (:base_type field)
|
||||
:type/Date "date"
|
||||
:type/DateTime "datetime"
|
||||
"text"))))
|
||||
|
||||
(defn- describe-table-action
|
||||
[{:keys [action-kw
|
||||
table-id
|
||||
param-map
|
||||
dashcard-viz
|
||||
row-data]}]
|
||||
(when-not table-id
|
||||
(throw (ex-info "Must provide table-id" {:status-code 400})))
|
||||
(let [table (api/read-check (t2/select-one :model/Table :id table-id :active true))
|
||||
fields (-> (t2/select :model/Field :table_id table-id :active true {:order-by [[:position]]})
|
||||
(t2/hydrate :dimensions
|
||||
:has_field_values
|
||||
:values))
|
||||
;; it's a huge assumption that there's always dashcard-viz
|
||||
;; TODO: this should live in our configuration
|
||||
dashcard-column-editable? (or (some-> dashcard-viz :table.editableColumns set)
|
||||
;; columns are assumed editable if no dashcard-viz specialisation
|
||||
(constantly true))
|
||||
dashcard-sort (zipmap (map :name (:table.columns dashcard-viz)) (range))
|
||||
field-name->dashcard-column (u/index-by :name (:table.columns dashcard-viz))
|
||||
field-sort (zipmap (map :name fields) (range))
|
||||
sort-key (fn [{:keys [name]}]
|
||||
(or (dashcard-sort name) ; prefer user defined sort in the dashcard
|
||||
(+ (inc (count dashcard-sort))
|
||||
(field-sort name))))]
|
||||
{:title (format "%s: %s" (:display_name table) (u/capitalize-en (name action-kw)))
|
||||
:parameters (->> (for [field (sort-by sort-key fields)
|
||||
:let [{field-values :values} field
|
||||
pk (= :type/PK (:semantic_type field))
|
||||
param-setting (get param-map (keyword (:name field)))
|
||||
dashcard-column (field-name->dashcard-column (:name field))]
|
||||
:when (case action-kw
|
||||
;; create does not take pk cols if auto increment, todo generated cols?
|
||||
(:table.row/create :data-grid.row/create) (not (:database_is_auto_increment field))
|
||||
;; delete only requires pk cols
|
||||
(:table.row/delete :data-grid.row/delete) pk
|
||||
;; update takes both the pk and field (if not a row action)
|
||||
(:table.row/update
|
||||
:table.row/create-or-update
|
||||
:data-grid.row/update
|
||||
:data-grid.row/create-or-update) true)
|
||||
;; row-actions can explicitly hide parameters
|
||||
:when (not= "hidden" (:visibility param-setting))
|
||||
;; dashcard column context can hide parameters (if defined)
|
||||
:when (:enabled dashcard-column true)
|
||||
:let [required (or pk (:database_required field))]]
|
||||
(u/remove-nils
|
||||
{:id (:name field) ;; TODO we shouldn't use field name as id I think, what if we have 1 field that maps to 2 params?
|
||||
:display_name (:display_name field)
|
||||
:semantic_type (:semantic_type field)
|
||||
:input_type (field-input-type field field-values)
|
||||
:field_id (:id field)
|
||||
:human_readable_field_id (-> field :dimensions first :human_readable_field_id)
|
||||
:optional (not required)
|
||||
:nullable (:database_is_nullable field)
|
||||
:database_default (:database_default field)
|
||||
:readonly (or (= "readonly" (:visibility param-setting))
|
||||
(not (dashcard-column-editable? (:name field))))
|
||||
;; TODO oh dear, we need to worry about case sensitivity issue now (e.g. in tests)
|
||||
;; it would be much better if our mappings were based on field ids.
|
||||
;; probably not an issue in practice, because FE is writing the config AND calling tmp-modal
|
||||
;; with likely the same names for fields (fingers crossed)
|
||||
:value (get row-data (:sourceValueTarget param-setting))}))
|
||||
vec)}))
|
||||
|
||||
(defn- saved-param-base-type
|
||||
[param-type viz-field]
|
||||
(case param-type
|
||||
:string/= :type/Text
|
||||
:number/= :type/Number
|
||||
:date/single (case (:inputType viz-field)
|
||||
;; formatting needs thought
|
||||
"datetime" :type/DateTime
|
||||
:type/Date)
|
||||
(if (= "type" (namespace param-type))
|
||||
param-type
|
||||
(throw
|
||||
(ex-info "Unsupported query action parameter type"
|
||||
{:status-code 500
|
||||
:param-type param-type})))))
|
||||
|
||||
(defn- saved-param-input-type
|
||||
[param-type viz-field]
|
||||
(cond
|
||||
;; we could distinguish between inline-select and dropdown (which are both options for model action params)
|
||||
(seq (:valueOptions viz-field))
|
||||
"dropdown"
|
||||
|
||||
(= "text" (:inputType viz-field))
|
||||
"textarea"
|
||||
|
||||
:else
|
||||
(condp #(isa? %2 %1) (saved-param-base-type param-type viz-field)
|
||||
:type/Date "date"
|
||||
:type/DateTime "datetime"
|
||||
"text")))
|
||||
|
||||
(defn- describe-saved-action
|
||||
[{:keys [action-id param-map row-data]}]
|
||||
(let [action (-> (actions/select-action :id action-id
|
||||
:archived false
|
||||
{:where [:not [:= nil :model_id]]})
|
||||
api/read-check
|
||||
api/check-404)
|
||||
param-id->viz-field (-> action :visualization_settings (:fields {}))]
|
||||
;; TODO: this assumes this is a query action, we need to handle implicit actions as well
|
||||
{:title (:name action)
|
||||
:parameters (->> (for [param (:parameters action)
|
||||
;; query type actions store most stuff in viz settings rather than the
|
||||
;; parameter
|
||||
:let [viz-field (param-id->viz-field (:id param))
|
||||
param-mapping (get param-map (keyword (:id param)))]
|
||||
:when (and (not (:hidden viz-field))
|
||||
(not= "hidden" (:visibility param-mapping)))]
|
||||
(u/remove-nils
|
||||
{:id (:id param)
|
||||
:display_name (or (:display-name param) (:name param))
|
||||
:input_type (saved-param-input-type (:type param) viz-field)
|
||||
:optional (and (not (:required param)) (not (:required viz-field)))
|
||||
;; TODO Ngoc: is there a way to know this?
|
||||
;; Chris: well the param would need to tell us.
|
||||
;; for now let's guess that anything required is not nullabe
|
||||
:nullable (not (:required param))
|
||||
:readonly (= "readonly" (:visibility param-mapping))
|
||||
:value (get row-data (:sourceValueTarget param-mapping))
|
||||
:value_options (:valueOptions viz-field)}))
|
||||
vec)}))
|
||||
|
||||
(mu/defn describe-unified-action :- ::action-description
|
||||
"Describe parameters of an unified action."
|
||||
[unified scope row-data partial-input]
|
||||
;; TODO: we didn't handle dashboard-action (i.e. dashboard buttons)
|
||||
(cond
|
||||
;; saved action
|
||||
;; hmm, having checked like this makes me insecure, what makes having an action-id enforces that this
|
||||
;; is an saved question? what if put an aciton-id on a row action for some reasons?
|
||||
(:action-id unified)
|
||||
(describe-saved-action {:action-id (:action-id unified)})
|
||||
|
||||
;; table action
|
||||
;; TODO remove assumption that all primitives are table actions
|
||||
(:action-kw unified)
|
||||
(describe-table-action
|
||||
{:action-kw (:action-kw unified)
|
||||
:table-id (:table-id partial-input)
|
||||
:param-map (:param-map unified)
|
||||
;; tried commenting it out and no tests failed, so will leave this here as reference for now
|
||||
;; (also don't see any code that could ever populate this tho)
|
||||
:dashcard-viz nil #_(:dashcard-viz (:dashcard-viz unified))})
|
||||
(:inner-action unified)
|
||||
(let [inner (:inner-action unified)
|
||||
param-map (:param-map unified)
|
||||
dashcard-id (:dashcard-id unified)
|
||||
dashcard-viz (or (:dashcard-viz unified)
|
||||
;; TODO we shouldn't need this fallback, fetch-unified-action should have fetched this for us
|
||||
(t2/select-one-fn :visualization_settings :model/DashboardCard dashcard-id))
|
||||
saved-id (:action-id inner)
|
||||
action-kw (:action-kw inner)
|
||||
table-id (:table-id partial-input)]
|
||||
(cond
|
||||
saved-id
|
||||
(describe-saved-action {:action-id saved-id
|
||||
:row-action-dashcard-id dashcard-id
|
||||
:param-map param-map
|
||||
:row-data row-data})
|
||||
action-kw
|
||||
;; TODO remove assumption that all primitives are table actions
|
||||
(describe-table-action {:action-kw action-kw
|
||||
:table-id table-id
|
||||
:param-map param-map
|
||||
:dashcard-viz dashcard-viz
|
||||
:row-data row-data})
|
||||
:else (ex-info "Not a supported row action" {:status-code 500 :scope scope :unified unified})))
|
||||
:else
|
||||
(throw (ex-info "Not able to execute given action yet" {:status-code 500 :scope scope :unified unified}))))
|
||||
@@ -0,0 +1,17 @@
|
||||
(ns metabase-enterprise.data-editing.events
|
||||
(:require
|
||||
[metabase.notification.core :as notification]))
|
||||
|
||||
(derive ::event :metabase/event)
|
||||
|
||||
(derive :event/rows.created ::event)
|
||||
(derive :event/rows.updated ::event)
|
||||
(derive :event/rows.deleted ::event)
|
||||
(derive :event/row.created ::event)
|
||||
(derive :event/row.updated ::event)
|
||||
(derive :event/row.deleted ::event)
|
||||
|
||||
(doseq [event (descendants ::event)]
|
||||
(defmethod notification/notification-filter-for-topic event
|
||||
[_topic event-info]
|
||||
[:= :table_id (-> event-info :args :table_id)]))
|
||||
@@ -0,0 +1,4 @@
|
||||
(ns metabase-enterprise.data-editing.init
|
||||
(:require
|
||||
[metabase-enterprise.data-editing.actions]
|
||||
[metabase-enterprise.data-editing.events]))
|
||||
@@ -0,0 +1,251 @@
|
||||
(ns metabase-enterprise.data-editing.models.undo
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private retention-total-batches 100)
|
||||
(def ^:private retention-total-rows 1000)
|
||||
(def ^:private retention-batches-per-scope 100)
|
||||
(def ^:private retention-batches-per-user 50)
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Undo [_model] :data_edit_undo_chain)
|
||||
|
||||
(t2/deftransforms :model/Undo
|
||||
{:row_pk mi/transform-json
|
||||
:raw_before mi/transform-json
|
||||
:raw_after mi/transform-json})
|
||||
|
||||
(doto :model/Undo
|
||||
(derive :metabase/model)
|
||||
(derive :hook/timestamped?))
|
||||
|
||||
(defn- nested-sort
|
||||
"Sort every map within the data structure."
|
||||
[x]
|
||||
(walk/postwalk #(if-not (map? %) % (apply sorted-map (apply concat %))) x))
|
||||
|
||||
(defn- serialize-scope
|
||||
"Convert the scope map or string into a stable string we can do example matches on in the database. Idempotent."
|
||||
[scope]
|
||||
(if (string? scope)
|
||||
scope
|
||||
;; For now :type is ignored for undo scope, but once the FE starts passing it in, we'll probably change that.
|
||||
(or (some-> scope actions/normalize-scope (dissoc :type) nested-sort pr-str) "unknown")))
|
||||
|
||||
(defn- next-batch [undo? user-id scope]
|
||||
;; For now, we assume all the changes are to the same table.
|
||||
;; In the future, we might want to skip multi-table changes when using cmd-Z.
|
||||
;; We may also want to filter based on the type of interaction that caused the change (e.g., grid, workflow, etc)
|
||||
(t2/select :model/Undo
|
||||
:batch_num [:in
|
||||
{:select [[[(if undo? :max :min) :batch_num]]]
|
||||
:from [(t2/table-name :model/Undo)]
|
||||
:where [:and
|
||||
[:= :user_id user-id]
|
||||
[:= :scope (serialize-scope scope)]
|
||||
(if undo?
|
||||
[:not :undone]
|
||||
:undone)]}]))
|
||||
|
||||
(defn- batch-to-prune-from
|
||||
"Return the largest batch_num that we should no longer retain, if we only want to keep a certain number of batches
|
||||
matching the given where clause. Returns 0 if we do not need to prune anything."
|
||||
[batches-to-keep & [where]]
|
||||
(-> {:select [:batch_num]
|
||||
:from [(t2/table-name :model/Undo)]
|
||||
:where (or where true)
|
||||
:group-by :batch_num
|
||||
:order-by [[:batch_num :desc]]
|
||||
:limit 1
|
||||
:offset batches-to-keep}
|
||||
t2/query
|
||||
first
|
||||
:batch_num
|
||||
(or 0)))
|
||||
|
||||
(defn- batch-to-prune-from-for-rows
|
||||
"This is like [[batch-to-prune-from], except that we're enforcing a max on the row count."
|
||||
[rows-to-keep & [where]]
|
||||
(-> {:select [:batch_num]
|
||||
:from [(t2/table-name :model/Undo)]
|
||||
:where (or where true)
|
||||
:order-by [[:id :desc]]
|
||||
:limit 1
|
||||
:offset rows-to-keep}
|
||||
t2/query
|
||||
first
|
||||
:batch_num
|
||||
(or 0)))
|
||||
|
||||
(defn- prune-from-batch! [batch-num & [where]]
|
||||
(t2/delete! :model/Undo
|
||||
:batch_num [:<= batch-num]
|
||||
{:where (or where true)}))
|
||||
|
||||
(defn- prune-batches! [batches-to-keep & [where]]
|
||||
(prune-from-batch! (batch-to-prune-from batches-to-keep where) where))
|
||||
|
||||
(defn track-change!
|
||||
"Insert some snapshot data based on edits made to the given table."
|
||||
[user-id scope table-id->row-pk->values]
|
||||
(let [scope (serialize-scope scope)]
|
||||
(t2/with-transaction [_conn]
|
||||
(let [seq-name "undo_batch_num"
|
||||
next-batch-num (or (t2/select-one-fn :next_val [:sequences :next_val] :name seq-name {:for :update}) 1)]
|
||||
(t2/update! :sequences {:name seq-name} {:next_val (inc next-batch-num)})
|
||||
(t2/insert!
|
||||
:model/Undo
|
||||
(for [[table-id table-updates] table-id->row-pk->values
|
||||
[row-pk values] table-updates]
|
||||
(merge {:batch_num next-batch-num
|
||||
:table_id table-id
|
||||
:user_id user-id
|
||||
:row_pk row-pk
|
||||
:scope scope
|
||||
;; default value, can be override in values
|
||||
:undoable true}
|
||||
values)))))
|
||||
|
||||
;; Delete snapshots that have been undone, as we keep a linear history and will no longer be able to "redo" them.
|
||||
(when-let [{:keys [batch_num]} (first (next-batch false user-id scope))]
|
||||
(t2/delete! :model/Undo
|
||||
:batch_num [:>= batch_num]
|
||||
:scope scope
|
||||
:undone true))
|
||||
|
||||
;; Pruning. Fairly naive implementation. Doesn't assume we were fully pruned before this update.
|
||||
|
||||
(prune-from-batch! (max (batch-to-prune-from-for-rows retention-total-rows)
|
||||
(batch-to-prune-from retention-total-batches)))
|
||||
|
||||
(let [where-scope [:= :scope scope]]
|
||||
(prune-batches! retention-batches-per-scope where-scope))
|
||||
|
||||
(let [where-user [:= :user_id user-id]]
|
||||
(prune-batches! retention-batches-per-user where-user))))
|
||||
|
||||
(defn next-batch-num
|
||||
"Return the batch number of the new change that we would (un-)revert.
|
||||
NOTE: this does not check whether there is a conflict preventing us from actually performing it."
|
||||
[undo-or-redo user-id scope]
|
||||
(:batch_num (first (next-batch (= :undo undo-or-redo) user-id scope))))
|
||||
|
||||
;; This will be used to fix conflict false positives
|
||||
#_{:clj-kondo/ignore [:unused-private-var]}
|
||||
(defn- diff-keys
|
||||
"Give the sorted list of keys on which m1 and m2 differ."
|
||||
[m1 m2]
|
||||
(->> (into (apply sorted-set (keys m1)) (keys m2))
|
||||
(filter #(not= (get m1 %) (get m2 %)))))
|
||||
|
||||
(defn- conflict?
|
||||
"Has another change superseded this batch, making it non-undoable? Typically, will mean someone else has edited it."
|
||||
[undo? batch]
|
||||
(let [row-pks (into #{} (map :row_pk) batch)
|
||||
table-ids (into #{} (map :table_id) batch)
|
||||
{:keys [batch_num]} (first batch)]
|
||||
;; TODO fix false positives in multi-table case where row-pks are ambiguous
|
||||
;; PRODUCT: We probably also want to fix false positive conflicts when different columns were changed
|
||||
;; Idea: Select all potential conflicting undos, check for any whose changed keys overlap with ours.
|
||||
;; Can store a precalculated [[diff-keys]] on each :model/Undo row, or even normalize into its own table and do
|
||||
;; conflict detection completely in SQL.
|
||||
;; This normal form may make maintenance of the history a bit too expensive however.
|
||||
(t2/exists? :model/Undo
|
||||
:table_id [:in table-ids]
|
||||
:row_pk [:in row-pks]
|
||||
:batch_num [(if undo? :> :<) batch_num]
|
||||
:undone (not undo?))))
|
||||
|
||||
(defn- categorize [{:keys [raw_before raw_after]}]
|
||||
(cond
|
||||
(nil? raw_before) :create
|
||||
(nil? raw_after) :delete
|
||||
:else :update))
|
||||
|
||||
(def ^:private invert
|
||||
{:create :delete
|
||||
:update :update
|
||||
:delete :create})
|
||||
|
||||
(def ^:private category->op
|
||||
{:create :created
|
||||
:update :updated
|
||||
:delete :deleted})
|
||||
|
||||
(defn- batch->rows
|
||||
"Given the undo snapshots for some batch update, return the corresponding row updates."
|
||||
[undo? batch]
|
||||
(let [k (if undo? :raw_before :raw_after)]
|
||||
(for [b batch]
|
||||
(merge (:row_pk b) (k b)))))
|
||||
|
||||
(defn- update-table-data!
|
||||
"Revert the underlying table data."
|
||||
[undo-or-redo context batch]
|
||||
(let [undo? (= :undo undo-or-redo)]
|
||||
;; Do a single perform-nested-action! call
|
||||
(doseq [[[table-id category] sub-batch] (u/group-by (juxt :table_id categorize) batch)
|
||||
:let [rows (batch->rows undo? sub-batch)
|
||||
inputs (map #(array-map :table-id table-id :row (update-keys % u/qualified-name)) rows)]]
|
||||
(case (if undo? (invert category) category)
|
||||
:create (try (actions/perform-nested-action! :table.row/create context inputs)
|
||||
(catch Exception e
|
||||
;; Sometimes cols don't support a custom value being provided, e.g., GENERATED ALWAYS AS IDENTITY
|
||||
(throw (ex-info "Failed to un-delete row(s)"
|
||||
{:error :undo/cannot-undelete
|
||||
:batch-num (:batch_num (first batch))
|
||||
:table-id table-id
|
||||
:pks (map :row_pk batch)}
|
||||
e))))
|
||||
:update (actions/perform-nested-action! :table.row/update context inputs)
|
||||
:delete (actions/perform-nested-action! :table.row/delete context inputs)))
|
||||
;; don't leak action outputs, we don't want to accidentally create more coupling to them.
|
||||
true))
|
||||
|
||||
(defn- undo*! [undo-or-redo context user-id scope]
|
||||
(let [undo? (= :undo undo-or-redo)
|
||||
batch (next-batch undo? user-id scope)]
|
||||
(cond
|
||||
(not (seq batch)) (throw (ex-info (if undo?
|
||||
"No previous versions found"
|
||||
"No subsequent versions found")
|
||||
{:error :undo/none
|
||||
:user-id user-id
|
||||
:scope scope}))
|
||||
(some (comp false? :undoable) batch)
|
||||
(throw (ex-info "Batch contains changes that are not undoable"
|
||||
{:error :undo/cannot-undo
|
||||
:user-id user-id
|
||||
:scope scope
|
||||
:batch-num (:batch_num (first batch))}))
|
||||
(conflict? undo? batch) (throw (ex-info "Blocked by other changes"
|
||||
;; It would be nice if we gave the batch_num for the first conflict.
|
||||
{:error :undo/conflict
|
||||
:user-id user-id
|
||||
:scope scope}))
|
||||
:else
|
||||
(do (update-table-data! undo-or-redo context batch)
|
||||
(t2/update! :model/Undo
|
||||
{:batch_num (:batch_num (first batch))}
|
||||
{:undone undo?})
|
||||
(for [[table-id sub-batch] (u/group-by :table_id batch)
|
||||
:let [rows (batch->rows undo? sub-batch)]
|
||||
[delta row] (map vector sub-batch rows)
|
||||
:let [category ((if undo? invert identity) (categorize delta))]]
|
||||
{:op (category->op category)
|
||||
:table-id table-id
|
||||
:row row})))))
|
||||
|
||||
(defn undo!
|
||||
"Rollback the given user's last change to the given table."
|
||||
[context user-id scope]
|
||||
(undo*! :undo context user-id scope))
|
||||
|
||||
(defn redo!
|
||||
"Rollback the given user's last change to the given table."
|
||||
[context user-id scope]
|
||||
(undo*! :redo context user-id scope))
|
||||
@@ -0,0 +1,35 @@
|
||||
(ns metabase-enterprise.data-editing-public.api
|
||||
(:require
|
||||
[metabase.actions.core :as actions]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(api.macros/defendpoint :post "/webhook/:token/data"
|
||||
"Inserts rows into the table associated with the token. See routes for ee/data-editing/webhook for token CRUD.
|
||||
|
||||
Accepts either a single row or multiple rows in an array.
|
||||
Expects all required columns to be provided.
|
||||
|
||||
The provided row JSON must be compatible with the underlying types of the table.
|
||||
e.g if you have set up a casting rule for a unix epoch seconds field to present it as a datetime, callers must provide the integer value to this API.
|
||||
|
||||
Callers should expect 400 errors on constraint violation (e.g the primary key already exists) or the schema is not met."
|
||||
[{:keys [token]}
|
||||
_
|
||||
row-or-rows]
|
||||
(let [hook (api/check-404 (t2/select-one :table_webhook_token :token token))
|
||||
rows (if (map? row-or-rows) [row-or-rows] row-or-rows)]
|
||||
(api/check-400 (seq rows) "Please supply at least one row.")
|
||||
(api/check-400 (every? seq rows) "Every row should not be empty.")
|
||||
(let [user-id (:creator_id hook)
|
||||
scope {:webhook-id (:id hook)}
|
||||
table-id (:table_id hook)
|
||||
inputs (for [r rows]
|
||||
{:table-id table-id, :row r})]
|
||||
{:created (count (:outputs (actions/perform-action! :table.row/create scope inputs {:user-id user-id
|
||||
:policy :data-editing})))})))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/data-editing routes."
|
||||
(api.macros/ns-handler *ns*))
|
||||
@@ -66,6 +66,7 @@
|
||||
"Notification"
|
||||
"NotificationCard"
|
||||
"NotificationSubscription"
|
||||
"NotificationSystemEvent"
|
||||
"NotificationHandler"
|
||||
"NotificationRecipient"
|
||||
"Permissions"
|
||||
@@ -89,6 +90,7 @@
|
||||
"Secret"
|
||||
"Session"
|
||||
"TaskHistory"
|
||||
"Undo"
|
||||
"User"
|
||||
"UserKeyValue"
|
||||
"UserParameterValue"
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
:sso_jwt true
|
||||
:sso_ldap true
|
||||
:sso_saml true
|
||||
:table_data_editing false
|
||||
:upload_management true
|
||||
:whitelabel true
|
||||
:collection_cleanup true
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
(ns metabase-enterprise.data-editing.action-v2-api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]))
|
||||
|
||||
;; TODO High level stuff we should do:
|
||||
;; 0. Make sure we have tests to cover every single step taken in our core user journeys.
|
||||
;; 1. Cover :create-or-update as well.
|
||||
;; 2. DRY up or otherwise streamline how we construct these scenarios - hard to see the forest for the boilerplate.
|
||||
;; 3. Consolidate test cases between /config-form, /tmp-model, and /execute
|
||||
|
||||
;; Important missing tests
|
||||
(comment
|
||||
configure-saved-action-on-editable-on-dashboard-test
|
||||
;; either copy past tests or use a doseq to vary how we construct it
|
||||
configure-saved-action-on-question-on-dashboard-test
|
||||
configure-table-action-on-question-on-dashboard-test)
|
||||
|
||||
;; For example, when picking a model action to add as a row action, we call this for the initial config form.
|
||||
(deftest configure-saved-query-action-test
|
||||
(let [req #(mt/user-http-request-full-response
|
||||
(:user % :crowberto)
|
||||
:post
|
||||
"action/v2/config-form"
|
||||
(select-keys % [:action_id
|
||||
:scope
|
||||
:input]))]
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/with-data-editing-enabled! true
|
||||
(testing "saved actions"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Table table {}
|
||||
:model/Card model {:type :model
|
||||
:table_id (:id table)}
|
||||
:model/Action action {:type :query
|
||||
:name "Do cool thing"
|
||||
:model_id (:id model)
|
||||
:parameters [{:id "random-a"
|
||||
:name "A"
|
||||
:type "number/="
|
||||
:slug "a"}
|
||||
{:id "random-b"
|
||||
:name "B"
|
||||
:type "date/single"
|
||||
:slug "b"}
|
||||
{:id "random-c"
|
||||
:name "C"
|
||||
:type "string/="
|
||||
:slug "c"}
|
||||
{:id "random-d"
|
||||
:name "D"
|
||||
:type "string/="
|
||||
:slug "d"}
|
||||
{:id "random-e"
|
||||
:name "E"
|
||||
:type "string/="
|
||||
:slug "e"}]
|
||||
:visualization_settings
|
||||
{:fields {"c" {:inputType "text"}
|
||||
"e" {:valueOptions ["a" "b"]}}}}]
|
||||
(is (=? {:status 200
|
||||
:body {:title "Do cool thing"
|
||||
;; TODO this is the raw format we're currently saving config like
|
||||
;; but, we want to instead return "data components" which tell us how to *change* it
|
||||
;; and also... as part of introducing /config-form we are free to *change* how config is saved
|
||||
;; because it will now be encapsulated. we just need migrate on read to avoid breaking
|
||||
;; staging and beta users.
|
||||
;; for now it doesn't tell you the type or the possible values, BUT maybe we want the wrapping
|
||||
;; component to know that (it just doesn't get saved)
|
||||
:parameters [{:id "random-a", :displayName "A", :sourceType "ask-user"}
|
||||
{:id "random-b", :displayName "B", :sourceType "ask-user"}
|
||||
{:id "random-c", :displayName "C", :sourceType "ask-user"}
|
||||
{:id "random-d", :displayName "D", :sourceType "ask-user"}
|
||||
{:id "random-e", :displayName "E", :sourceType "ask-user"}]}}
|
||||
(select-keys
|
||||
(req {:scope {:model-id (:id model)
|
||||
:table-id (:id table)}
|
||||
:action_id {:action-id (:id action)}})
|
||||
[:status :body])))))))))))
|
||||
|
||||
;; For example, when picking a model action to add as a row action, we call this for the initial config form.
|
||||
(deftest configure-saved-implicit-action-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/with-data-editing-enabled! true
|
||||
(testing "saved actions"
|
||||
(let [expected-id-params [{:id "id", :displayName "ID", :sourceType "ask-user"}]
|
||||
expected-row-params [{:id "user_id", :displayName "User ID", :sourceType "ask-user"}
|
||||
{:id "product_id", :displayName "Product ID", :sourceType "ask-user"}
|
||||
{:id "subtotal", :displayName "Subtotal", :sourceType "ask-user"}
|
||||
{:id "tax" :displayName "Tax", :sourceType "ask-user"}
|
||||
{:id "total" :displayName "Total", :sourceType "ask-user"}
|
||||
{:id "discount" :displayName "Discount", :sourceType "ask-user"}
|
||||
{:id "created_at" :displayName "Created At", :sourceType "ask-user"}
|
||||
{:id "quantity" :displayName "Quantity", :sourceType "ask-user"}]
|
||||
action-kind->expected-params {"row/create" expected-row-params
|
||||
"row/update" (concat expected-id-params expected-row-params)
|
||||
"row/delete" expected-id-params}]
|
||||
|
||||
(doseq [[action-kind expected-params] action-kind->expected-params]
|
||||
(mt/with-actions [model {:type :model
|
||||
:dataset_query (mt/mbql-query orders)}
|
||||
{action-id :action-id} {:type :implicit
|
||||
:kind action-kind
|
||||
:name "Do cool thing"}]
|
||||
(is (= {:title "Do cool thing"
|
||||
:parameters expected-params}
|
||||
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
|
||||
{:scope {:model-id (:id model)
|
||||
:table-id (mt/id :orders)}
|
||||
:action_id {:action-id action-id}})))))))))))
|
||||
|
||||
;; For example, when picking a table action to add as a row action, we call this for the initial config form.
|
||||
(deftest configure-table-action-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/with-test-tables! [test-table [{:id 'auto-inc-type
|
||||
:text [:text]
|
||||
:int [:int]
|
||||
:timestamp [:timestamp]
|
||||
:date [:date]}
|
||||
{:primary-key [:id]}]]
|
||||
|
||||
(let [;; TODO the form for configuring a row action will need to know that ID is meant to be "locked" to
|
||||
;; the pk of the table underlying the data-grid.
|
||||
expected-id-params [{:id "id" :displayName "ID", :sourceType "ask-user"}]
|
||||
expected-row-params [{:id "text" :displayName "Text", :sourceType "ask-user"}
|
||||
{:id "int" :displayName "Int", :sourceType "ask-user"}
|
||||
{:id "timestamp" :displayName "Timestamp", :sourceType "ask-user"}
|
||||
{:id "date" :displayName "Date", :sourceType "ask-user"}]]
|
||||
|
||||
(testing "table actions"
|
||||
(let [scope {:table-id test-table}
|
||||
{create-id "table.row/create"
|
||||
update-id "table.row/update"
|
||||
delete-id "table.row/delete"} (->> (mt/user-http-request-full-response
|
||||
:crowberto
|
||||
:get
|
||||
"action/v2/tmp-action")
|
||||
:body :actions
|
||||
(filter #(= test-table (:table_id %)))
|
||||
(u/index-by :kind :id))]
|
||||
(testing "create"
|
||||
(is (=? {:title (mt/malli=? :string)
|
||||
:parameters expected-row-params}
|
||||
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
|
||||
{:scope scope
|
||||
:action_id create-id}))))
|
||||
(testing "update"
|
||||
(is (=? {:title (mt/malli=? :string)
|
||||
:parameters (concat expected-id-params expected-row-params)}
|
||||
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
|
||||
{:scope scope
|
||||
:action_id update-id}))))
|
||||
(testing "delete"
|
||||
(is (=? {:title (mt/malli=? :string)
|
||||
:parameters expected-id-params}
|
||||
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
|
||||
{:scope scope
|
||||
:action_id delete-id})))))))))))
|
||||
|
||||
;; This is the case where we are EDITING some kind of header or row action, for example.
|
||||
;;
|
||||
;; There are actually two different cases here:
|
||||
;; 1. built-in actions (don't have their own configuration, but inherit parameter order from the Editable settings)
|
||||
;; 2. custom actions (have their own configuration, but no inheritance (for now, Katya working on prod doc))
|
||||
;;
|
||||
;; Since we don't support configuration for (1) yet, this is only concerned with (2)
|
||||
(deftest configure-table-action-on-editable-on-dashboard-test
|
||||
(let [req #(dissoc (mt/user-http-request-full-response
|
||||
(:user % :crowberto)
|
||||
:post
|
||||
"action/v2/config-form"
|
||||
(select-keys % [:action_id
|
||||
:scope
|
||||
:input]))
|
||||
:headers)]
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/with-test-tables! [source-table [{:id 'auto-inc-type
|
||||
:text [:text]}
|
||||
{:primary-key [:id]}]
|
||||
target-table [{:id 'auto-inc-type
|
||||
:text [:text]
|
||||
:int [:int]
|
||||
:timestamp [:timestamp]
|
||||
:date [:date]}
|
||||
{:primary-key [:id]}]]
|
||||
(let [action-id (->> (mt/user-http-request-full-response
|
||||
:crowberto
|
||||
:get
|
||||
"action/v2/tmp-action")
|
||||
:body :actions
|
||||
(filter #(= target-table (:table_id %)))
|
||||
(u/index-by :kind :id)
|
||||
(#(get % "table.row/create")))]
|
||||
(mt/with-temp
|
||||
[:model/Dashboard dashboard {}
|
||||
:model/Card model {:type :model
|
||||
:table_id source-table
|
||||
:database_id (mt/id)
|
||||
:dataset_query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table source-table}}}
|
||||
:model/DashboardCard dashcard {:dashboard_id (:id dashboard)
|
||||
:card_id (:id model)
|
||||
:visualization_settings
|
||||
{:table_id source-table
|
||||
|
||||
;; The data-grid config is NOT inherited by custom actions (yet)
|
||||
:table.columns
|
||||
[{:name "int", :enabled true}
|
||||
{:name "text", :enabled true}
|
||||
{:name "timetamp", :enabled true}
|
||||
;; this signals date should not be shown in the grid, or be available
|
||||
;; to default actions.
|
||||
{:name "date", :enabled false}]
|
||||
|
||||
;; The data-grid config is NOT inherited by custom actions (yet)
|
||||
:editableTable.columns
|
||||
["int"
|
||||
;; this signals text is not editable by default actions
|
||||
#_"text"
|
||||
"timestamp"
|
||||
"date"]
|
||||
|
||||
:editableTable.enabledActions
|
||||
;; See [[metabase.dashboards.api/create-or-fix-action-id]] for why this is unknown.
|
||||
[{:id "dashcard:unknown:built-in-update"
|
||||
:actionId "table.row/update"
|
||||
;; TODO Katya calls these "default" actions, maybe we should rename to match?
|
||||
:actionType "data-grid/built-in"
|
||||
:enabled true
|
||||
;; Not currently configurable.
|
||||
:parameterMappings nil}
|
||||
{:id "dashcard:unknown:update"
|
||||
:actionId action-id
|
||||
:actionType "data-grid/custom-action"
|
||||
:enabled true
|
||||
;; TODO make sure this stuff makes sense.
|
||||
;; What does the FE really write? Have we messed anything up?
|
||||
;; Have we missed any cases?
|
||||
:parameterMappings [{:parameterId "id"
|
||||
:displayName "Identifier"
|
||||
:sourceType "ask-user"}
|
||||
{:parameterId "int"
|
||||
:sourceType "constant"
|
||||
:value 42}
|
||||
{:parameterId "text"
|
||||
:sourceType "row-data"
|
||||
:sourceValueTarget "text"
|
||||
:visibility "readonly"}
|
||||
{:parameterId "timestamp"
|
||||
:visibility "hidden"}]}]}}]
|
||||
;; insert a row for the row action
|
||||
(mt/user-http-request :crowberto :post 200
|
||||
(data-editing.tu/table-url source-table)
|
||||
{:rows [{:text "a very important string"}]})
|
||||
|
||||
(testing "default table action on a data-grid"
|
||||
(is (=? {:status 400}
|
||||
(req {:action_id "dashcard:unknown:built-in-update"
|
||||
:scope {:dashcard-id (:id dashcard)}}))))
|
||||
|
||||
(testing "custom table action on a data-grid"
|
||||
(is (=? {:status 200
|
||||
:body {:parameters
|
||||
[{:id "id", :displayName "Identifier", :sourceType "ask-user"}
|
||||
{:id "int", :displayName "Int", :sourceType "constant", :value 42}
|
||||
{:id "text", :displayName "Text", :sourceType "row-data", :sourceValueTarget "text", :visibility "readonly"}
|
||||
{:id "timestamp", :displayName "Timestamp", :visibility "hidden"}
|
||||
{:id "date", :displayName "Date", :sourceType "ask-user"}]}}
|
||||
(req {:action_id "dashcard:unknown:update"
|
||||
:scope {:dashcard-id (:id dashcard)}})))))))))))
|
||||
|
||||
(deftest configure-saved-action-on-editable-on-dashboard-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/with-test-tables! [test-table [{:id 'auto-inc-type
|
||||
:text [:text]
|
||||
:int [:int]
|
||||
:timestamp [:timestamp]
|
||||
:date [:date]}
|
||||
{:primary-key [:id]}]]
|
||||
|
||||
(mt/with-temp
|
||||
[:model/Dashboard dash {}
|
||||
:model/Card model {:type :model
|
||||
:table_id test-table
|
||||
:database_id (mt/id)
|
||||
:dataset_query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table test-table}}}
|
||||
:model/Action action {:type :query
|
||||
:name "update"
|
||||
:model_id (:id model)
|
||||
:parameters [{:id "pid:id"
|
||||
:name "Id"
|
||||
:slug "id"}
|
||||
{:id "pid:name"
|
||||
:name "Name"
|
||||
:slug "name"}
|
||||
{:id "pid:status"
|
||||
:name "Status"
|
||||
:slug "status"}]}
|
||||
:model/ImplicitAction _ {:action_id (:id action)
|
||||
:kind "row/update"}
|
||||
:model/DashboardCard dashcard {:dashboard_id (:id dash)
|
||||
:card_id (:id model)
|
||||
:visualization_settings
|
||||
{:table_id test-table
|
||||
:editableTable.enabledActions
|
||||
[{:id "dashcard:unknown:default"
|
||||
:actionId (:id action)
|
||||
:actionType "data-grid/custom-action"
|
||||
:enabled true}
|
||||
{:id "dashcard:unknown:configured"
|
||||
:actionId (:id action)
|
||||
:actionType "data-grid/custom-action"
|
||||
:parameterMappings [{:parameterId "pid:id"
|
||||
:displayName "Identifier"
|
||||
:sourceType "ask-user"}
|
||||
;; missing name
|
||||
{:parameterId "pid:status"
|
||||
:displayName "How u?"
|
||||
:sourceType "row-data"
|
||||
:sourceValueTarget "text"}]
|
||||
:enabled true}]}}]
|
||||
;; insert a row for the row action
|
||||
(mt/user-http-request :crowberto :post 200
|
||||
(data-editing.tu/table-url test-table)
|
||||
{:rows [{:text "a very important string"}]})
|
||||
|
||||
(testing "configure for unsaved action will contains all action params"
|
||||
(is (= {:parameters [{:id "pid:id", :displayName "Id", :sourceType "ask-user"}
|
||||
{:id "pid:name", :displayName "Name", :sourceType "ask-user"}
|
||||
{:id "pid:status", :displayName "Status", :sourceType "ask-user"}]}
|
||||
(select-keys
|
||||
(mt/user-http-request :crowberto :post 200
|
||||
"action/v2/config-form" {:action_id "dashcard:unknown:default"
|
||||
:scope {:dashcard-id (:id dashcard)}})
|
||||
[:parameters]))))
|
||||
|
||||
(testing "saved configurations includes any new parameter if exists"
|
||||
(is (= {:parameters [{:id "pid:id", :displayName "Identifier", :sourceType "ask-user"}
|
||||
{:id "pid:status", :displayName "How u?", :sourceType "row-data", :sourceValueTarget "text"}
|
||||
;; name is added even though it's not originally in the saved parameterMappings
|
||||
{:id "pid:name", :displayName "Name", :sourceType "ask-user"}]}
|
||||
(select-keys
|
||||
(mt/user-http-request :crowberto :post 200
|
||||
"action/v2/config-form" {:action_id "dashcard:unknown:configured"
|
||||
:scope {:dashcard-id (:id dashcard)}})
|
||||
[:parameters])))))))))
|
||||
|
||||
;; This covers a more exotic case where we're coming back to edit the config for an action before it has been saved.
|
||||
;; This should cover both the cases where it has never been saved, or where it's simply been edited at least once since
|
||||
;; it was last saved.
|
||||
(deftest configure-pending-table-action-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/with-test-tables! [test-table [{:id 'auto-inc-type
|
||||
:text [:text]
|
||||
:int [:int]
|
||||
:timestamp [:timestamp]}
|
||||
{:primary-key [:id]}]]
|
||||
(testing "custom data-grid calling a table action, with pending configuration changes"
|
||||
(let [{action-id "table.row/update"} (->> (mt/user-http-request-full-response
|
||||
:crowberto
|
||||
:get
|
||||
"action/v2/tmp-action")
|
||||
:body :actions
|
||||
(filter #(= test-table (:table_id %)))
|
||||
(u/index-by :kind :id))
|
||||
pending-config {:parameters
|
||||
[{:id "id", :sourceType "row-data"}
|
||||
{:id "int", :sourceType "constant", :value 42}
|
||||
{:id "text", :sourceType "row-data", :visibility "readonly"}
|
||||
{:id "timestamp", :visibility "hidden"}]}
|
||||
|
||||
scope {:table-id test-table}]
|
||||
(is (=? {:title "Custom Row Action 489"
|
||||
:parameters (:parameters pending-config)}
|
||||
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
|
||||
{:scope scope
|
||||
:action_id {:action-id action-id
|
||||
:name "Custom Row Action 489"
|
||||
:parameters (:parameters pending-config)}})))))))))
|
||||
|
||||
(deftest configure-pending-saved-implicit-action-test
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(mt/test-drivers #{:h2 :postgres}
|
||||
(data-editing.tu/with-data-editing-enabled! true
|
||||
(testing "saved actions"
|
||||
(let [mapped-pk-params [{:id "id" :displayName "ID", :sourceType "row-data"}]
|
||||
pending-row-params [{:id "user_id" :displayName "User ID", :sourceType "row-data"}
|
||||
{:id "product_id" :displayName "Product ID", :sourceType "ask-user"}
|
||||
{:id "subtotal" :displayName "Subtotal", :sourceType "constant" :value 10}
|
||||
{:id "tax" :displayName "Tax", :sourceType "constant" :value 0}
|
||||
{:id "total" :displayName "Total", :sourceType "constant" :value 0}
|
||||
{:id "discount" :displayName "Discount", :sourceType "constant" :value 10}
|
||||
{:id "created_at" :displayName "Created At", :sourceType "ask-user"}
|
||||
{:id "quantity" :displayName "Quantity", :sourceType "constant" :value 1}]
|
||||
action-kind->params {"row/create" pending-row-params
|
||||
"row/update" (concat mapped-pk-params pending-row-params)
|
||||
"row/delete" mapped-pk-params}]
|
||||
|
||||
(doseq [[action-kind expected-params] action-kind->params]
|
||||
(mt/with-actions [model {:type :model, :dataset_query (mt/mbql-query orders)}
|
||||
{action-id :action-id} {:type :implicit, :kind action-kind, :name "Implicit Action"}]
|
||||
(is (= {:title "My Row Action"
|
||||
:parameters expected-params}
|
||||
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
|
||||
{:scope {:model-id (:id model)
|
||||
:table-id (mt/id :orders)}
|
||||
:action_id {:action-id action-id
|
||||
:name "My Row Action"
|
||||
:parameters expected-params}})))))))))))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
(ns metabase-enterprise.data-editing.coerce-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.coerce :as coerce]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest coercion-conversions-test
|
||||
(mt/with-clock #t "2000-01-01T00:00:00Z"
|
||||
(let [test-cases
|
||||
[{:strategy :Coercion/UNIXSeconds->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output 1710948645}
|
||||
|
||||
{:strategy :Coercion/UNIXMilliSeconds->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output 1710948645000}
|
||||
|
||||
{:strategy :Coercion/UNIXMicroSeconds->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output 1710948645000000}
|
||||
|
||||
{:strategy :Coercion/UNIXNanoSeconds->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output 1710948645000000000}
|
||||
|
||||
{:strategy :Coercion/YYYYMMDDHHMMSSString->Temporal
|
||||
:input "2024-03-20T15:30Z[UTC]" ;; Note: seconds set to 00 since the format doesn't preserve seconds
|
||||
:output "20240320153000"}
|
||||
|
||||
{:strategy :Coercion/ISO8601->DateTime
|
||||
:input "2024-03-20T15:30:45Z[UTC]"
|
||||
:output "2024-03-20T15:30:45Z[UTC]"}
|
||||
|
||||
{:strategy :Coercion/ISO8601->Date
|
||||
:input "2024-03-20T00:00Z[UTC]"
|
||||
:output "2024-03-20"}
|
||||
|
||||
{:strategy :Coercion/ISO8601->Time
|
||||
:input "2025-05-01T15:30:45Z[UTC]"
|
||||
:reversed #(str/ends-with? % "T15:30:45Z[UTC]")
|
||||
:output "15:30:45"}]]
|
||||
|
||||
(doseq [{:keys [strategy input reversed output]} test-cases]
|
||||
(testing (format "Testing %s conversions" strategy)
|
||||
(let [reversed? (or reversed #{input})
|
||||
{:keys [in out]} (get coerce/coercion-fns strategy)]
|
||||
;; Test input conversion (JSON -> Database format)
|
||||
(testing "Input conversion"
|
||||
(is (= output (in input))
|
||||
(format "Input conversion failed for %s: expected %s, got %s"
|
||||
strategy output (in input))))
|
||||
|
||||
;; Test output conversion (Database format -> JSON)
|
||||
(testing "Output conversion"
|
||||
(is (reversed? (out output))
|
||||
(format "Output conversion failed for %s: expected %s, got %s"
|
||||
strategy
|
||||
(if-not reversed
|
||||
input
|
||||
(str "something like " input))
|
||||
(out output))))
|
||||
|
||||
;; Test roundtrip conversion
|
||||
(testing "Roundtrip conversion"
|
||||
(is (reversed? (-> input in out))
|
||||
(format "Roundtrip conversion failed for %s: %s -> %s -> %s"
|
||||
strategy input (in input) (out (in input)))))))))))
|
||||
|
||||
(deftest coercion-fns-static-test
|
||||
(testing "all coercion pair have to have an in and out function"
|
||||
(testing (every? #(and (fn? (:in %)) (fn? (:out %))) coerce/coercion-fns)))
|
||||
|
||||
;; TODO: fix this test by implementing all strategies
|
||||
#_(testing "all coercion strategies are covered"
|
||||
(is (empty? (second (diff (into #{} (keys coerce/coercion-fns)) (descendants :Coercion/*)))))))
|
||||
@@ -0,0 +1,410 @@
|
||||
(ns metabase-enterprise.data-editing.models.undo-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.models.undo :as undo]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest diff-keys-test
|
||||
(testing "Detect which keys have changes"
|
||||
(is (= [:a :b :c] (#'undo/diff-keys
|
||||
nil
|
||||
(zipmap [:a :b :c] (range)))))
|
||||
(is (= [:a :b :c] (#'undo/diff-keys
|
||||
(zipmap [:a :b :c] (range))
|
||||
nil)))
|
||||
(is (= [:b :c :d] (#'undo/diff-keys
|
||||
(zipmap [:a :b :c] (range))
|
||||
(zipmap [:a :c :d] (range)))))))
|
||||
|
||||
(defn- table-rows [table-id]
|
||||
(mt/rows
|
||||
(qp/process-query
|
||||
{:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table table-id}})))
|
||||
|
||||
(defn- create-row! [user-id table-id row]
|
||||
(mt/user-http-request user-id :post 200 (data-editing.tu/table-url table-id) {:rows [row]}))
|
||||
|
||||
(defn- update-row! [user-id table-id row]
|
||||
(mt/user-http-request user-id :put 200 (data-editing.tu/table-url table-id) {:rows [row]}))
|
||||
|
||||
(defn- delete-row! [user-id table-id pk]
|
||||
(mt/user-http-request user-id :post 200 (str (data-editing.tu/table-url table-id) "/delete") {:rows [pk]}))
|
||||
|
||||
(defn- next-batch-num [undo-or-redo user-id scope]
|
||||
(undo/next-batch-num undo-or-redo user-id scope))
|
||||
|
||||
(defn- undo-via-api! [user-id scope]
|
||||
(mt/user-http-request user-id :post "/action/v2/execute"
|
||||
{:action_id "data-editing/undo"
|
||||
:scope scope
|
||||
:input {}}))
|
||||
|
||||
(defn- redo-via-api! [user-id scope]
|
||||
(mt/user-http-request user-id :post "/action/v2/execute"
|
||||
{:action_id "data-editing/redo"
|
||||
:scope scope
|
||||
:input {}}))
|
||||
|
||||
(defn- write-sequence! [table-id pk states]
|
||||
(loop [prior nil
|
||||
states states]
|
||||
(when (seq states)
|
||||
(let [[user-id value] (first states)]
|
||||
(case [(some? prior) (some? value)]
|
||||
[false false] nil
|
||||
[false true] (create-row! user-id table-id (merge pk value))
|
||||
[true false] (delete-row! user-id table-id pk)
|
||||
[true true] (update-row! user-id table-id (merge pk value)))
|
||||
(recur value (rest states))))))
|
||||
|
||||
;; I'm OK reducing this scenario's scope once we have e2e tests.
|
||||
;; Until then, I think this is ideal.
|
||||
#_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]}
|
||||
(deftest undo-redo-single-user-single-table-single-record-integration-test
|
||||
(mt/with-empty-h2-app-db!
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "Single-user chain, non-generated pk"
|
||||
;; TODO test with a real PK
|
||||
;; Beware: h2 will not allow un-delete, we'll need to adjust the test not to include deletion in its history.
|
||||
;; and, we'll want to test the failure we get when we try to un-delete
|
||||
;; Blocker for other drivers: https://linear.app/metabase/issue/WRK-223/pk-creation-for-non-h2-drivers
|
||||
(data-editing.tu/with-test-tables! [table-id [{:id [:int]
|
||||
:name [:text]
|
||||
:favourite_food [:text]}
|
||||
{:primary-key [:id]}]]
|
||||
(let [user-id (mt/user->id :crowberto)
|
||||
test-scope {:table-id table-id}]
|
||||
|
||||
(write-sequence! table-id {:id 1} [[user-id {:name "Snorkmaiden" :favourite_food "pork"}]
|
||||
[user-id {:name "Snorkmaiden" :favourite_food "orc"}]
|
||||
[user-id nil]])
|
||||
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (not (next-batch-num :redo user-id test-scope)))
|
||||
(is (= {:outputs [{:op "created", :table-id table-id :row {:id 1, :name "Snorkmaiden", :favourite_food "orc"}}]}
|
||||
(undo-via-api! user-id test-scope)))
|
||||
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 1, :name "Snorkmaiden", :favourite_food "pork"}}]}
|
||||
(undo-via-api! user-id test-scope)))
|
||||
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 1}}]}
|
||||
(undo-via-api! user-id test-scope)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (not (next-batch-num :undo user-id test-scope)))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= "Nothing to do" (undo-via-api! user-id test-scope)))
|
||||
|
||||
(is (not (next-batch-num :undo user-id test-scope)))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "created", :table-id table-id :row {:id 1, :name "Snorkmaiden", :favourite_food "pork"}}]}
|
||||
(redo-via-api! user-id test-scope)))
|
||||
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 1, :name "Snorkmaiden", :favourite_food "orc"}}]}
|
||||
(redo-via-api! user-id test-scope)))
|
||||
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (next-batch-num :redo user-id test-scope))
|
||||
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 1}}]}
|
||||
(redo-via-api! user-id test-scope)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (not (next-batch-num :redo user-id test-scope)))
|
||||
(is (= "Nothing to do" (redo-via-api! user-id test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (not (next-batch-num :redo user-id test-scope)))))))))
|
||||
|
||||
;; I'm OK reducing this scenario's scope once we have e2e tests.
|
||||
;; Until then, I think this is ideal.
|
||||
#_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]}
|
||||
(deftest undo-redo-multi-user-single-table-single-record-integration-test
|
||||
(mt/with-empty-h2-app-db!
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "Multi-user chain"
|
||||
(data-editing.tu/with-test-tables! [table-id [{:id [:int]
|
||||
:name [:text]
|
||||
:power [:int]}
|
||||
{:primary-key [:id]}]]
|
||||
(let [user-1 (mt/user->id :crowberto)
|
||||
user-2 (mt/user->id :rasta)
|
||||
test-scope {:table-id table-id}]
|
||||
|
||||
;; NOTE: this test relies on the "conflicts even when different columns changed" semantics
|
||||
;; If we improve the semantics, we'll need to improve this test!
|
||||
|
||||
(write-sequence! table-id {:id 2} [[user-1 {:name "Moomintroll" :power 3}]
|
||||
[user-1 {:name "Moomintroll" :power 9001}]
|
||||
[user-2 {:name "Moominswole" :power 9001}]
|
||||
[user-1 nil]])
|
||||
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-2 test-scope))
|
||||
(is (not (next-batch-num :redo user-2 test-scope)))
|
||||
(is (= "Your previous change has a conflict with another edit" (undo-via-api! user-2 test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (not (next-batch-num :redo user-1 test-scope)))
|
||||
(is (= {:outputs [{:op "created", :table-id table-id, :row {:id 2, :name "Moominswole", :power 9001}}]}
|
||||
(undo-via-api! user-1 test-scope)))
|
||||
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= "Your previous change has a conflict with another edit" (undo-via-api! user-1 test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (next-batch-num :undo user-2 test-scope))
|
||||
(is (not (next-batch-num :redo user-2 test-scope)))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 9001}}]}
|
||||
(undo-via-api! user-2 test-scope)))
|
||||
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 3}}]}
|
||||
(undo-via-api! user-1 test-scope)))
|
||||
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 2}}]}
|
||||
(undo-via-api! user-1 test-scope)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (not (next-batch-num :undo user-1 test-scope)))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= "Nothing to do" (undo-via-api! user-1 test-scope)))
|
||||
|
||||
(is (not (next-batch-num :undo user-1 test-scope)))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "created", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 3}}]}
|
||||
(redo-via-api! user-1 test-scope)))
|
||||
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 9001}}]}
|
||||
(redo-via-api! user-1 test-scope)))
|
||||
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moominswole", :power 9001}}]}
|
||||
(redo-via-api! user-2 test-scope)))
|
||||
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (next-batch-num :redo user-1 test-scope))
|
||||
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 2}}]}
|
||||
(redo-via-api! user-1 test-scope)))
|
||||
(is (= [] (table-rows table-id)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (not (next-batch-num :redo user-1 test-scope)))
|
||||
(is (= "Nothing to do" (redo-via-api! user-1 test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-2 test-scope))
|
||||
(is (not (next-batch-num :redo user-2 test-scope)))
|
||||
(is (= "Nothing to do" (redo-via-api! user-2 test-scope)))
|
||||
|
||||
(is (next-batch-num :undo user-1 test-scope))
|
||||
(is (not (next-batch-num :redo user-1 test-scope)))))))))
|
||||
|
||||
(deftest undo-reverted-changes-integration-test
|
||||
(mt/with-empty-h2-app-db!
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "Reverted changes have their snapshots deleted when there are further changes"
|
||||
(data-editing.tu/with-test-tables! [table-id [{:id [:int]
|
||||
:name [:text]
|
||||
:status [:text]}
|
||||
{:primary-key [:id]}]]
|
||||
(let [user-id (mt/user->id :crowberto)
|
||||
test-scope {:table-id table-id}]
|
||||
|
||||
;; NOTE: this test relies on the "conflicts even when different columns changed" semantics
|
||||
;; If we improve the semantics, we'll need to improve this test!
|
||||
|
||||
(write-sequence! table-id {:id 1} [[user-id {:name "Too-ticky" :status "sitting"}]
|
||||
[user-id {:name "Too-tickley" :status "squirming"}]
|
||||
[user-id nil]])
|
||||
|
||||
(write-sequence! table-id {:id 2} [[user-id {:name "Toffle" :status "uncomfortable"}]
|
||||
[user-id {:name "Toffle" :status "comforted"}]
|
||||
[user-id nil]])
|
||||
|
||||
(create-row! user-id table-id {:id 1, :name "Too-tickley", :status "squirming"})
|
||||
|
||||
(create-row! user-id table-id {:id 2, :name "Toggle", :status "restored"})
|
||||
(delete-row! user-id table-id {:id 2})
|
||||
|
||||
(is (= [[1 "Too-tickley" "squirming"]] (table-rows table-id)))
|
||||
|
||||
(is (nil? (next-batch-num :redo user-id test-scope)))
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
|
||||
(undo-via-api! user-id test-scope)
|
||||
(is (= [[1 "Too-tickley" "squirming"]
|
||||
[2 "Toggle" "restored"]] (table-rows table-id)))
|
||||
|
||||
(redo-via-api! user-id test-scope)
|
||||
(is (= [[1 "Too-tickley" "squirming"]] (table-rows table-id)))))))))
|
||||
|
||||
(defn- count-batches [& [where]]
|
||||
(val (ffirst (t2/query {:select [[[:count [:distinct :batch_num]] :cnt]]
|
||||
:from [(t2/table-name :model/Undo)]
|
||||
:where (or where true)}))))
|
||||
|
||||
(deftest prune-snapshots-test
|
||||
(mt/with-empty-h2-app-db!
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "We delete older batches when they exceed our retention limits"
|
||||
(data-editing.tu/with-test-tables! [table-1 [{:id [:int]} {:primary-key [:id]}]
|
||||
table-2 [{:id [:int]} {:primary-key [:id]}]]
|
||||
(let [user-1 (mt/user->id :crowberto)
|
||||
user-2 (mt/user->id :rasta)]
|
||||
|
||||
(testing "Total rows"
|
||||
(with-redefs [undo/retention-total-rows 17]
|
||||
(dotimes [i 25]
|
||||
(undo/track-change! user-1
|
||||
{:table-id table-1}
|
||||
{table-1
|
||||
{{:id 1} {:raw_before (if (even? i) {} nil)
|
||||
:raw_after (if (even? i) nil {})}
|
||||
{:id 2} {:raw_before (if (odd? i) {} nil)
|
||||
:raw_after (if (odd? i) nil {})}}})))
|
||||
|
||||
(is (= 16 (t2/count :model/Undo)))
|
||||
(is (= 8 (count-batches))))
|
||||
|
||||
(testing "Total batches"
|
||||
(with-redefs [undo/retention-total-batches 15]
|
||||
(dotimes [i 25]
|
||||
(undo/track-change! user-1
|
||||
{:table-id table-1}
|
||||
{table-1
|
||||
{{:id 1} {:raw_before (if (even? i) {} nil)
|
||||
:raw_after (if (even? i) nil {})}
|
||||
{:id 2} {:raw_before (if (odd? i) {} nil)
|
||||
:raw_after (if (odd? i) nil {})}}})))
|
||||
|
||||
(is (= 30 (t2/count :model/Undo)))
|
||||
(is (= 15 (count-batches))))
|
||||
|
||||
(testing "User id"
|
||||
(with-redefs [undo/retention-batches-per-user 5]
|
||||
(dotimes [i 25]
|
||||
;; just toggle existence
|
||||
(undo/track-change! (if (zero? (mod i 3)) user-1 user-2)
|
||||
{:table-id table-1}
|
||||
{table-1
|
||||
{{:id 1} {:raw_before (if (even? i) {} nil)
|
||||
:raw_after (if (even? i) nil {})}
|
||||
{:id 2} {:raw_before (if (odd? i) {} nil)
|
||||
:raw_after (if (odd? i) nil {})}}})))
|
||||
|
||||
(is (= 20 (t2/count :model/Undo)))
|
||||
(is (= 10 (count-batches)))
|
||||
(is (= 5 (count-batches [:= :user_id user-1])))
|
||||
(is (= 5 (count-batches [:= :user_id user-2]))))
|
||||
|
||||
(testing "Scope"
|
||||
(t2/delete! :model/Undo)
|
||||
(with-redefs [undo/retention-batches-per-scope 9]
|
||||
(dotimes [i 35]
|
||||
;; just toggle existence
|
||||
(let [table-id (if (zero? (mod i 5)) table-1 table-2)]
|
||||
(undo/track-change! user-1
|
||||
{:table-id table-id}
|
||||
{table-id
|
||||
{{:id 1} {:raw_before (if (even? i) {} nil)
|
||||
:raw_after (if (even? i) nil {})}
|
||||
{:id 2} {:raw_before (if (odd? i) {} nil)
|
||||
:raw_after (if (odd? i) nil {})}}})))
|
||||
|
||||
(is (= 32 (t2/count :model/Undo)))
|
||||
(is (= 16 (count-batches)))
|
||||
(is (= 7 (count-batches [:= :table_id table-1])))
|
||||
(is (= 9 (count-batches [:= :table_id table-2])))))
|
||||
|
||||
(testing "A haphazard mix"
|
||||
(with-redefs [undo/retention-total-rows 17
|
||||
undo/retention-total-batches 21
|
||||
undo/retention-batches-per-user 5
|
||||
undo/retention-batches-per-scope 9]
|
||||
|
||||
(dotimes [i 25]
|
||||
;; just toggle existence
|
||||
(undo/track-change! (if (zero? (mod i 3)) user-1 user-2)
|
||||
{:dashboard-id 1}
|
||||
{(if (zero? (mod i 5)) table-1 table-2)
|
||||
{{:id 1} {:raw_before (if (even? i) {} nil)
|
||||
:raw_after (if (even? i) nil {})}
|
||||
{:id 2} {:raw_before (if (odd? i) {} nil)
|
||||
:raw_after (if (odd? i) nil {})}}})))
|
||||
|
||||
(is (= 16 (t2/count :model/Undo)))
|
||||
(is (= 8 (count-batches)))
|
||||
(is (= 3 (count-batches [:= :user_id user-1])))
|
||||
(is (= 5 (count-batches [:= :user_id user-2])))
|
||||
(is (= 1 (count-batches [:= :table_id table-1])))
|
||||
(is (= 7 (count-batches [:= :table_id table-2]))))))))))
|
||||
|
||||
(deftest undo-non-undoable-batch-test
|
||||
(mt/with-empty-h2-app-db!
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(testing "Cannot undo a batch marked as undoable: false"
|
||||
(data-editing.tu/with-test-tables! [table-id [{:id [:int]
|
||||
:name [:text]}
|
||||
{:primary-key [:id]}]]
|
||||
(let [user-id (mt/user->id :crowberto)
|
||||
test-scope {:table-id table-id}]
|
||||
|
||||
;; Create a regular undoable change first
|
||||
(create-row! user-id table-id {:id 1, :name "Undoable change"})
|
||||
|
||||
;; Manually create a non-undoable change using track-change! directly
|
||||
(undo/track-change!
|
||||
user-id
|
||||
test-scope
|
||||
{table-id
|
||||
{{:id 2} {:raw_before nil ; before (nil for create)
|
||||
:raw_after {:id 2, :name "Non-undoable change"} ; after
|
||||
:undoable false}}}) ; mark as non-undoable
|
||||
|
||||
(is (= [[1 "Undoable change"]] (table-rows table-id)))
|
||||
|
||||
;; Should have batches available to undo
|
||||
(is (next-batch-num :undo user-id test-scope))
|
||||
(is (not (next-batch-num :redo user-id test-scope)))
|
||||
|
||||
;; Try to undo - should fail because the latest batch has undoable: false
|
||||
(let [before-batch-num (next-batch-num :undo user-id test-scope)]
|
||||
(is (= "Your previous change cannot be undone"
|
||||
(undo-via-api! user-id test-scope)))
|
||||
(testing "batchnum is unchanged"
|
||||
(is (= before-batch-num (next-batch-num :undo user-id test-scope)))))
|
||||
|
||||
;; Table should remain unchanged
|
||||
(is (= [[1 "Undoable change"]] (table-rows table-id)))))))))
|
||||
@@ -0,0 +1,556 @@
|
||||
(ns metabase-enterprise.data-editing.notification-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
|
||||
[metabase.actions.test-util :as actions.tu]
|
||||
[metabase.channel.urls :as urls]
|
||||
[metabase.notification.events.notification :as events.notification]
|
||||
[metabase.notification.test-util :as notification.tu]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :each (fn [thunk]
|
||||
(mt/with-premium-features #{:table-data-editing}
|
||||
(thunk))))
|
||||
|
||||
(defn- assert-email-contains
|
||||
[email expected-subject body-patterns]
|
||||
(let [expected-body-map (zipmap body-patterns (repeat (count body-patterns) true))]
|
||||
(is (=? {:subject expected-subject
|
||||
:body [(update-keys expected-body-map str)]}
|
||||
(apply mt/summarize-multipart-single-email email body-patterns)))))
|
||||
|
||||
(defn all-handlers
|
||||
[http-channel-id]
|
||||
[{:channel_type :channel/email
|
||||
:recipients [{:type :notification-recipient/user
|
||||
:user_id (mt/user->id :crowberto)}]}
|
||||
{:channel_type :channel/slack
|
||||
:recipients [{:type :notification-recipient/raw-value
|
||||
:details {:value "#test-pulse"}}]}
|
||||
{:channel_type :channel/http
|
||||
:channel_id http-channel-id}])
|
||||
|
||||
(defn test-row-notification!
|
||||
[prop request-fn channel-type->assert-fns]
|
||||
(data-editing.tu/with-temp-test-db!
|
||||
(mt/with-temp [:model/Channel chn {:type :channel/http}]
|
||||
(let [table-id (mt/id (or (:table prop) :categories))]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[notification {:notification-system-event {:event_name (:event_name prop)
|
||||
:table_id table-id}
|
||||
:notification {:condition (or
|
||||
(:condition prop)
|
||||
[:and
|
||||
[:= [:context :table_id] table-id]
|
||||
[:= [:context :event_name] (u/qualified-name (:event_name prop))]])}
|
||||
:handlers (all-handlers (:id chn))}]
|
||||
(notification.tu/with-channel-fixtures [:channel/email :channel/slack]
|
||||
(let [channel-type->captured-message (notification.tu/with-captured-channel-send!
|
||||
(request-fn notification))]
|
||||
(doseq [[channel-type assert-fn] channel-type->assert-fns]
|
||||
(testing (format "channel-type = %s" channel-type)
|
||||
(assert-fn (get channel-type->captured-message channel-type)))))))))))
|
||||
|
||||
(deftest create-row-notification-test
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.created}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:post
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:NAME "New Category"}]}))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text
|
||||
{:type "mrkdwn"
|
||||
:text (format (str
|
||||
"*A new record was _created_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
|
||||
"• *ID*: 76\n"
|
||||
"• *NAME*: New Category\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(assert-email-contains
|
||||
email
|
||||
"A new record was added to \"CATEGORIES\" by Crowberto Corv"
|
||||
[(re-pattern (format "<strong>A new record was <i>created</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
|
||||
(urls/table-url (mt/id) (mt/id :categories))))
|
||||
#"Table CATEGORIES"
|
||||
#"Crowberto Corv"
|
||||
#"Field"
|
||||
#"Value"
|
||||
#"NAME"
|
||||
#"New Category"]))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(deftest update-row-notification-test
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "Updated Category"}]}))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (format (str "*A record was _updated_* in <%s|Table CATEGORIES> by Crowberto Corv\n"
|
||||
"*Changed Fields*\n"
|
||||
"• *NAME*: ~African~ → Updated Category\n"
|
||||
"\n"
|
||||
"*Current Record Details*\n"
|
||||
"• *ID*: 1\n"
|
||||
"• *NAME*: Updated Category\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(assert-email-contains
|
||||
email
|
||||
"A record was updated in \"CATEGORIES\" by Crowberto Corv"
|
||||
[(re-pattern (format "<strong>A record was <i>updated</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
|
||||
(urls/table-url (mt/id) (mt/id :categories))))
|
||||
#"Table CATEGORIES"
|
||||
#"Crowberto Corv"
|
||||
#"Changed Fields:"
|
||||
#"NAME"
|
||||
#"African"
|
||||
#"Updated Category"
|
||||
#"Current Record Details"
|
||||
#"Field"
|
||||
#"Value"]))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(deftest delete-row-notification-test
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.deleted}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:post
|
||||
(format "%s/delete" (data-editing.tu/table-url (mt/id :categories)))
|
||||
{:rows [{:ID 1}]}))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (format (str
|
||||
"*A record was _deleted_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
|
||||
"• ~*ID*~: 1\n"
|
||||
"• ~*NAME*~: African\n\n"
|
||||
"This record is no longer available\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(assert-email-contains
|
||||
email
|
||||
"A record was deleted from \"CATEGORIES\" by Crowberto Corv"
|
||||
[(re-pattern (format "<strong>A record was <i>deleted</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
|
||||
(urls/table-url (mt/id) (mt/id :categories))))
|
||||
#"Table CATEGORIES"
|
||||
#"Crowberto Corv"
|
||||
#"Field"
|
||||
#"Value"
|
||||
#"NAME"
|
||||
#"African"
|
||||
#"This record is no longer available"]))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(deftest conditional-notification-test
|
||||
(let [condition [:and
|
||||
[:= [:context :event_name] "event/row.updated"]
|
||||
[:= [:context :record :NAME] "Facebook"]]]
|
||||
(testing "no notification sends if condition does not match"
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated
|
||||
:condition condition}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "Twitter"}]}))
|
||||
|
||||
{:channel/slack (fn [msgs] (is (zero? (count msgs))))
|
||||
:channel/email (fn [emails] (is (zero? (count emails))))
|
||||
:channel/http (fn [reqs] (is (zero? (count reqs))))}))
|
||||
|
||||
(testing "notification sends if condition matches"
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated
|
||||
:condition condition}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "Facebook"}]}))
|
||||
|
||||
{:channel/slack (fn [msgs] (is (pos-int? (count msgs))))
|
||||
:channel/email (fn [emails] (is (pos-int? (count emails))))
|
||||
:channel/http (fn [reqs] (is (pos-int? (count reqs))))}))))
|
||||
|
||||
(deftest default-slack-notification-is-escaped
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated}
|
||||
(fn [_notification]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "Metabase's Notification"}]}))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (format
|
||||
(str "*A record was _updated_* in <%1$s|Table CATEGORIES> by Crowberto Corv\n"
|
||||
"*Changed Fields*\n"
|
||||
"• *NAME*: ~African~ → Metabase's Notification\n"
|
||||
"\n"
|
||||
"*Current Record Details*\n"
|
||||
"• *ID*: 1\n"
|
||||
"• *NAME*: Metabase's Notification\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))}))
|
||||
|
||||
(deftest record-and-changes-in-template-context-are-ordered
|
||||
(actions.tu/with-actions-test-data-tables #{"people"}
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated
|
||||
:table :people}
|
||||
(fn [notification]
|
||||
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
|
||||
:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "Hello"
|
||||
:body (str "Row: {{#each record}}{{@key}},{{/each}}\n"
|
||||
"Changes: {{#each changes}}{{@key}},{{/each}}")}}]
|
||||
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :people))
|
||||
{:rows [{:ID 1 :NAME "Ngoc Khuat" :CITY "Ha Noi" :EMAIL "ngoc@metabase.com"}]})))
|
||||
{:channel/email (fn [[email :as _emails]]
|
||||
(is (=? {:body [{:content (str "Row: ID,ADDRESS,EMAIL,PASSWORD,NAME,CITY,LONGITUDE,STATE,SOURCE,BIRTH_DATE,ZIP,LATITUDE,CREATED_AT,\n"
|
||||
"Changes: EMAIL,NAME,CITY,")
|
||||
:type "text/html; charset=utf-8"}]}
|
||||
email)))})
|
||||
|
||||
(testing "respect field-order"
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated
|
||||
:table :people}
|
||||
(fn [notification]
|
||||
(t2/update! :model/Table (mt/id :people) {:field_order :alphabetical})
|
||||
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
|
||||
:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "Hello"
|
||||
:body (str "Row: {{#each record}}{{@key}},{{/each}}\n"
|
||||
"Changes: {{#each changes}}{{@key}},{{/each}}")}}]
|
||||
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :people))
|
||||
{:rows [{:ID 1 :NAME "Ngoc Khuat" :CITY "Ha Noi" :EMAIL "ngoc@metabase.com"}]})))
|
||||
{:channel/email (fn [[email :as _emails]]
|
||||
(is (=? {:body [{:content (str "Row: ADDRESS,BIRTH_DATE,CITY,CREATED_AT,EMAIL,ID,LATITUDE,LONGITUDE,NAME,PASSWORD,SOURCE,STATE,ZIP,\n"
|
||||
"Changes: CITY,EMAIL,NAME,")
|
||||
:type "text/html; charset=utf-8"}]}
|
||||
email)))}))))
|
||||
|
||||
(deftest record-and-changes-is-coerced-properly
|
||||
(actions.tu/with-actions-test-data-tables #{"categories"}
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.updated}
|
||||
(fn [notification]
|
||||
;; why testing coercion against the name column?
|
||||
;; for one it's quite hard to setup custom test data so I decided to just roll with it
|
||||
;; but also this test that failure in coercion is ignored (e.g: failed to convert name to datetime)
|
||||
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
|
||||
:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "Hello"
|
||||
:body (str "Name: {{record.NAME}}\n"
|
||||
"Name: {{changes.NAME.before}}\n"
|
||||
"Name: {{changes.NAME.after}}")}}]
|
||||
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
|
||||
(t2/update! :model/Field (mt/id :categories :name) {:coercion_strategy :Coercion/ISO8601->Date})
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "2025-03-25T00:00:00Z"}]})))
|
||||
{:channel/email (fn [[email :as _emails]]
|
||||
(is (=? {:body [{:content "Name: 2025-03-25T00:00Z[UTC]\nName: African\nName: 2025-03-25T00:00Z[UTC]"
|
||||
:type "text/html; charset=utf-8"}]}
|
||||
email)))})))
|
||||
|
||||
(deftest create-row-notification-webhook-test
|
||||
(test-row-notification!
|
||||
{:event_name :event/row.created}
|
||||
(fn [_notification]
|
||||
(let [token (:token (mt/user-http-request :crowberto
|
||||
:post "ee/data-editing/webhook"
|
||||
{:table-id (mt/id :categories)}))]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:post
|
||||
(data-editing.tu/webhook-ingest-url token)
|
||||
[{:NAME "New Category"}])))
|
||||
|
||||
{:channel/slack (fn [[message :as msgs]]
|
||||
(is (= 1 (count msgs)))
|
||||
(is (=? {:blocks [{:type "section"
|
||||
:text {:type "mrkdwn"
|
||||
:text (format (str
|
||||
"*A new record was _created_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
|
||||
"• *ID*: 76\n"
|
||||
"• *NAME*: New Category\n"
|
||||
"<%1$s|View table in Metabase>")
|
||||
(urls/table-url (mt/id) (mt/id :categories)))}}]
|
||||
:channel "#test-pulse"}
|
||||
message)))
|
||||
:channel/email (fn [[email :as emails]]
|
||||
(is (= 1 (count emails)))
|
||||
(assert-email-contains
|
||||
email
|
||||
"A new record was added to \"CATEGORIES\" by Crowberto Corv"
|
||||
[#"<strong>A new record was <i>created</i></strong> in <a[^>]*>Table CATEGORIES</a> by Crowberto Corv"
|
||||
#"Field"
|
||||
#"Value"
|
||||
#"NAME"
|
||||
#"New Category"]))
|
||||
:channel/http (fn [[req :as reqs]]
|
||||
(is (= 1 (count reqs)))
|
||||
(is (=? {:body (mt/malli=? :map)} req)))}))
|
||||
|
||||
(deftest filter-notifications-test
|
||||
(testing "getting table notifications will return only notifications of a table and action"
|
||||
(doseq [event [:event/row.created
|
||||
:event/row.updated
|
||||
:event/row.deleted]]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[_create-categories {:notification-system-event {:event_name event
|
||||
:table_id (mt/id :categories)}}]
|
||||
|
||||
(notification.tu/with-system-event-notification!
|
||||
[create-orders {:notification-system-event {:event_name event
|
||||
:table_id (mt/id :orders)}}]
|
||||
(is (= [(:id create-orders)]
|
||||
(map :id (#'events.notification/notifications-for-topic
|
||||
event
|
||||
{:args {:table_id (mt/id :orders)}})))))))))
|
||||
|
||||
(deftest example-payload-row-create-test
|
||||
(doseq [channel-type [:channel/slack :channel/email]]
|
||||
(is (=? {:context {:event_name "event/row.created"}
|
||||
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:table {:id (mt/id :orders) :name "ORDERS"}
|
||||
:settings {}
|
||||
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
|
||||
:USER_ID :TAX :CREATED_AT}
|
||||
(set (keys %)))])}
|
||||
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)}
|
||||
:channel_types [channel-type]})
|
||||
|
||||
[channel-type
|
||||
:payload])))))
|
||||
|
||||
(deftest example-payload-row-update-test
|
||||
(doseq [channel-type [:channel/slack :channel/email]]
|
||||
(is (=? {:context {:event_name "event/row.updated"}
|
||||
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:table {:id (mt/id :orders) :name "ORDERS"}
|
||||
:settings {}
|
||||
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
|
||||
:USER_ID :TAX :CREATED_AT}
|
||||
(set (keys %)))])
|
||||
:changes (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
|
||||
:USER_ID :TAX :CREATED_AT}
|
||||
(set (keys %)))])}
|
||||
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.updated
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)}
|
||||
:channel_types [channel-type]})
|
||||
|
||||
[channel-type
|
||||
:payload])))))
|
||||
|
||||
(deftest example-payload-row-delete-test
|
||||
(doseq [channel-type [:channel/slack :channel/email]]
|
||||
(is (=? {:context {:event_name "event/row.deleted"}
|
||||
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
|
||||
:table {:id (mt/id :orders) :name "ORDERS"}
|
||||
:settings {}
|
||||
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
|
||||
:USER_ID :TAX :CREATED_AT}
|
||||
(set (keys %)))])}
|
||||
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
|
||||
{:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.deleted
|
||||
:table_id (mt/id :orders)}
|
||||
:creator_id (mt/user->id :crowberto)}
|
||||
:channel_types [channel-type]})
|
||||
|
||||
[channel-type
|
||||
:payload])))))
|
||||
|
||||
(deftest preview-notification-test
|
||||
(is (=? {:context (mt/malli=? :map)
|
||||
:rendered {:from "notifications@metabase.com"
|
||||
:bcc ["bot@metabase.com"]
|
||||
:subject "Meta Bot has created a row for orders"
|
||||
:body [{:type "text/html; charset=utf-8" :content "New record: active"}]}}
|
||||
(mt/user-http-request :crowberto :post 200 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
|
||||
:body "New record: {{record.status}}"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}}))))
|
||||
|
||||
(deftest preview-notification-nice-error-message-test
|
||||
(testing "invalide handlebars template"
|
||||
(is (=? "Failed to render template: found: '}'\n{{}\n ^"
|
||||
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "subject"
|
||||
:body "{{}"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}}))))
|
||||
(testing "invalid subject template"
|
||||
(is (=? "Failed to render template: Invalid query: found '[[' or '{{' with no matching ']]' or '}}'"
|
||||
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{"
|
||||
:body "{{}"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}})))))
|
||||
|
||||
(deftest preview-notification-custom-payload-test
|
||||
(testing "use the custom context if it's valid"
|
||||
(is (=? {:context (mt/malli=? :map)
|
||||
:rendered {:from "notifications@metabase.com"
|
||||
:bcc ["bot@metabase.com"]
|
||||
:subject "Ngoc Khuat has created a row for orders"
|
||||
:body [{:type "text/html; charset=utf-8" :content "New record: cancelled"}]}}
|
||||
(mt/user-http-request :crowberto :post 200 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
|
||||
:body "New record: {{record.status}}"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}
|
||||
:custom_context {:context {:event_name :event/row.created
|
||||
:timestamp "2023-01-01T10:00:00Z"
|
||||
:scope {:type "table"
|
||||
:origin_url "https://metabase.com/table/1"}}
|
||||
:creator {:common_name "Meta Bot",
|
||||
:email "bot@metabase.com",
|
||||
:first_name "Meta",
|
||||
:last_name "Bot"},
|
||||
:editor {:common_name "Ngoc Khuat",
|
||||
:email "ngoc@metabase.com",
|
||||
:first_name "Ngoc",
|
||||
:last_name "Khuat"},
|
||||
:record {:id 1, :status "cancelled"}
|
||||
:settings {},
|
||||
:table {:id 1, :name "orders" :url "http://localhost:3000/table/1"}}}))))
|
||||
(testing "fail if the custom context does not match the schema"
|
||||
(is (=? {:errors (mt/malli=? :map)
|
||||
:specific-errors (mt/malli=? :map)}
|
||||
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
|
||||
{:template {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
|
||||
:body "Created {{count records}} records"}}
|
||||
:notification {:payload_type :notification/system-event
|
||||
:payload {:event_name :event/row.created}}
|
||||
:custom_context {::context true}})))))
|
||||
|
||||
(deftest custom-template-test
|
||||
(data-editing.tu/with-temp-test-db!
|
||||
(mt/with-temp [:model/Channel {http-chn :id} {:type :channel/http}
|
||||
:model/ChannelTemplate {http-tmpl :id} {:channel_type :channel/http
|
||||
:details {:type :http/handlebars-text
|
||||
:body "{\"record\": {{{json-encode record}}}, \"new_name\": \"{{changes.NAME.after}}\" }"}}
|
||||
:model/ChannelTemplate {slack-tmpl :id} {:channel_type :channel/slack
|
||||
:details {:type :slack/handlebars-text
|
||||
:body "Name Was changed from {{changes.NAME.before}} to {{record.NAME}}"}}
|
||||
:model/ChannelTemplate {email-tmpl :id} {:channel_type :channel/email
|
||||
:details {:type :email/handlebars-text
|
||||
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
|
||||
:body "New record: {{record.status}}"}}]
|
||||
(notification.tu/with-system-event-notification!
|
||||
[_notification {:notification-system-event {:event_name :event/row.updated
|
||||
:table_id (mt/id :categories)}
|
||||
:handlers [{:channel_type :channel/email
|
||||
:template_id email-tmpl
|
||||
:recipients [{:type :notification-recipient/user
|
||||
:user_id (mt/user->id :crowberto)}]}
|
||||
{:channel_type :channel/slack
|
||||
:template_id slack-tmpl
|
||||
:recipients [{:type :notification-recipient/raw-value
|
||||
:details {:value "#test-pulse"}}]}
|
||||
{:channel_type :channel/http
|
||||
:template_id http-tmpl
|
||||
:channel_id http-chn}]}]
|
||||
(is (=? {:channel/email [{:bcc ["crowberto@metabase.com"]
|
||||
:body [{:content "New record: "
|
||||
:type "text/html; charset=utf-8"}]
|
||||
:from "notifications@metabase.com"
|
||||
:subject "Crowberto Corv has created a row for CATEGORIES"}]
|
||||
:channel/http [{:body {"record" {"ID" 1, "NAME" "Updated Category"}
|
||||
"new_name" "Updated Category"}}]
|
||||
:channel/slack [{:blocks [{:text {:text "Name Was changed from African to Updated Category"
|
||||
:type "mrkdwn"}
|
||||
:type "section"}]
|
||||
:channel "#test-pulse"}]}
|
||||
(notification.tu/with-captured-channel-send!
|
||||
(notification.tu/with-channel-fixtures [:channel/email :channel/slack]
|
||||
(mt/user-http-request
|
||||
:crowberto
|
||||
:put
|
||||
(data-editing.tu/table-url (mt/id :categories))
|
||||
{:rows [{:ID 1 :NAME "Updated Category"}]})))))))))
|
||||
@@ -0,0 +1,122 @@
|
||||
(ns metabase-enterprise.data-editing.test-util
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[clojure.walk :as walk]
|
||||
[metabase.actions.test-util :as actions.tu]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.sync.core :as sync]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn sync-new-table!
|
||||
"Syncs a new table by name, returns the table id."
|
||||
[db table-name]
|
||||
(let [table (sync/create-table! db {:name table-name
|
||||
;; todo figure out how to determine default schema from driver
|
||||
:schema (case (:engine db) :postgres "public" nil)
|
||||
:display_name table-name
|
||||
:field_order :database})]
|
||||
(sync/sync-fields-for-table! db table)
|
||||
(:id table)))
|
||||
|
||||
(defn- create-test-table! [db table-name column-map create-table-opts]
|
||||
(let [_ (driver/create-table! (:engine db)
|
||||
(:id db)
|
||||
table-name
|
||||
column-map
|
||||
create-table-opts)]
|
||||
(sync-new-table! db table-name)))
|
||||
|
||||
(defn table-url
|
||||
"Returns the URL for data editing of the table with the given ID."
|
||||
[table-id]
|
||||
(format "ee/data-editing/table/%d" table-id))
|
||||
|
||||
(defn webhook-ingest-url
|
||||
"Returns the URL for the webhook ingest endpoint."
|
||||
[token]
|
||||
(format "ee/data-editing-public/webhook/%s/data" token))
|
||||
|
||||
(defn toggle-data-editing-enabled! [db-id on-or-off]
|
||||
(let [current-settings (t2/select-one-fn :settings :model/Database db-id)]
|
||||
(t2/update! :model/Database db-id {:settings (assoc current-settings :database-enable-table-editing (boolean on-or-off))})))
|
||||
|
||||
(defmacro with-data-editing-enabled! [on-or-off & body]
|
||||
`(mt/with-temp-vals-in-db :model/Database (mt/id) {:settings {:database-enable-table-editing ~on-or-off}}
|
||||
~@body))
|
||||
|
||||
(def default-test-table
|
||||
"The default test table config."
|
||||
[{:id 'auto-inc-type
|
||||
:name [:text]
|
||||
:song [:text]}
|
||||
{:primary-key [:id]}])
|
||||
|
||||
(defn do-with-test-tables!
|
||||
"Impl of [[with-test-tables!]]."
|
||||
[[column-map create-table-opts] thunk]
|
||||
(let [db (t2/select-one :model/Database (mt/id))
|
||||
driver (:engine db)
|
||||
auto-inc-type (driver/upload-type->database-type driver :metabase.upload/auto-incrementing-int-pk)
|
||||
column-map (walk/postwalk-replace {'auto-inc-type auto-inc-type} column-map)
|
||||
table-name (str "temp_table_" (str/replace (random-uuid) "-" "_"))
|
||||
cleanup (fn []
|
||||
(driver/drop-table! driver (mt/id) table-name)
|
||||
(t2/delete! :model/Table :name table-name))]
|
||||
(try
|
||||
(thunk (create-test-table! db table-name column-map create-table-opts))
|
||||
(catch Exception e
|
||||
(try (cleanup) (catch Exception cleanup-ex (.addSuppressed e cleanup-ex)))
|
||||
(throw e)))))
|
||||
|
||||
(defmacro with-test-tables!
|
||||
"Execute `body` with temporary table(s) created in the test database that has actions and table editing enabled.
|
||||
You may specify multiple table specifications to create multiple tables.
|
||||
Each table specification should be a vector containing `[column-map create-table-opts]`.
|
||||
The symbol `auto-inc-type` can be used in column maps to denote the driver-specific auto-incrementing type for
|
||||
primary keys.
|
||||
|
||||
Each table is created with a unique name and is automatically cleaned up after the body concludes. The body is
|
||||
executed within an empty database context (mt/with-empty-db).
|
||||
|
||||
DOES NOT CREATE TABLES UNTIL THE BODY IS EXECUTED!
|
||||
|
||||
;; create a single table with default schema
|
||||
(with-test-tables! [table-id [{:id 'auto-inc-type, :name [:text], :song [:text]}
|
||||
{:primary-key [:id]}]]
|
||||
...)
|
||||
|
||||
;; create multiple tables
|
||||
(with-test-tables! [users-table [{:id 'auto-inc-type, :name [:text]} {:primary-key [:id]}]
|
||||
posts-table [{:id 'auto-inc-type, :user_id [:int], :content [:text]} {:primary-key [:id]}]]
|
||||
...)"
|
||||
[[table-binding table-spec & more] & body]
|
||||
`(mt/with-empty-db
|
||||
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing true
|
||||
:database-enable-actions true}})
|
||||
(do-with-test-tables!
|
||||
~table-spec
|
||||
(fn [~table-binding]
|
||||
~@(if (seq more)
|
||||
[`(with-test-tables! ~(vec more) ~@body)]
|
||||
body)))))
|
||||
|
||||
(defmacro with-temp-test-db!
|
||||
"Sets up a temporary database in the appdb to do destrcutive tests.
|
||||
|
||||
Use (mt/id), (mt/db) etc to get the database id and database object."
|
||||
[& body]
|
||||
`(actions.tu/with-actions-test-data
|
||||
(toggle-data-editing-enabled! (mt/id) true)
|
||||
~@body))
|
||||
|
||||
(defmacro with-actions-temp-db
|
||||
"Like [[actions.tu/with-actions-temp-db]] but with table editing enabled"
|
||||
[dataset-definition & body]
|
||||
`(actions.tu/with-actions-temp-db ~dataset-definition
|
||||
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing true
|
||||
:database-enable-actions true}})
|
||||
~@body))
|
||||
@@ -0,0 +1 @@
|
||||
(ns metabase-enterprise.data-editing.util)
|
||||
@@ -54,6 +54,7 @@
|
||||
:model/Notification
|
||||
:model/NotificationCard
|
||||
:model/NotificationSubscription
|
||||
:model/NotificationSystemEvent
|
||||
:model/NotificationHandler
|
||||
:model/NotificationRecipient
|
||||
:model/ParameterCard
|
||||
@@ -78,6 +79,7 @@
|
||||
:model/Session
|
||||
:model/TaskHistory
|
||||
:model/TimelineEvent
|
||||
:model/Undo
|
||||
:model/User
|
||||
:model/UserParameterValue
|
||||
:model/UserKeyValue
|
||||
|
||||
@@ -14,20 +14,18 @@
|
||||
site-name "Metabase Test"
|
||||
site-url "https://metabase.com"
|
||||
admin-email "ngoc@metabase.com"]
|
||||
(is (= {:payload_type :notification/system-event
|
||||
:payload {:event_info {:foo :bar}
|
||||
:event_topic :event/user-joined
|
||||
:custom {}}
|
||||
:context {:application_name "Metabase Test"
|
||||
:application_color "#509EE3"
|
||||
:application_logo_url "http://static.metabase.com/email_logo.png"
|
||||
:include_branding false
|
||||
:site_name "Metabase Test"
|
||||
:site_url "https://metabase.com"
|
||||
:admin_email "ngoc@metabase.com"
|
||||
:style {:button true}}
|
||||
:creator nil}
|
||||
(is (= {:payload {:foo :bar
|
||||
:event_name :event/user-joined}
|
||||
:context {:application_name "Metabase Test"
|
||||
:application_color "#509EE3"
|
||||
:application_logo_url "http://static.metabase.com/email_logo.png"
|
||||
:site_name "Metabase Test"
|
||||
:site_url "https://metabase.com"
|
||||
:admin_email "ngoc@metabase.com"
|
||||
:include_branding false
|
||||
:style {:button true}}
|
||||
:creator nil}
|
||||
(-> (notification.payload/notification-payload {:payload_type :notification/system-event
|
||||
:payload {:event_topic :event/user-joined
|
||||
:event_info {:foo :bar}}})
|
||||
:payload {:event_name :event/user-joined}
|
||||
:event_info {:foo :bar}})
|
||||
(update-in [:context :style :button] string?))))))))
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
:handlers [@notification.tu/default-email-handler]}]
|
||||
(let [send-alert-by-user! (fn [user-kw]
|
||||
(-> (assoc notification :creator_id (mt/user->id user-kw))
|
||||
notification.payload/payload
|
||||
notification.payload/notification-payload
|
||||
:payload
|
||||
:card_part
|
||||
:result
|
||||
:data
|
||||
|
||||
@@ -26,6 +26,7 @@ const CHART_ANALYSIS_ENABLED = {
|
||||
scalar: false,
|
||||
smartscalar: false,
|
||||
table: false,
|
||||
"table-editable": false,
|
||||
text: false,
|
||||
} as const satisfies { [display in VisualizationDisplay]: boolean };
|
||||
|
||||
|
||||
@@ -9,5 +9,6 @@ export * from "./gdrive";
|
||||
export * from "./db-routing";
|
||||
export * from "./saml";
|
||||
export * from "./scim";
|
||||
export * from "./table-data-edit";
|
||||
export * from "./tags";
|
||||
export * from "./upload-management";
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { SkipToken } from "@reduxjs/toolkit/query";
|
||||
|
||||
import type {
|
||||
DescribeActionFormRequest,
|
||||
DescribeActionFormResponse,
|
||||
TableDeleteRowsRequest,
|
||||
TableDeleteRowsResponse,
|
||||
TableExecuteActionRequest,
|
||||
TableExecuteActionResponse,
|
||||
TableInsertRowsRequest,
|
||||
TableInsertRowsResponse,
|
||||
TableUndoRedoRequest,
|
||||
TableUndoRedoResponse,
|
||||
TableUpdateRowsRequest,
|
||||
TableUpdateRowsResponse,
|
||||
} from "metabase-enterprise/data_editing/tables/types";
|
||||
import type { DataGridWritebackAction } from "metabase-types/api/actions";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
|
||||
export const tableDataEditApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
insertTableRows: builder.mutation<
|
||||
TableInsertRowsResponse,
|
||||
TableInsertRowsRequest
|
||||
>({
|
||||
query: ({ rows, scope }) => ({
|
||||
method: "POST",
|
||||
url: `/api/action/v2/execute-bulk`,
|
||||
body: { inputs: rows, scope, action_id: "data-grid.row/create" },
|
||||
}),
|
||||
}),
|
||||
updateTableRows: builder.mutation<
|
||||
TableUpdateRowsResponse,
|
||||
TableUpdateRowsRequest
|
||||
>({
|
||||
query: ({ inputs, params, scope }) => ({
|
||||
method: "POST",
|
||||
url: `/api/action/v2/execute-bulk`,
|
||||
body: { inputs, params, scope, action_id: "data-grid.row/update" },
|
||||
}),
|
||||
}),
|
||||
deleteTableRows: builder.mutation<
|
||||
TableDeleteRowsResponse,
|
||||
TableDeleteRowsRequest
|
||||
>({
|
||||
query: ({ rows, scope, params }) => ({
|
||||
method: "POST",
|
||||
url: `/api/action/v2/execute-bulk`,
|
||||
body: {
|
||||
inputs: rows,
|
||||
scope,
|
||||
action_id: "data-grid.row/delete",
|
||||
...(params && { params }),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
tableUndo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
|
||||
query: ({ tableId, scope }) => ({
|
||||
method: "POST",
|
||||
url: `/api/action/v2/execute`,
|
||||
body: {
|
||||
input: {
|
||||
"table-id": tableId,
|
||||
},
|
||||
scope,
|
||||
action_id: "data-editing/undo",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
tableRedo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
|
||||
query: ({ tableId, scope }) => ({
|
||||
method: "POST",
|
||||
url: `/api/action/v2/execute`,
|
||||
body: {
|
||||
input: {
|
||||
"table-id": tableId,
|
||||
},
|
||||
scope,
|
||||
action_id: "data-editing/redo",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getActions: builder.query<
|
||||
DataGridWritebackAction[],
|
||||
null | SkipToken | void
|
||||
>({
|
||||
query: (params) => ({
|
||||
method: "GET",
|
||||
url: `/api/action/v2/tmp-action`,
|
||||
params,
|
||||
}),
|
||||
transformResponse: (response: { actions: DataGridWritebackAction[] }) =>
|
||||
response?.actions,
|
||||
}),
|
||||
executeAction: builder.mutation<
|
||||
TableExecuteActionResponse,
|
||||
TableExecuteActionRequest
|
||||
>({
|
||||
query: ({ actionId, scope, input, params }) => ({
|
||||
method: "POST",
|
||||
url: `/api/action/v2/execute`,
|
||||
body: {
|
||||
input,
|
||||
params,
|
||||
scope,
|
||||
action_id: actionId,
|
||||
},
|
||||
}),
|
||||
transformResponse: (response: {
|
||||
outputs: [{ "rows-affected": number }];
|
||||
}) => {
|
||||
return response?.outputs?.[0];
|
||||
},
|
||||
}),
|
||||
describeActionForm: builder.mutation<
|
||||
DescribeActionFormResponse,
|
||||
DescribeActionFormRequest
|
||||
>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: `/api/action/v2/tmp-modal`,
|
||||
body,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useInsertTableRowsMutation,
|
||||
useUpdateTableRowsMutation,
|
||||
useDeleteTableRowsMutation,
|
||||
useTableUndoMutation,
|
||||
useTableRedoMutation,
|
||||
useGetActionsQuery,
|
||||
useExecuteActionMutation,
|
||||
useDescribeActionFormMutation,
|
||||
} = tableDataEditApi;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { EditableTableActionsDisplaySettings } from "metabase-types/api";
|
||||
|
||||
export const useBuiltInActions = (
|
||||
actionsVizSettings: EditableTableActionsDisplaySettings[] | undefined,
|
||||
) => {
|
||||
return useMemo(() => {
|
||||
let hasCreateAction = false;
|
||||
let hasDeleteAction = false;
|
||||
let hasUpdateAction = true;
|
||||
|
||||
actionsVizSettings?.forEach((action) => {
|
||||
if (action.actionId === "data-grid.row/create" && action.enabled) {
|
||||
hasCreateAction = true;
|
||||
}
|
||||
if (action.actionId === "data-grid.row/delete" && action.enabled) {
|
||||
hasDeleteAction = true;
|
||||
}
|
||||
if (action.actionId === "data-grid.row/update" && !action.enabled) {
|
||||
hasUpdateAction = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hasCreateAction,
|
||||
hasDeleteAction,
|
||||
hasUpdateAction,
|
||||
};
|
||||
}, [actionsVizSettings]);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
.root {
|
||||
/* position: relative; */
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: calc(100% - 5.5rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rootPreviewOpen {
|
||||
grid-template-columns: 50% 50%;
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.rootNoPreview {
|
||||
grid-template-columns: 100%;
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
position: relative;
|
||||
z-index: 1002;
|
||||
background-color: var(--mb-color-bg-white);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
position: relative;
|
||||
z-index: 1003;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modalClose {
|
||||
z-index: 1004;
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import { skipToken } from "@reduxjs/toolkit/query";
|
||||
import cx from "classnames";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
import { isEqual } from "underscore";
|
||||
|
||||
import {
|
||||
useCreateNotificationMutation,
|
||||
useGetChannelInfoQuery,
|
||||
useGetDefaultNotificationTemplateQuery,
|
||||
useGetNotificationPayloadExampleQuery,
|
||||
useLazyPreviewNotificationTemplateQuery,
|
||||
useListChannelsQuery,
|
||||
usePreviewNotificationTemplateQuery,
|
||||
useUpdateNotificationMutation,
|
||||
} from "metabase/api";
|
||||
import ButtonWithStatus from "metabase/common/components/ButtonWithStatus";
|
||||
import { ConfirmModal } from "metabase/common/components/ConfirmModal";
|
||||
import { AutoWidthSelect } from "metabase/common/components/Schedule/AutoWidthSelect";
|
||||
import { useEscapeToCloseModal } from "metabase/common/hooks/use-escape-to-close-modal";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { openInBlankWindow } from "metabase/lib/dom";
|
||||
import { alertIsValid } from "metabase/lib/notifications";
|
||||
import {
|
||||
getHasConfiguredAnyChannel,
|
||||
getHasConfiguredEmailChannel,
|
||||
} from "metabase/lib/pulse";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { ChannelSetupModal } from "metabase/notifications/modals/shared/ChannelSetupModal";
|
||||
import { AlertModalSettingsBlock } from "metabase/notifications/modals/shared/components/AlertModalSettingsBlock/AlertModalSettingsBlock";
|
||||
import { AlertTriggerIcon } from "metabase/notifications/modals/shared/components/AlertTriggerIcon";
|
||||
import { NotificationChannelsPicker } from "metabase/notifications/modals/shared/components/NotificationChannels/NotificationChannelsPicker/NotificationChannelsPicker";
|
||||
import { getDefaultTableNotificationRequest } from "metabase/notifications/utils";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import { canAccessSettings, getUser } from "metabase/selectors/user";
|
||||
import { Button, Flex, Modal, Stack, rem } from "metabase/ui";
|
||||
import type {
|
||||
ChannelApiResponse,
|
||||
ChannelTemplate,
|
||||
ConditionalAlertExpression,
|
||||
CreateTableNotificationRequest,
|
||||
NotificationChannelType,
|
||||
NotificationHandler,
|
||||
NotificationTriggerEvent,
|
||||
TableId,
|
||||
TableNotification,
|
||||
UpdateTableNotificationRequest,
|
||||
User,
|
||||
UserId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import S from "./CreateOrEditTableNotificationModal.module.css";
|
||||
import { AlertConditionBuilder } from "./components/AlertConditionBuilder/AlertConditionBuilder";
|
||||
import { PreviewTemplatePanel } from "./components/PreviewTemplatePanel/PreviewTemplatePanel";
|
||||
|
||||
type TableNotificationTriggerOption = {
|
||||
value: {
|
||||
eventName: NotificationTriggerEvent;
|
||||
};
|
||||
get label(): string;
|
||||
};
|
||||
|
||||
const NOTIFICATION_TRIGGER_OPTIONS_MAP: Record<
|
||||
NotificationTriggerEvent,
|
||||
TableNotificationTriggerOption
|
||||
> = {
|
||||
"event/row.created": {
|
||||
value: {
|
||||
eventName: "event/row.created",
|
||||
},
|
||||
get label() {
|
||||
return t`When new records are created`;
|
||||
},
|
||||
},
|
||||
"event/row.updated": {
|
||||
value: {
|
||||
eventName: "event/row.updated",
|
||||
},
|
||||
get label() {
|
||||
return t`When any cell changes it's value`;
|
||||
},
|
||||
},
|
||||
"event/row.deleted": {
|
||||
value: {
|
||||
eventName: "event/row.deleted",
|
||||
},
|
||||
get label() {
|
||||
return t`When records are deleted`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const triggerOptions = (
|
||||
[
|
||||
"event/row.created",
|
||||
"event/row.updated",
|
||||
"event/row.deleted",
|
||||
] as NotificationTriggerEvent[]
|
||||
).map((event) => ({
|
||||
value: event,
|
||||
label: NOTIFICATION_TRIGGER_OPTIONS_MAP[event].label,
|
||||
option: NOTIFICATION_TRIGGER_OPTIONS_MAP[event],
|
||||
}));
|
||||
|
||||
interface CreateOrEditTableNotificationModalProps {
|
||||
tableId: TableId;
|
||||
notification?: TableNotification | null;
|
||||
onNotificationCreated?: () => void;
|
||||
onNotificationUpdated?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Custom hooks to encapsulate logic from the modal.
|
||||
// Fetch necessary data.
|
||||
const useNotificationTemplates = (
|
||||
requestBody:
|
||||
| CreateTableNotificationRequest
|
||||
| UpdateTableNotificationRequest
|
||||
| null,
|
||||
user: { id: UserId } | null,
|
||||
) => {
|
||||
const channelTypes = useMemo(
|
||||
() =>
|
||||
requestBody?.handlers
|
||||
? requestBody.handlers
|
||||
.map((h) => h.channel_type)
|
||||
.filter((type): type is NotificationChannelType => !!type)
|
||||
: [],
|
||||
[requestBody?.handlers],
|
||||
);
|
||||
|
||||
const { data: defaultTemplates } = useGetDefaultNotificationTemplateQuery(
|
||||
requestBody?.payload_type &&
|
||||
requestBody?.payload?.event_name &&
|
||||
user &&
|
||||
channelTypes.length > 0
|
||||
? {
|
||||
notification: {
|
||||
payload_type: requestBody.payload_type,
|
||||
payload: requestBody.payload,
|
||||
},
|
||||
channel_types: channelTypes,
|
||||
}
|
||||
: skipToken,
|
||||
{
|
||||
skip: !requestBody?.payload?.event_name || channelTypes.length === 0,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: templateContext } = useGetNotificationPayloadExampleQuery(
|
||||
requestBody?.payload_type &&
|
||||
requestBody?.payload?.event_name &&
|
||||
user &&
|
||||
channelTypes.length > 0
|
||||
? {
|
||||
notification: {
|
||||
payload_type: requestBody.payload_type,
|
||||
payload: requestBody.payload,
|
||||
creator_id: user.id,
|
||||
},
|
||||
channel_types: channelTypes,
|
||||
}
|
||||
: skipToken,
|
||||
{
|
||||
skip: !requestBody?.payload?.event_name || channelTypes.length === 0,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
defaultTemplates,
|
||||
templateContext,
|
||||
};
|
||||
};
|
||||
|
||||
const useNotificationFormState = (
|
||||
tableId: TableId,
|
||||
notification: TableNotification | null,
|
||||
user: User | null,
|
||||
channelSpec: ChannelApiResponse | undefined,
|
||||
userCanAccessSettings: boolean,
|
||||
) => {
|
||||
const { data: hookChannels } = useListChannelsQuery();
|
||||
const [requestBody, setRequestBody] = useState<
|
||||
CreateTableNotificationRequest | UpdateTableNotificationRequest | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure all required data is loaded before initializing
|
||||
if (!requestBody && user && channelSpec && hookChannels) {
|
||||
const eventName: NotificationTriggerEvent =
|
||||
notification?.payload.event_name ?? "event/row.created";
|
||||
const initialRequestBody = getDefaultTableNotificationRequest({
|
||||
tableId,
|
||||
eventName,
|
||||
currentUserId: user.id,
|
||||
channelSpec,
|
||||
hookChannels,
|
||||
userCanAccessSettings,
|
||||
});
|
||||
// If editing, merge existing notification data
|
||||
if (notification) {
|
||||
setRequestBody({ ...initialRequestBody, ...notification });
|
||||
} else {
|
||||
setRequestBody(initialRequestBody);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
tableId,
|
||||
notification,
|
||||
requestBody,
|
||||
user,
|
||||
channelSpec,
|
||||
hookChannels,
|
||||
userCanAccessSettings,
|
||||
]);
|
||||
|
||||
const handleEventNameChange = useCallback((value: string | null) => {
|
||||
if (value) {
|
||||
const selectedOption =
|
||||
NOTIFICATION_TRIGGER_OPTIONS_MAP[value as NotificationTriggerEvent];
|
||||
if (selectedOption) {
|
||||
setRequestBody((requestBody) =>
|
||||
requestBody
|
||||
? {
|
||||
...requestBody,
|
||||
payload: {
|
||||
...requestBody.payload,
|
||||
event_name: selectedOption.value.eventName,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChannelHandlersChange = useCallback(
|
||||
(newHandlers: NotificationHandler[]) => {
|
||||
setRequestBody((requestBody) =>
|
||||
requestBody
|
||||
? {
|
||||
...requestBody,
|
||||
handlers: newHandlers,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleConditionChange = useCallback(
|
||||
(newCondition: ConditionalAlertExpression) => {
|
||||
setRequestBody((requestBody) =>
|
||||
requestBody
|
||||
? {
|
||||
...requestBody,
|
||||
condition: newCondition,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
requestBody,
|
||||
handleEventNameChange,
|
||||
handleChannelHandlersChange,
|
||||
handleConditionChange,
|
||||
};
|
||||
};
|
||||
|
||||
// Manage the preview panel state and data fetching
|
||||
const useNotificationTemplatePreview = (
|
||||
requestBody:
|
||||
| CreateTableNotificationRequest
|
||||
| UpdateTableNotificationRequest
|
||||
| null,
|
||||
defaultTemplates:
|
||||
| Record<NotificationChannelType, ChannelTemplate>
|
||||
| undefined,
|
||||
) => {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewRequest, setPreviewRequest] = useState<{
|
||||
notification:
|
||||
| CreateTableNotificationRequest
|
||||
| UpdateTableNotificationRequest;
|
||||
template: ChannelTemplate;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
data: previewData,
|
||||
isLoading: isPreviewLoading,
|
||||
error: previewError,
|
||||
} = usePreviewNotificationTemplateQuery(previewRequest ?? skipToken, {
|
||||
skip: !previewRequest,
|
||||
});
|
||||
const [fetchPreview] = useLazyPreviewNotificationTemplateQuery();
|
||||
|
||||
const handlePreviewClick = useCallback(
|
||||
async (channelType: NotificationChannelType) => {
|
||||
if (previewOpen && channelType === "channel/email") {
|
||||
setPreviewOpen(false);
|
||||
setPreviewRequest(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let previewRequest = null;
|
||||
if (requestBody && channelType) {
|
||||
const handler = requestBody.handlers.find(
|
||||
(h) => h.channel_type === channelType && h.template,
|
||||
);
|
||||
const currentTemplate =
|
||||
handler?.template || defaultTemplates?.[channelType];
|
||||
|
||||
if (currentTemplate) {
|
||||
previewRequest = {
|
||||
notification: requestBody,
|
||||
template: currentTemplate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (channelType === "channel/email") {
|
||||
setPreviewOpen(true);
|
||||
setPreviewRequest(previewRequest);
|
||||
}
|
||||
|
||||
if (channelType === "channel/slack" && previewRequest) {
|
||||
const { data } = await fetchPreview(previewRequest);
|
||||
if (data?.preview_url) {
|
||||
openInBlankWindow(data.preview_url);
|
||||
}
|
||||
}
|
||||
},
|
||||
[defaultTemplates, fetchPreview, previewOpen, requestBody],
|
||||
);
|
||||
|
||||
const handlePreviewClose = useCallback(() => {
|
||||
setPreviewOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
previewOpen,
|
||||
previewData,
|
||||
isPreviewLoading,
|
||||
previewError,
|
||||
handlePreviewClick,
|
||||
handlePreviewClose,
|
||||
};
|
||||
};
|
||||
|
||||
// Manage saving/updating and the confirmation modal
|
||||
const useNotificationSave = (
|
||||
requestBody:
|
||||
| CreateTableNotificationRequest
|
||||
| UpdateTableNotificationRequest
|
||||
| null,
|
||||
notification: TableNotification | null,
|
||||
isEditMode: boolean,
|
||||
onNotificationCreated?: () => void,
|
||||
onNotificationUpdated?: () => void,
|
||||
) => {
|
||||
const dispatch = useDispatch();
|
||||
const [createNotification] = useCreateNotificationMutation();
|
||||
const [updateNotification] = useUpdateNotificationMutation();
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() => notification && !isEqual(requestBody, notification),
|
||||
[requestBody, notification],
|
||||
);
|
||||
|
||||
const onCreateOrEditAlert = useCallback(async () => {
|
||||
if (!requestBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditMode && notification) {
|
||||
await updateNotification({
|
||||
...requestBody,
|
||||
id: notification.id,
|
||||
} as UpdateTableNotificationRequest);
|
||||
dispatch(
|
||||
addUndo({
|
||||
message: isEditMode ? t`Alert updated.` : t`Alert created.`,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isEditMode) {
|
||||
onNotificationUpdated?.();
|
||||
} else {
|
||||
onNotificationCreated?.();
|
||||
}
|
||||
} else {
|
||||
await createNotification(requestBody as CreateTableNotificationRequest);
|
||||
dispatch(
|
||||
addUndo({
|
||||
message: isEditMode ? t`Alert updated.` : t`Alert created.`,
|
||||
}),
|
||||
);
|
||||
onNotificationCreated?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save notification:", error);
|
||||
throw error;
|
||||
}
|
||||
}, [
|
||||
requestBody,
|
||||
isEditMode,
|
||||
notification,
|
||||
updateNotification,
|
||||
createNotification,
|
||||
dispatch,
|
||||
onNotificationCreated,
|
||||
onNotificationUpdated,
|
||||
]);
|
||||
|
||||
return {
|
||||
hasChanges,
|
||||
onCreateOrEditAlert,
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateOrEditTableNotificationModal = ({
|
||||
tableId,
|
||||
notification,
|
||||
onNotificationCreated,
|
||||
onNotificationUpdated,
|
||||
onClose,
|
||||
}: CreateOrEditTableNotificationModalProps) => {
|
||||
const user = useSelector(getUser);
|
||||
const userCanAccessSettings = useSelector(canAccessSettings);
|
||||
const isEditMode = !!notification?.id;
|
||||
|
||||
const { data: channelSpec, isLoading: isLoadingChannelInfo } =
|
||||
useGetChannelInfoQuery();
|
||||
|
||||
const {
|
||||
requestBody,
|
||||
handleEventNameChange,
|
||||
handleChannelHandlersChange,
|
||||
handleConditionChange,
|
||||
} = useNotificationFormState(
|
||||
tableId,
|
||||
notification ?? null,
|
||||
user,
|
||||
channelSpec,
|
||||
userCanAccessSettings,
|
||||
);
|
||||
|
||||
const { defaultTemplates, templateContext } = useNotificationTemplates(
|
||||
requestBody,
|
||||
user,
|
||||
);
|
||||
|
||||
const {
|
||||
previewOpen,
|
||||
previewData,
|
||||
isPreviewLoading,
|
||||
previewError,
|
||||
handlePreviewClick,
|
||||
handlePreviewClose,
|
||||
} = useNotificationTemplatePreview(requestBody, defaultTemplates);
|
||||
|
||||
const { hasChanges, onCreateOrEditAlert } = useNotificationSave(
|
||||
requestBody,
|
||||
notification ?? null,
|
||||
isEditMode,
|
||||
onNotificationCreated,
|
||||
onNotificationUpdated,
|
||||
);
|
||||
|
||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||
const handleCloseAttempt = useCallback(() => {
|
||||
if (isConfirmModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
setIsConfirmModalOpen(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [hasChanges, onClose, isConfirmModalOpen]);
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
setIsConfirmModalOpen(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEscapeToCloseModal(handleCloseAttempt, { capture: false });
|
||||
|
||||
const hasConfiguredAnyChannel = useMemo(
|
||||
() => getHasConfiguredAnyChannel(channelSpec),
|
||||
[channelSpec],
|
||||
);
|
||||
const hasConfiguredEmailChannel = useMemo(
|
||||
() => getHasConfiguredEmailChannel(channelSpec),
|
||||
[channelSpec],
|
||||
);
|
||||
|
||||
const channelRequirementsMet = userCanAccessSettings
|
||||
? hasConfiguredAnyChannel
|
||||
: hasConfiguredEmailChannel;
|
||||
|
||||
if (!isLoadingChannelInfo && channelSpec && !channelRequirementsMet) {
|
||||
return (
|
||||
<ChannelSetupModal
|
||||
userCanAccessSettings={userCanAccessSettings}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!requestBody || !requestBody.payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
data-testid="table-notification-create"
|
||||
opened
|
||||
size={previewOpen ? rem(1360) : rem(680)}
|
||||
onClose={handleCloseAttempt}
|
||||
padding="2.5rem"
|
||||
closeOnEscape={false}
|
||||
title={isEditMode ? t`Edit alert` : t`New alert`}
|
||||
maw="90%"
|
||||
classNames={{
|
||||
header: S.modalHeader,
|
||||
content: S.modalContent,
|
||||
body: S.modalBody,
|
||||
close: S.modalClose,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
S.root,
|
||||
previewOpen ? S.rootPreviewOpen : S.rootNoPreview,
|
||||
)}
|
||||
>
|
||||
<Stack gap="xl" mt="1.5rem" mb="2rem" px="2.5rem">
|
||||
<AlertModalSettingsBlock
|
||||
title={t`What do you want to be notified about?`}
|
||||
>
|
||||
<Flex gap="lg" align="center">
|
||||
<AlertTriggerIcon />
|
||||
<AutoWidthSelect
|
||||
data-testid="notification-event-select"
|
||||
data={triggerOptions}
|
||||
value={requestBody.payload.event_name}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
handleEventNameChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex mt="md">
|
||||
<AlertConditionBuilder
|
||||
tableId={tableId}
|
||||
eventType={requestBody?.payload.event_name}
|
||||
onChange={handleConditionChange}
|
||||
initialExpression={notification?.condition}
|
||||
/>
|
||||
</Flex>
|
||||
</AlertModalSettingsBlock>
|
||||
<AlertModalSettingsBlock
|
||||
title={t`Where do you want to send the alerts?`}
|
||||
contentProps={{
|
||||
style: {
|
||||
// To display autocomplete popovers properly.
|
||||
overflow: "visible",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NotificationChannelsPicker
|
||||
enableTemplates={{ email: true, slack: true }}
|
||||
notificationHandlers={requestBody.handlers}
|
||||
channels={channelSpec ? channelSpec.channels : undefined}
|
||||
onChange={handleChannelHandlersChange}
|
||||
templateContext={templateContext}
|
||||
defaultTemplates={defaultTemplates}
|
||||
onPreviewClick={handlePreviewClick}
|
||||
isPreviewOpen={previewOpen}
|
||||
getInvalidRecipientText={(domains) =>
|
||||
t`You're only allowed to email alerts to addresses ending in ${domains}`
|
||||
}
|
||||
/>
|
||||
</AlertModalSettingsBlock>
|
||||
</Stack>
|
||||
|
||||
{previewOpen && (
|
||||
<PreviewTemplatePanel
|
||||
onClose={handlePreviewClose}
|
||||
isLoading={isPreviewLoading}
|
||||
error={previewError}
|
||||
previewContent={previewData?.rendered}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Flex
|
||||
h="5.5rem"
|
||||
justify="flex-end"
|
||||
px="2.5rem"
|
||||
py="lg"
|
||||
className={cx(CS.borderTop, S.modalFooter)}
|
||||
>
|
||||
<Button
|
||||
onClick={handleCloseAttempt}
|
||||
className={CS.mr2}
|
||||
>{t`Cancel`}</Button>
|
||||
<ButtonWithStatus
|
||||
titleForState={{
|
||||
default: isEditMode && hasChanges ? t`Save changes` : t`Done`,
|
||||
}}
|
||||
disabled={
|
||||
!alertIsValid(requestBody.handlers, channelSpec) ||
|
||||
(isEditMode && !hasChanges)
|
||||
}
|
||||
onClickOperation={onCreateOrEditAlert}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{hasChanges && (
|
||||
<ConfirmModal
|
||||
size="md"
|
||||
opened={isConfirmModalOpen}
|
||||
title={t`Discard unsaved changes?`}
|
||||
message={t`You have unsaved changes. Are you sure you want to discard them?`}
|
||||
onClose={() => setIsConfirmModalOpen(false)}
|
||||
onConfirm={handleConfirmDiscard}
|
||||
confirmButtonText={t`Discard`}
|
||||
closeButtonText={t`Cancel`}
|
||||
closeOnEscape={false}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,450 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { setupEnterprisePlugins } from "__support__/enterprise";
|
||||
import {
|
||||
setupNotificationChannelsEndpoints,
|
||||
setupUserRecipientsEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { setupWebhookChannelsEndpoint } from "__support__/server-mocks/channel";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { createMockEntitiesState } from "__support__/store";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import type { UserWithApplicationPermissions } from "metabase/plugins";
|
||||
import type {
|
||||
ChannelApiResponse,
|
||||
NotificationChannel,
|
||||
NotificationTriggerEvent,
|
||||
TableNotification,
|
||||
} from "metabase-types/api";
|
||||
import {
|
||||
createMockTable,
|
||||
createMockTokenFeatures,
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
import { createMockChannel } from "metabase-types/api/mocks/channel";
|
||||
import {
|
||||
createMockNotificationHandlerEmail,
|
||||
createMockNotificationRecipientUser,
|
||||
} from "metabase-types/api/mocks/notification";
|
||||
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
|
||||
|
||||
import { CreateOrEditTableNotificationModal } from "./CreateOrEditTableNotificationModal";
|
||||
|
||||
describe("CreateOrEditTableNotificationModal", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.post("path:/api/notification/payload", { body: { id: 123 } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should display first available channel by default - Email", async () => {
|
||||
setup({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("table-notification-create"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([{ isAdmin: true }, { isAdmin: false, userCanAccessSettings: true }])(
|
||||
"should display first available channel by default - Slack %p",
|
||||
async (setupConfig) => {
|
||||
setup({
|
||||
isEmailSetup: false,
|
||||
isSlackSetup: true,
|
||||
...setupConfig,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("table-notification-create"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Email")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Slack")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ isAdmin: true }, { isAdmin: false, userCanAccessSettings: true }])(
|
||||
"should display first available channel by default - Webhook %p",
|
||||
async (setupConfig) => {
|
||||
const mockWebhook = createMockChannel();
|
||||
setup({
|
||||
isEmailSetup: false,
|
||||
isHttpSetup: true,
|
||||
webhooksResult: [mockWebhook],
|
||||
...setupConfig,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("table-notification-create"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Email")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(mockWebhook.name)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("should not show channels if user does not have permissions", async () => {
|
||||
const mockWebhook = createMockChannel();
|
||||
setup({
|
||||
isEmailSetup: false,
|
||||
isSlackSetup: true,
|
||||
isHttpSetup: true,
|
||||
webhooksResult: [mockWebhook],
|
||||
userCanAccessSettings: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("alerts-channel-setup-modal"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("table-notification-create"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show set up channels model for non-admin users with access setting permission", async () => {
|
||||
setup({
|
||||
isEmailSetup: false,
|
||||
isSlackSetup: false,
|
||||
isHttpSetup: false,
|
||||
userCanAccessSettings: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("alerts-channel-setup-modal"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId("alerts-channel-create-webhook"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show row created event as the default trigger", async () => {
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that the correct option is selected (UI displays the label, not the value)
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
expect(triggerSelect).toHaveValue("When new records are created");
|
||||
});
|
||||
|
||||
it("should show the correct event options for table notifications", async () => {
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the event select to open the dropdown
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
await userEvent.click(triggerSelect);
|
||||
|
||||
// Verify all event options are available
|
||||
expect(
|
||||
screen.getByRole("option", { name: /When new records are created/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: /When any cell changes it's value/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: /When records are deleted/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show notification data when in edit mode", async () => {
|
||||
// Create a custom notification with 'row updated' event
|
||||
const mockNotification = createMockTableNotification({
|
||||
event_name: "event/row.updated",
|
||||
});
|
||||
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
editingNotification: mockNotification,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show "Edit alert" instead of "New alert"
|
||||
expect(screen.getByText("Edit alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify the correct event is selected (UI displays the label, not the value)
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
expect(triggerSelect).toHaveValue("When any cell changes it's value");
|
||||
});
|
||||
|
||||
it("should create a new notification with default settings", async () => {
|
||||
// Setup fetchMock for API call
|
||||
fetchMock.postOnce("path:/api/notification", { body: { id: 123 } });
|
||||
const onNotificationCreatedMock = jest.fn();
|
||||
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
onNotificationCreatedMock,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click save button without changing anything
|
||||
const saveButton = screen.getByRole("button", { name: /done/i });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
// Verify the API was called with the default settings
|
||||
const calls = fetchMock.calls("path:/api/notification");
|
||||
expect(calls.length).toBe(1);
|
||||
|
||||
const requestBody = await calls[0][1]?.body;
|
||||
const parsedBody = JSON.parse(requestBody as string);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(parsedBody.payload.event_name).toBe("event/row.created");
|
||||
});
|
||||
|
||||
// Verify it has the default table_id
|
||||
expect(parsedBody.payload.table_id).toBe(42);
|
||||
|
||||
// Verify it has at least one handler
|
||||
expect(parsedBody.handlers.length).toBeGreaterThan(0);
|
||||
|
||||
expect(onNotificationCreatedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should change notification event and create new notification", async () => {
|
||||
// Setup fetchMock for API call
|
||||
fetchMock.postOnce("path:/api/notification", { body: { id: 123 } });
|
||||
const onNotificationCreatedMock = jest.fn();
|
||||
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
onNotificationCreatedMock,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change the event to "row updated"
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
await userEvent.click(triggerSelect);
|
||||
await userEvent.click(
|
||||
screen.getByRole("option", { name: /When any cell changes it's value/i }),
|
||||
);
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByRole("button", { name: /done/i });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
// Verify the API was called with the updated event
|
||||
const calls = fetchMock.calls("path:/api/notification");
|
||||
expect(calls.length).toBe(1);
|
||||
|
||||
const requestBody = await calls[0][1]?.body;
|
||||
const parsedBody = JSON.parse(requestBody as string);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(parsedBody.payload.event_name).toBe("event/row.updated");
|
||||
});
|
||||
|
||||
// Additional assertions...
|
||||
});
|
||||
|
||||
it("should update an existing notification when in edit mode", async () => {
|
||||
const notificationId = 42;
|
||||
// Setup fetchMock for API call
|
||||
fetchMock.putOnce(`path:/api/notification/${notificationId}`, {
|
||||
body: { id: notificationId },
|
||||
});
|
||||
|
||||
const onNotificationUpdatedMock = jest.fn();
|
||||
|
||||
// Create a notification with 'row created' event
|
||||
const mockNotification = createMockTableNotification({
|
||||
id: notificationId,
|
||||
event_name: "event/row.created",
|
||||
});
|
||||
|
||||
setup({
|
||||
isAdmin: true,
|
||||
isEmailSetup: true,
|
||||
editingNotification: mockNotification,
|
||||
onNotificationUpdatedMock,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Edit alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change event from 'row created' to 'row updated'
|
||||
const triggerSelect = screen.getByTestId("notification-event-select");
|
||||
await userEvent.click(triggerSelect);
|
||||
|
||||
const updateOption = screen.getByRole("option", {
|
||||
name: /When any cell changes it's value/i,
|
||||
});
|
||||
await userEvent.click(updateOption);
|
||||
|
||||
// Click Save button
|
||||
const saveButton = screen.getByRole("button", { name: /save changes/i });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
// Verify the API was called with the updated event
|
||||
const calls = fetchMock.calls(`path:/api/notification/${notificationId}`);
|
||||
expect(calls.length).toBe(1);
|
||||
|
||||
await waitFor(async () => {
|
||||
const requestBody = await calls[0][1]?.body;
|
||||
const parsedBody = JSON.parse(requestBody as string);
|
||||
return parsedBody; // Return the parsed body for later assertions
|
||||
}).then((parsedBody) => {
|
||||
// Verify the event has been changed to 'row updated'
|
||||
expect(parsedBody.payload.event_name).toBe("event/row.updated");
|
||||
});
|
||||
|
||||
expect(onNotificationUpdatedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to create a mock table notification
|
||||
function createMockTableNotification({
|
||||
id = 123,
|
||||
event_name = "event/row.created",
|
||||
table_id = 42,
|
||||
user_id = 1,
|
||||
}: {
|
||||
id?: number;
|
||||
event_name?: NotificationTriggerEvent;
|
||||
table_id?: number;
|
||||
user_id?: number;
|
||||
}): TableNotification {
|
||||
const user = createMockUser({ id: user_id });
|
||||
return {
|
||||
id,
|
||||
active: true,
|
||||
creator_id: user_id,
|
||||
creator: user,
|
||||
handlers: [
|
||||
createMockNotificationHandlerEmail({
|
||||
recipients: [
|
||||
createMockNotificationRecipientUser({
|
||||
user_id,
|
||||
user,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
created_at: "2025-01-07T12:00:00Z",
|
||||
updated_at: "2025-01-07T12:00:00Z",
|
||||
payload_type: "notification/system-event",
|
||||
payload: {
|
||||
event_name,
|
||||
table_id,
|
||||
},
|
||||
payload_id: null,
|
||||
condition: ["=", ["field", "id"], 1],
|
||||
};
|
||||
}
|
||||
|
||||
function setup({
|
||||
userCanAccessSettings = false,
|
||||
isAdmin = false,
|
||||
isEmailSetup = true,
|
||||
isSlackSetup = false,
|
||||
isHttpSetup = false,
|
||||
webhooksResult = [],
|
||||
editingNotification,
|
||||
onNotificationCreatedMock = jest.fn(),
|
||||
onNotificationUpdatedMock = jest.fn(),
|
||||
}: {
|
||||
userCanAccessSettings?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isEmailSetup?: boolean;
|
||||
isSlackSetup?: boolean;
|
||||
isHttpSetup?: boolean;
|
||||
webhooksResult?: NotificationChannel[];
|
||||
editingNotification?: TableNotification;
|
||||
onNotificationCreatedMock?: jest.Mock;
|
||||
onNotificationUpdatedMock?: jest.Mock;
|
||||
}) {
|
||||
const settings = mockSettings({
|
||||
"token-features": createMockTokenFeatures({
|
||||
advanced_permissions: true,
|
||||
}),
|
||||
});
|
||||
|
||||
setupEnterprisePlugins();
|
||||
|
||||
setupNotificationChannelsEndpoints({
|
||||
slack: { configured: isSlackSetup },
|
||||
email: { configured: isEmailSetup },
|
||||
http: { configured: isHttpSetup },
|
||||
} as ChannelApiResponse["channels"]);
|
||||
|
||||
setupWebhookChannelsEndpoint(webhooksResult);
|
||||
setupUserRecipientsEndpoint({ users: [] });
|
||||
|
||||
const currentUser = createMockUser(
|
||||
isAdmin ? { is_superuser: true } : undefined,
|
||||
);
|
||||
|
||||
if (userCanAccessSettings) {
|
||||
(currentUser as UserWithApplicationPermissions).permissions = {
|
||||
can_access_setting: true,
|
||||
can_access_monitoring: false,
|
||||
can_access_subscription: false,
|
||||
};
|
||||
}
|
||||
|
||||
const database = createSampleDatabase();
|
||||
const _table = createMockTable({ id: 42 });
|
||||
|
||||
return renderWithProviders(
|
||||
<CreateOrEditTableNotificationModal
|
||||
tableId={_table.id}
|
||||
notification={editingNotification ?? null}
|
||||
onClose={jest.fn()}
|
||||
onNotificationCreated={onNotificationCreatedMock}
|
||||
onNotificationUpdated={onNotificationUpdatedMock}
|
||||
/>,
|
||||
{
|
||||
storeInitialState: {
|
||||
currentUser,
|
||||
settings,
|
||||
entities: createMockEntitiesState({
|
||||
databases: [database],
|
||||
tables: [_table],
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
useGetTableDataQuery,
|
||||
useGetTableQueryMetadataQuery,
|
||||
} from "metabase/api";
|
||||
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
|
||||
import { getBaseCondition } from "metabase/notifications/utils";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Icon,
|
||||
Menu,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
rem,
|
||||
} from "metabase/ui";
|
||||
import { EditingBodyCellConditional } from "metabase-enterprise/data_editing/tables/edit/inputs";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
ConditionalAlertExpression,
|
||||
FieldId,
|
||||
FieldWithMetadata,
|
||||
Literal,
|
||||
LogicalOperator,
|
||||
MultipleConditionalAlertExpressions,
|
||||
NotificationTriggerEvent,
|
||||
SingleConditionalAlertExpression,
|
||||
TableId,
|
||||
} from "metabase-types/api";
|
||||
import type { DatasetColumn } from "metabase-types/api/dataset";
|
||||
|
||||
// Types for the condition builder UI
|
||||
type ColumnType =
|
||||
| "type/Text"
|
||||
| "type/Integer"
|
||||
| "type/BigInteger"
|
||||
| "type/Float"
|
||||
| "type/Boolean"
|
||||
| "type/Date"
|
||||
| "type/DateTime"
|
||||
| "type/DateTimeWithLocalTZ";
|
||||
|
||||
type ConditionBuilderColumn = DatasetColumn & {
|
||||
field: FieldWithMetadata;
|
||||
isNumeric: boolean;
|
||||
isBoolean: boolean;
|
||||
isStringOrStringLike: boolean;
|
||||
};
|
||||
|
||||
const SUPPORTED_TYPES = [
|
||||
"type/PK",
|
||||
"type/Text",
|
||||
"type/Integer",
|
||||
"type/BigInteger",
|
||||
"type/Float",
|
||||
"type/Boolean",
|
||||
];
|
||||
|
||||
interface OperatorOption {
|
||||
value: ComparisonOperator;
|
||||
label: string;
|
||||
applicableTypes: ColumnType[];
|
||||
}
|
||||
|
||||
interface ConditionRow {
|
||||
id: string;
|
||||
columnId: FieldId | null;
|
||||
columnName: string | null;
|
||||
operator: ComparisonOperator | null;
|
||||
value: Literal;
|
||||
}
|
||||
|
||||
interface AlertConditionBuilderProps {
|
||||
tableId: TableId;
|
||||
eventType: NotificationTriggerEvent;
|
||||
onChange?: (expression: ConditionalAlertExpression) => void;
|
||||
initialExpression?: ConditionalAlertExpression;
|
||||
}
|
||||
|
||||
// Define operators outside of component to avoid recreation on each render
|
||||
const OPERATORS: OperatorOption[] = [
|
||||
{
|
||||
value: "=",
|
||||
label: "Equal to",
|
||||
applicableTypes: [
|
||||
"type/Text",
|
||||
"type/Integer",
|
||||
"type/BigInteger",
|
||||
"type/Float",
|
||||
"type/Boolean",
|
||||
"type/Date",
|
||||
"type/DateTime",
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "!=",
|
||||
label: "Not equal to",
|
||||
applicableTypes: [
|
||||
"type/Text",
|
||||
"type/Integer",
|
||||
"type/BigInteger",
|
||||
"type/Float",
|
||||
"type/Boolean",
|
||||
"type/Date",
|
||||
"type/DateTime",
|
||||
],
|
||||
},
|
||||
{
|
||||
value: ">",
|
||||
label: "Higher than",
|
||||
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
|
||||
},
|
||||
{
|
||||
value: "<",
|
||||
label: "Lower than",
|
||||
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
|
||||
},
|
||||
{
|
||||
value: ">=",
|
||||
label: "Greater than or equal to",
|
||||
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
|
||||
},
|
||||
{
|
||||
value: "<=",
|
||||
label: "Less than or equal to",
|
||||
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
|
||||
},
|
||||
];
|
||||
|
||||
// Component for a single condition row
|
||||
const ConditionRowComponent = ({
|
||||
condition,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
columns,
|
||||
}: {
|
||||
condition: ConditionRow;
|
||||
onUpdate: (id: string, updates: Partial<ConditionRow>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
columns: Array<ConditionBuilderColumn>;
|
||||
}) => {
|
||||
const selectedColumn = condition.columnId
|
||||
? columns.find((col) => col.id === Number(condition.columnId))
|
||||
: columns[0];
|
||||
|
||||
const columnType = selectedColumn?.effective_type as ColumnType | undefined;
|
||||
// Filter operators based on column type
|
||||
|
||||
// Get operators based on column type, or all operators if no column selected
|
||||
const filteredOperators = columnType
|
||||
? OPERATORS.filter((op: OperatorOption) =>
|
||||
op.applicableTypes.includes(columnType),
|
||||
)
|
||||
: OPERATORS;
|
||||
|
||||
// Helper to get a default value based on column type
|
||||
const getDefaultValueForType = (columnType: ColumnType | undefined): any => {
|
||||
if (!columnType) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case columnType.includes("Integer"):
|
||||
case columnType.includes("Float"):
|
||||
case columnType.includes("Decimal"):
|
||||
return 0;
|
||||
case columnType.includes("Boolean"):
|
||||
return true;
|
||||
case columnType.includes("Date"):
|
||||
case columnType.includes("Time"):
|
||||
return new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
case columnType.includes("Text"):
|
||||
return "";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnChange = (columnId: FieldId | null) => {
|
||||
// When a column is selected, automatically select the first applicable operator and set default value
|
||||
if (columnId) {
|
||||
const selectedColumn = columns.find((col) => col.id === Number(columnId));
|
||||
const columnType = selectedColumn?.effective_type as
|
||||
| ColumnType
|
||||
| undefined;
|
||||
|
||||
if (columnType) {
|
||||
const applicableOperators = OPERATORS.filter((op) =>
|
||||
op.applicableTypes.includes(columnType),
|
||||
);
|
||||
|
||||
if (applicableOperators.length > 0) {
|
||||
const defaultValue = getDefaultValueForType(columnType);
|
||||
|
||||
onUpdate(condition.id, {
|
||||
columnId,
|
||||
columnName: selectedColumn?.name || null,
|
||||
operator: applicableOperators[0].value as ComparisonOperator,
|
||||
value: defaultValue,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no column selected or no applicable operators found
|
||||
onUpdate(condition.id, {
|
||||
columnId: null,
|
||||
columnName: null,
|
||||
operator: null,
|
||||
value: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOperatorChange = (operator: ComparisonOperator | null) => {
|
||||
onUpdate(condition.id, { operator });
|
||||
};
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
const parsedValue = parseLiteral(value, selectedColumn);
|
||||
onUpdate(condition.id, { value: parsedValue });
|
||||
};
|
||||
|
||||
// Helper function to get operator icon/symbol
|
||||
const getOperatorIcon = (operator: ComparisonOperator | null) => {
|
||||
switch (operator) {
|
||||
case "=":
|
||||
return "=";
|
||||
case "!=":
|
||||
return "≠";
|
||||
case ">":
|
||||
return ">";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">=":
|
||||
return "≥";
|
||||
case "<=":
|
||||
return "≤";
|
||||
default:
|
||||
return "=";
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare UI for the condition row
|
||||
return (
|
||||
<Flex gap="xs" align="center" w="100%">
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 32px 1fr",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
data={columns.map((col) => ({
|
||||
value: col.id?.toString() || "",
|
||||
label: col.display_name,
|
||||
icon: getColumnIcon(col),
|
||||
}))}
|
||||
value={condition.columnId?.toString() || ""}
|
||||
onChange={(newValue) => handleColumnChange(Number(newValue))}
|
||||
placeholder={t`Select column`}
|
||||
searchable
|
||||
clearable
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
|
||||
{selectedColumn && (
|
||||
<>
|
||||
<Menu position="bottom" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
fz="lg"
|
||||
fw={500}
|
||||
>
|
||||
<Text>{getOperatorIcon(condition.operator)}</Text>
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{filteredOperators.map((op: OperatorOption) => (
|
||||
<Menu.Item
|
||||
key={op.value}
|
||||
onClick={() => handleOperatorChange(op.value)}
|
||||
leftSection={
|
||||
<Text fw={600}>
|
||||
{getOperatorIcon(op.value as ComparisonOperator)}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{op.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<EditingBodyCellConditional
|
||||
field={selectedColumn?.field}
|
||||
datasetColumn={selectedColumn}
|
||||
initialValue={condition.value}
|
||||
onSubmit={(value) => handleValueChange(value as string)}
|
||||
onCancel={() => {}}
|
||||
inputProps={{ disabled: false, placeholder: t`Select value` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => onRemove(condition.id)}
|
||||
p={0}
|
||||
style={{
|
||||
width: "2rem",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
styles={{ label: { display: "flex" } }}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertConditionBuilder = ({
|
||||
tableId,
|
||||
eventType,
|
||||
onChange,
|
||||
initialExpression,
|
||||
}: AlertConditionBuilderProps) => {
|
||||
const { data: tableMetadata } = useGetTableQueryMetadataQuery({
|
||||
id: tableId,
|
||||
});
|
||||
const { data: datasetData } = useGetTableDataQuery({
|
||||
tableId,
|
||||
});
|
||||
|
||||
const [areConditionsEnabled, setConditionsEnabled] = useState(false);
|
||||
|
||||
// Convert table fields to column options
|
||||
const columns = useMemo(() => {
|
||||
return (datasetData?.data?.cols || [])
|
||||
.map((col) => {
|
||||
const field = tableMetadata?.fields?.find((f) => f.name === col.name);
|
||||
const typeInfo = Lib.legacyColumnTypeInfo(col);
|
||||
return {
|
||||
...col,
|
||||
field,
|
||||
isNumeric: Lib.isNumeric(typeInfo),
|
||||
isStringOrStringLike: Lib.isStringOrStringLike(typeInfo),
|
||||
isBoolean: Lib.isBoolean(typeInfo),
|
||||
} as ConditionBuilderColumn;
|
||||
})
|
||||
.filter(
|
||||
(col) =>
|
||||
col.visibility_type === "normal" &&
|
||||
(SUPPORTED_TYPES.includes(col.field?.semantic_type as string) ||
|
||||
SUPPORTED_TYPES.includes(col.field?.effective_type as ColumnType)),
|
||||
);
|
||||
}, [datasetData?.data?.cols, tableMetadata?.fields]);
|
||||
|
||||
// Initialize conditions from initialExpression or with a single empty condition
|
||||
const [conditions, setConditions] = useState<ConditionRow[]>([]);
|
||||
const [logicalOperator, setLogicalOperator] =
|
||||
useState<LogicalOperator>("and");
|
||||
|
||||
const populateInitialState = useCallback(() => {
|
||||
const {
|
||||
conditions: initialConditions,
|
||||
logicalOperator: initialLogicalOperator,
|
||||
} = parseInitialExpression(initialExpression, columns);
|
||||
setConditions(initialConditions);
|
||||
setLogicalOperator(initialLogicalOperator);
|
||||
}, [initialExpression, columns]);
|
||||
|
||||
// Initial state population.
|
||||
useEffect(() => {
|
||||
if (!initialExpression) {
|
||||
return;
|
||||
}
|
||||
populateInitialState();
|
||||
if (doesExpressionHaveClientSideConditions(initialExpression)) {
|
||||
setConditionsEnabled(true);
|
||||
}
|
||||
}, [populateInitialState, initialExpression]);
|
||||
|
||||
// Handle adding a new condition
|
||||
// const handleAddCondition = () => {
|
||||
// setConditions([
|
||||
// ...conditions,
|
||||
// {
|
||||
// id: crypto.randomUUID(),
|
||||
// columnId: null,
|
||||
// columnName: null,
|
||||
// operator: null,
|
||||
// value: null,
|
||||
// },
|
||||
// ]);
|
||||
// };
|
||||
|
||||
// Handle updating a condition
|
||||
const handleUpdateCondition = useCallback(
|
||||
(id: string, updates: Partial<ConditionRow>) => {
|
||||
setConditions((conditions) =>
|
||||
conditions.map((condition) =>
|
||||
condition.id === id ? { ...condition, ...updates } : condition,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle removing a condition
|
||||
const handleRemoveCondition = (id: string) => {
|
||||
// TODO: Consider running onChange here.
|
||||
const newConditions = conditions.filter((condition) => condition.id !== id);
|
||||
setConditions(newConditions);
|
||||
if (newConditions.length === 0) {
|
||||
// onChange?.(null);
|
||||
setConditionsEnabled(false);
|
||||
}
|
||||
// } else {
|
||||
// onChange?.(generateExpression(tableId, newConditions, logicalOperator));
|
||||
// }
|
||||
};
|
||||
|
||||
// Handle changing the logical operator
|
||||
const handleOperatorChange = useCallback((value: string | null) => {
|
||||
if (value === "and" || value === "or") {
|
||||
setLogicalOperator(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const expression = generateExpression(
|
||||
tableId,
|
||||
eventType,
|
||||
conditions,
|
||||
logicalOperator,
|
||||
);
|
||||
onChange?.(expression);
|
||||
}, [
|
||||
initialExpression,
|
||||
conditions,
|
||||
logicalOperator,
|
||||
onChange,
|
||||
tableId,
|
||||
eventType,
|
||||
]);
|
||||
|
||||
if (!areConditionsEnabled) {
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
p={0}
|
||||
onClick={() => {
|
||||
populateInitialState();
|
||||
setConditionsEnabled(true);
|
||||
}}
|
||||
>
|
||||
{t`Add conditions for alerts`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm" w="100%">
|
||||
<Text size="lg" style={{ marginRight: "auto" }}>
|
||||
{t`Notify if the following condition is met`}
|
||||
</Text>
|
||||
{conditions.length > 1 && (
|
||||
<Flex align="center">
|
||||
<Select
|
||||
data={[
|
||||
{ value: "and", label: t`AND` },
|
||||
{ value: "or", label: t`OR` },
|
||||
]}
|
||||
value={logicalOperator}
|
||||
onChange={handleOperatorChange}
|
||||
style={{ width: rem(100) }}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
{conditions.map((condition) => (
|
||||
<ConditionRowComponent
|
||||
key={condition.id}
|
||||
condition={condition}
|
||||
onUpdate={handleUpdateCondition}
|
||||
onRemove={handleRemoveCondition}
|
||||
columns={columns}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{/* Multiple conditions support will be implemented later. */}
|
||||
{/* <Button
|
||||
variant="outline"
|
||||
leftSection={<Icon name="add" />}
|
||||
onClick={handleAddCondition}
|
||||
mt="sm"
|
||||
>
|
||||
{t`Add condition`}
|
||||
</Button> */}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// Parse initial expression into condition rows
|
||||
function parseInitialExpression(
|
||||
initialExpression: ConditionalAlertExpression | undefined,
|
||||
columns: ConditionBuilderColumn[],
|
||||
): { conditions: ConditionRow[]; logicalOperator: LogicalOperator } {
|
||||
const defaultCondition = {
|
||||
conditions: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
columnId: null,
|
||||
columnName: null,
|
||||
operator: null,
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
logicalOperator: "and" as LogicalOperator,
|
||||
};
|
||||
|
||||
// As a temporary solution, we need to hide an important implementation detail
|
||||
// from the client.
|
||||
// Currently, each condition has a required table_id comparison at the top level.
|
||||
// Eg.: '["=",["context","table_id"],8]'.
|
||||
// But it should not be exposed to the Condition Builder component, user cannot affect it.
|
||||
// An example of an actual conditional expression in that case would look like this:
|
||||
// Eg.: '["and",["=",["context","table_id"],8],["=",["context","PAYMENT"],0]]'
|
||||
// So we need to omit the table_id condition, and move directly to the 2nd nesting level,
|
||||
// before parsing the expression.
|
||||
|
||||
const clientSideCondition = doesExpressionHaveClientSideConditions(
|
||||
initialExpression,
|
||||
)
|
||||
? initialExpression[3]
|
||||
: null;
|
||||
|
||||
if (!clientSideCondition) {
|
||||
// If no initial expression, start with a single empty condition
|
||||
return defaultCondition;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if it's a single condition or multiple conditions
|
||||
if (Array.isArray(clientSideCondition) && clientSideCondition.length > 0) {
|
||||
const firstElement = clientSideCondition[0];
|
||||
|
||||
// Case 1: Single condition - ["=", ["context", "row_change", "before", "field_name"], value]
|
||||
if (
|
||||
typeof firstElement === "string" &&
|
||||
["=", "!=", ">", "<", ">=", "<="].includes(firstElement)
|
||||
) {
|
||||
const operator = firstElement as ComparisonOperator;
|
||||
const fieldRef = clientSideCondition[1];
|
||||
const value = clientSideCondition[2] as any;
|
||||
|
||||
if (
|
||||
Array.isArray(fieldRef) &&
|
||||
fieldRef[0] === "context" &&
|
||||
fieldRef.length > 1
|
||||
) {
|
||||
const columnName = fieldRef[2] as string;
|
||||
|
||||
return {
|
||||
conditions: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
columnId:
|
||||
columns.find((col) => col.name === columnName)?.id || null,
|
||||
columnName,
|
||||
operator,
|
||||
value,
|
||||
},
|
||||
],
|
||||
logicalOperator: "and" as LogicalOperator,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Multiple conditions - ["and"/"or", [...condition1], [...condition2], ...]
|
||||
if (
|
||||
typeof firstElement === "string" &&
|
||||
["and", "or"].includes(firstElement)
|
||||
) {
|
||||
const logicalOperator = firstElement as LogicalOperator;
|
||||
const parsedConditions: ConditionRow[] = [];
|
||||
|
||||
// Process each condition expression
|
||||
for (let i = 1; i < clientSideCondition.length; i++) {
|
||||
const conditionExpr = clientSideCondition[i];
|
||||
|
||||
if (Array.isArray(conditionExpr) && conditionExpr.length >= 3) {
|
||||
const operator = conditionExpr[0] as ComparisonOperator;
|
||||
const fieldRef = conditionExpr[1];
|
||||
const value = conditionExpr[2] as any;
|
||||
|
||||
if (
|
||||
Array.isArray(fieldRef) &&
|
||||
fieldRef[0] === "context" &&
|
||||
fieldRef.length > 1
|
||||
) {
|
||||
const columnName = fieldRef[2] as string;
|
||||
|
||||
parsedConditions.push({
|
||||
id: crypto.randomUUID(),
|
||||
columnId:
|
||||
columns.find((col) => col.name === columnName)?.id || null,
|
||||
columnName,
|
||||
operator,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedConditions.length > 0) {
|
||||
return {
|
||||
conditions: parsedConditions,
|
||||
logicalOperator,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing initial expression:", error);
|
||||
}
|
||||
|
||||
// Fallback to default if parsing fails
|
||||
return defaultCondition;
|
||||
}
|
||||
|
||||
// Generate the expression based on current conditions
|
||||
function generateExpression(
|
||||
tableId: TableId,
|
||||
eventType: NotificationTriggerEvent,
|
||||
conditions?: ConditionRow[],
|
||||
logicalOperator?: LogicalOperator,
|
||||
): ConditionalAlertExpression {
|
||||
const baseExpression = getBaseCondition(tableId, eventType);
|
||||
|
||||
if (!conditions) {
|
||||
return baseExpression;
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(
|
||||
(c) =>
|
||||
c.columnId && c.operator && c.value !== null && c.value !== undefined,
|
||||
);
|
||||
|
||||
if (validConditions.length === 0) {
|
||||
return baseExpression;
|
||||
}
|
||||
|
||||
// TODO: This is a temprorary workaround until we have a better abstraction
|
||||
// on the API side to specify conditions for different event types.
|
||||
// Basically, the meaningful payload of the event can have different paths.
|
||||
// For 'create' and 'update' event, it will be under 'after' key.
|
||||
// For 'delete' event, it will be under 'before' key.
|
||||
|
||||
const payloadPath = ["context", "record"];
|
||||
|
||||
if (validConditions.length === 1) {
|
||||
const condition = validConditions[0];
|
||||
|
||||
return [
|
||||
...baseExpression,
|
||||
[
|
||||
condition.operator as ComparisonOperator,
|
||||
[...payloadPath, condition.columnName],
|
||||
condition.value,
|
||||
] as SingleConditionalAlertExpression,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...baseExpression,
|
||||
[
|
||||
logicalOperator as LogicalOperator,
|
||||
...validConditions.map((condition) => [
|
||||
condition.operator,
|
||||
[...payloadPath, condition.columnName],
|
||||
condition.value,
|
||||
]),
|
||||
] as MultipleConditionalAlertExpressions,
|
||||
];
|
||||
}
|
||||
|
||||
function getColumnIcon(column: ConditionBuilderColumn) {
|
||||
if (column.isBoolean) {
|
||||
return "io";
|
||||
}
|
||||
|
||||
const semanticTypeIcon =
|
||||
FIELD_SEMANTIC_TYPES_MAP[
|
||||
(column.semantic_type ||
|
||||
column.effective_type ||
|
||||
column.base_type) as string
|
||||
]?.icon;
|
||||
if (semanticTypeIcon) {
|
||||
return semanticTypeIcon;
|
||||
}
|
||||
|
||||
if (column.isNumeric) {
|
||||
return "int";
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
||||
function doesExpressionHaveClientSideConditions(
|
||||
expression?: ConditionalAlertExpression,
|
||||
): expression is ConditionalAlertExpression {
|
||||
return Array.isArray(expression) && Array.isArray(expression[3]);
|
||||
}
|
||||
|
||||
function parseLiteral(
|
||||
value: string,
|
||||
selectedColumn: ConditionBuilderColumn | undefined,
|
||||
) {
|
||||
if (!selectedColumn) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (selectedColumn.isBoolean) {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
if (selectedColumn.isNumeric) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: calc(100% - 5.5rem);
|
||||
min-height: 0;
|
||||
width: 50%;
|
||||
border-left: 1px solid var(--mb-color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2.5rem;
|
||||
gap: var(--mantine-spacing-md);
|
||||
background: var(--mb-color-bg-white);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
height: 1.625rem;
|
||||
z-index: 1003;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
}
|
||||
|
||||
.closePreviewIcon {
|
||||
color: var(--mb-color-text-tertiary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollBox {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.previewStack {
|
||||
background: var(--mb-color-bg-white);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
.previewDetails {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.previewBody {
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Avatar } from "metabase/common/components/UserAvatar";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Flex,
|
||||
Icon,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "metabase/ui";
|
||||
import type { PreviewNotificationTemplateResponse } from "metabase-types/api";
|
||||
|
||||
import S from "./PreviewTemplatePanel.module.css";
|
||||
|
||||
interface PreviewTemplatePanelProps {
|
||||
onClose: () => void;
|
||||
isLoading: boolean;
|
||||
error: any;
|
||||
previewContent?: PreviewNotificationTemplateResponse["rendered"];
|
||||
}
|
||||
|
||||
export const PreviewTemplatePanel = ({
|
||||
onClose,
|
||||
isLoading,
|
||||
error,
|
||||
previewContent,
|
||||
}: PreviewTemplatePanelProps) => {
|
||||
const htmlContent = previewContent?.body?.[0]?.content;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
mah="100%"
|
||||
gap="md"
|
||||
className={S.root}
|
||||
data-testid="preview-template-panel"
|
||||
>
|
||||
<Flex gap="sm" align="center" h="1.625rem" className={S.header}>
|
||||
<Icon name="eye" size={16} />
|
||||
<Title size="h4">{t`Email message preview`}</Title>
|
||||
<Tooltip label={t`Close Preview`}>
|
||||
<ActionIcon variant="transparent" ml="auto">
|
||||
<Icon
|
||||
name="arrow_left_to_line"
|
||||
size={20}
|
||||
onClick={onClose}
|
||||
className={S.closePreviewIcon}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Box p={0} className={S.scrollBox}>
|
||||
{isLoading && (
|
||||
<Flex align="center" justify="center" gap="sm" py="md">
|
||||
<Loader size={12} />
|
||||
<Text
|
||||
size="lg"
|
||||
lh="1.25rem"
|
||||
c="text-medium"
|
||||
>{t`Loading preview...`}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{error && (
|
||||
<code style={{ color: "var(--mb-color-error)" }}>{error}</code>
|
||||
)}
|
||||
{previewContent && !error ? (
|
||||
<Stack className={S.previewStack}>
|
||||
<Box className={S.previewHeader}>
|
||||
<Avatar>{previewContent.from || "?"}</Avatar>
|
||||
|
||||
<Box className={S.previewDetails}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t`From:`}
|
||||
</Text>
|
||||
<Text size="sm">{previewContent.from}</Text>
|
||||
|
||||
{previewContent.bcc && previewContent.bcc.length > 0 && (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{t`BCC:`}
|
||||
</Text>
|
||||
<Text size="sm">{previewContent.bcc.join(", ")}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={600}>
|
||||
{t`Subject:`}
|
||||
</Text>
|
||||
<Text size="sm">{previewContent.subject}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{htmlContent ? (
|
||||
<div
|
||||
className={S.previewBody}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
) : (
|
||||
<Text c="text-medium">{t`(No body content)`}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
!isLoading &&
|
||||
!error && <Text c="text-medium">{t`No preview available.`}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateOrEditTableNotificationModal } from "./CreateOrEditTableNotificationModal";
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { NotificationTriggerEvent } from "metabase-types/api";
|
||||
|
||||
export type TableNotificationTriggerOption = {
|
||||
value: NotificationTriggerEvent;
|
||||
label: string;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
.notificationListItem {
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--mb-color-border);
|
||||
|
||||
.itemTitle {
|
||||
line-height: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.notificationListItemEditable {
|
||||
&:hover {
|
||||
background: var(--mb-base-color-brand-10);
|
||||
cursor: pointer;
|
||||
border-color: transparent;
|
||||
|
||||
.itemTitle {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
.handlersContainer {
|
||||
color: var(--mb-color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionButtonContainer {
|
||||
position: absolute;
|
||||
|
||||
/* 1rem - 0.6875rem (small icon button paddings) */
|
||||
right: 0.3125rem;
|
||||
top: 0.3125rem;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import cx from "classnames";
|
||||
import { type MouseEvent, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { formatCreatorMessage } from "metabase/account/notifications/components/NotificationCard/utils";
|
||||
import { getNotificationHandlersGroupedByTypes } from "metabase/lib/notifications";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import {
|
||||
HandlersInfo,
|
||||
NotificationActionButton,
|
||||
} from "metabase/notifications/modals/shared/components";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
import { Box, Group, Text } from "metabase/ui";
|
||||
import type {
|
||||
NotificationChannel,
|
||||
NotificationTriggerEvent,
|
||||
TableNotification,
|
||||
User,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import S from "./TableNotificationsListItem.module.css";
|
||||
|
||||
type TableNotificationsListItemProps = {
|
||||
notification: TableNotification;
|
||||
canEdit: boolean;
|
||||
users: User[] | undefined;
|
||||
httpChannelsConfig: NotificationChannel[] | undefined;
|
||||
onEdit: (notification: TableNotification) => void;
|
||||
onUnsubscribe: (notification: TableNotification) => void;
|
||||
onDelete: (notification: TableNotification) => void;
|
||||
};
|
||||
|
||||
export const TableNotificationsListItem = ({
|
||||
notification,
|
||||
canEdit,
|
||||
users,
|
||||
httpChannelsConfig,
|
||||
onEdit,
|
||||
onUnsubscribe,
|
||||
onDelete,
|
||||
}: TableNotificationsListItemProps) => {
|
||||
const user = useSelector(getUser);
|
||||
|
||||
const [showHoverActions, setShowHoverActions] = useState(false);
|
||||
|
||||
const { emailHandler, slackHandler, hookHandlers } =
|
||||
getNotificationHandlersGroupedByTypes(notification.handlers);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (canEdit) {
|
||||
onEdit(notification);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsubscribe = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onUnsubscribe(notification);
|
||||
};
|
||||
|
||||
const handleDelete = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onDelete(notification);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setShowHoverActions(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowHoverActions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={cx(
|
||||
S.notificationListItem,
|
||||
canEdit && S.notificationListItemEditable,
|
||||
)}
|
||||
p="1rem"
|
||||
onClick={handleEdit}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Text className={S.itemTitle} size="md" lineClamp={1} fw="bold">
|
||||
{formatTitle(notification.payload.event_name)}
|
||||
</Text>
|
||||
<Group gap="xs" align="center" c="text-secondary">
|
||||
{user && (
|
||||
<>
|
||||
<Text size="sm" c="text-light">
|
||||
•
|
||||
</Text>
|
||||
<Text size="sm" c="inherit">
|
||||
{formatCreatorMessage(notification, user?.id)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<HandlersInfo
|
||||
emailHandler={emailHandler}
|
||||
slackHandler={slackHandler}
|
||||
hookHandlers={hookHandlers}
|
||||
users={users}
|
||||
httpChannelsConfig={httpChannelsConfig}
|
||||
mt="1rem"
|
||||
/>
|
||||
|
||||
{showHoverActions && (
|
||||
<div className={S.actionButtonContainer}>
|
||||
{canEdit ? (
|
||||
<NotificationActionButton
|
||||
label={t`Delete this alert`}
|
||||
iconName="trash"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<NotificationActionButton
|
||||
label={t`Unsubscribe from this`}
|
||||
iconName="unsubscribe"
|
||||
onClick={handleUnsubscribe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTitle = (eventName: NotificationTriggerEvent): string => {
|
||||
switch (eventName) {
|
||||
case "event/row.created":
|
||||
return t`Notify when new records are created`;
|
||||
case "event/row.updated":
|
||||
return t`Notify when records are updated`;
|
||||
case "event/row.deleted":
|
||||
return t`Notify when records are deleted`;
|
||||
default:
|
||||
return t`Notification`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import type { StoryFn } from "@storybook/react";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
|
||||
import {
|
||||
createMockAlertNotification,
|
||||
createMockNotificationHandlerEmail,
|
||||
createMockNotificationHandlerSlack,
|
||||
} from "metabase-types/api/mocks/notification";
|
||||
|
||||
import { TableNotificationsListModal } from "./TableNotificationsListModal";
|
||||
|
||||
export default {
|
||||
title: "Notifications/TableNotificationsListModal",
|
||||
component: TableNotificationsListModal,
|
||||
};
|
||||
|
||||
const Template: StoryFn<ComponentProps<typeof TableNotificationsListModal>> = (
|
||||
args,
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<TableNotificationsListModal
|
||||
{...args}
|
||||
opened={isOpen}
|
||||
onClose={() => {
|
||||
args.onClose();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tableNotifications = [
|
||||
createMockAlertNotification({
|
||||
handlers: [
|
||||
createMockNotificationHandlerEmail(),
|
||||
createMockNotificationHandlerSlack(),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
export const Default = {
|
||||
render: Template,
|
||||
|
||||
args: {
|
||||
tableNotifications,
|
||||
onCreate: action("onCreate"),
|
||||
onEdit: action("onEdit"),
|
||||
onClose: action("onClose"),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useListChannelsQuery, useListUserRecipientsQuery } from "metabase/api";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import {
|
||||
canManageSubscriptions as canManageSubscriptionsSelector,
|
||||
getUser,
|
||||
} from "metabase/selectors/user";
|
||||
import { Button, Modal, type ModalProps, Stack, rem } from "metabase/ui";
|
||||
import type { TableNotification } from "metabase-types/api";
|
||||
|
||||
import { TableNotificationsListItem } from "./TableNotificationsListItem";
|
||||
|
||||
type TableNotificationsListModal = {
|
||||
notifications?: TableNotification[];
|
||||
opened: boolean;
|
||||
onCreate: () => void;
|
||||
onEdit: (notification: TableNotification) => void;
|
||||
onDelete: (notification: TableNotification) => void;
|
||||
onUnsubscribe: (notification: TableNotification) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const TableNotificationsListModal = ({
|
||||
notifications,
|
||||
opened,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUnsubscribe,
|
||||
onClose,
|
||||
...modalProps
|
||||
}: TableNotificationsListModal & ModalProps) => {
|
||||
const user = useSelector(getUser);
|
||||
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
|
||||
const isAdmin = user?.is_superuser;
|
||||
|
||||
const { data: users } = useListUserRecipientsQuery();
|
||||
const { data: httpChannelsConfig } = useListChannelsQuery();
|
||||
|
||||
if (!notifications) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCreatedByCurrentUser = (notification: TableNotification) => {
|
||||
return user ? notification.creator.id === user.id : false;
|
||||
};
|
||||
|
||||
const [ownNotifications, othersNotifications] = _.partition(
|
||||
notifications,
|
||||
isCreatedByCurrentUser,
|
||||
);
|
||||
|
||||
// user's own alert should be shown first if it exists
|
||||
const sortedNotifications = [...ownNotifications, ...othersNotifications];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
data-testid="alert-list-modal"
|
||||
opened={opened}
|
||||
size={rem(600)}
|
||||
onClose={onClose}
|
||||
padding="xl"
|
||||
title={t`Edit alerts`}
|
||||
{...modalProps}
|
||||
>
|
||||
<Stack gap="lg" mb="lg" mt="1rem">
|
||||
{sortedNotifications.map((notification) => {
|
||||
const canEditNotification =
|
||||
isAdmin ||
|
||||
(canManageSubscriptions && isCreatedByCurrentUser(notification));
|
||||
return (
|
||||
<TableNotificationsListItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
users={users?.data}
|
||||
httpChannelsConfig={httpChannelsConfig}
|
||||
canEdit={canEditNotification}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<div>
|
||||
<Button variant="filled" onClick={onCreate}>{t`New alert`}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { screen } from "__support__/ui";
|
||||
|
||||
import { createNotificationForUser, setup } from "./test-utils";
|
||||
|
||||
describe("TableNotificationsListModal", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should render modal with notification list", () => {
|
||||
const currentUserId = 1;
|
||||
const notifications = [
|
||||
createNotificationForUser(currentUserId),
|
||||
createNotificationForUser(2),
|
||||
];
|
||||
|
||||
setup({ notifications, currentUserId });
|
||||
|
||||
expect(screen.getByTestId("alert-list-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit alerts")).toBeInTheDocument();
|
||||
// The Box component in TableNotificationsListItem doesn't have a data-testid
|
||||
// Use getAllByText since we'll have multiple elements with the same text
|
||||
const notificationTitles = screen.getAllByText(
|
||||
"Notify when new records are created",
|
||||
);
|
||||
expect(notificationTitles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should display current user's notifications first", () => {
|
||||
const currentUserId = 1;
|
||||
const otherUserId = 2;
|
||||
|
||||
// Create notifications with different created_at timestamps to ensure
|
||||
// sort is by ownership, not time
|
||||
const userNotification = createNotificationForUser(currentUserId);
|
||||
userNotification.created_at = "2025-03-01T12:00:00.000Z";
|
||||
|
||||
const otherNotification = createNotificationForUser(otherUserId);
|
||||
otherNotification.created_at = "2025-03-27T12:00:00.000Z"; // more recent
|
||||
|
||||
setup({
|
||||
notifications: [otherNotification, userNotification],
|
||||
currentUserId,
|
||||
});
|
||||
|
||||
// We can determine the order of notifications by checking the creator message
|
||||
// which should contain info about the current user
|
||||
const creatorMessages = screen.getAllByText(/Created by/);
|
||||
expect(creatorMessages.length).toBe(2);
|
||||
|
||||
// The first message should be for the current user's notification
|
||||
expect(creatorMessages[0]).toHaveTextContent(/you/);
|
||||
});
|
||||
|
||||
it("should allow editing for admin users regardless of ownership", () => {
|
||||
const currentUserId = 1;
|
||||
const otherUserId = 2;
|
||||
const notifications = [
|
||||
createNotificationForUser(currentUserId),
|
||||
createNotificationForUser(otherUserId),
|
||||
];
|
||||
|
||||
setup({
|
||||
notifications,
|
||||
currentUserId,
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// Test that we can click on both notification titles (admin can edit all)
|
||||
const notificationItems = screen.getAllByText(
|
||||
"Notify when new records are created",
|
||||
);
|
||||
expect(notificationItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should allow editing only user's own notifications for non-admin users with subscription permissions", () => {
|
||||
const currentUserId = 1;
|
||||
const otherUserId = 2;
|
||||
const notifications = [
|
||||
createNotificationForUser(currentUserId),
|
||||
createNotificationForUser(otherUserId),
|
||||
];
|
||||
|
||||
setup({
|
||||
notifications,
|
||||
currentUserId,
|
||||
isAdmin: false,
|
||||
canManageSubscriptions: true,
|
||||
});
|
||||
|
||||
// Get all title elements for notifications
|
||||
const titles = screen.getAllByText(/Notify when/);
|
||||
expect(titles).toHaveLength(2);
|
||||
|
||||
// We can't easily test hover interactions in JSDOM
|
||||
// Instead, let's verify that user with permissions can see the creator info for both items
|
||||
const creatorInfoTexts = screen.getAllByText(/Created by/);
|
||||
expect(creatorInfoTexts).toHaveLength(2);
|
||||
|
||||
// First should be from the current user (shown as 'you')
|
||||
expect(creatorInfoTexts[0]).toHaveTextContent(/you/);
|
||||
});
|
||||
|
||||
it("should not allow editing any notifications for users without permissions", () => {
|
||||
const currentUserId = 1;
|
||||
const notifications = [
|
||||
createNotificationForUser(currentUserId),
|
||||
createNotificationForUser(2),
|
||||
];
|
||||
|
||||
setup({
|
||||
notifications,
|
||||
currentUserId,
|
||||
isAdmin: false,
|
||||
canManageSubscriptions: false,
|
||||
});
|
||||
|
||||
expect(screen.queryAllByLabelText("pencil icon")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should call onCreate when 'New alert' button is clicked", async () => {
|
||||
const onCreate = jest.fn();
|
||||
|
||||
setup({ onCreate });
|
||||
|
||||
const createButton = screen.getByText("New alert");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onEdit when edit button is clicked", async () => {
|
||||
const currentUserId = 1;
|
||||
const notification = createNotificationForUser(currentUserId, 0, {
|
||||
payload: {
|
||||
event_name: "event/row.updated",
|
||||
table_id: 1,
|
||||
},
|
||||
});
|
||||
const onEdit = jest.fn();
|
||||
|
||||
setup({
|
||||
notifications: [notification],
|
||||
currentUserId,
|
||||
isAdmin: true,
|
||||
onEdit,
|
||||
});
|
||||
|
||||
// Clicking on the notification item itself triggers edit
|
||||
// We'll click directly on the title which is part of the notification item
|
||||
const notificationTitle = screen.getByText(
|
||||
"Notify when records are updated",
|
||||
);
|
||||
await userEvent.click(notificationTitle);
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it("should call onDelete when delete button is clicked", async () => {
|
||||
const currentUserId = 1;
|
||||
const notification = createNotificationForUser(currentUserId, 0, {
|
||||
payload: {
|
||||
event_name: "event/row.deleted",
|
||||
table_id: 1,
|
||||
},
|
||||
});
|
||||
const onDelete = jest.fn();
|
||||
|
||||
setup({
|
||||
notifications: [notification],
|
||||
currentUserId,
|
||||
isAdmin: true,
|
||||
onDelete,
|
||||
});
|
||||
|
||||
// Get the notification title and test that it's rendered
|
||||
const notificationTitle = screen.getByText(
|
||||
"Notify when records are deleted",
|
||||
);
|
||||
expect(notificationTitle).toBeInTheDocument();
|
||||
|
||||
// Find the containing notification item (we're looking at structure not implementation)
|
||||
// and hover over it
|
||||
await userEvent.hover(notificationTitle);
|
||||
|
||||
// Now find and click the delete button (trash icon)
|
||||
const deleteButton = await screen.findByLabelText("Delete this alert");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Verify onDelete was called with the notification
|
||||
expect(onDelete).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it("should call onUnsubscribe when unsubscribe button is clicked", async () => {
|
||||
const currentUserId = 1;
|
||||
const otherUserId = 2; // Use another user's notification
|
||||
const notification = createNotificationForUser(otherUserId);
|
||||
const onUnsubscribe = jest.fn();
|
||||
|
||||
// Set up with non-admin user who can't edit others' notifications
|
||||
setup({
|
||||
notifications: [notification],
|
||||
currentUserId,
|
||||
isAdmin: false,
|
||||
canManageSubscriptions: false,
|
||||
onUnsubscribe,
|
||||
});
|
||||
|
||||
// Verify the notification is rendered
|
||||
const notificationTitle = screen.getByText(
|
||||
"Notify when new records are created",
|
||||
);
|
||||
expect(notificationTitle).toBeInTheDocument();
|
||||
|
||||
// Hover over the notification title to trigger the hover state on the container
|
||||
await userEvent.hover(notificationTitle);
|
||||
|
||||
// Now find and click the unsubscribe button
|
||||
const unsubscribeButton = await screen.findByLabelText(
|
||||
"Unsubscribe from this",
|
||||
);
|
||||
await userEvent.click(unsubscribeButton);
|
||||
|
||||
// Verify onUnsubscribe was called with the notification
|
||||
expect(onUnsubscribe).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it("should call onClose when modal is closed", async () => {
|
||||
const onClose = jest.fn();
|
||||
|
||||
setup({ onClose });
|
||||
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle case with no notifications", () => {
|
||||
setup({ notifications: [] });
|
||||
|
||||
// There should be no notification titles when the list is empty
|
||||
expect(screen.queryAllByText(/Notify when/)).toHaveLength(0);
|
||||
expect(screen.getByText("New alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render null if notifications is undefined", () => {
|
||||
setup({ notifications: undefined, opened: false });
|
||||
|
||||
expect(screen.queryByTestId("alert-list-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { TableNotificationsListModal } from "./TableNotificationsListModal";
|
||||
@@ -0,0 +1,106 @@
|
||||
import { setupUserRecipientsEndpoint } from "__support__/server-mocks";
|
||||
import { setupWebhookChannelsEndpoint } from "__support__/server-mocks/channel";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { renderWithProviders } from "__support__/ui";
|
||||
import type { UserWithApplicationPermissions } from "metabase/plugins";
|
||||
import { TableNotificationsListModal } from "metabase-enterprise/data_editing/alerts/TableNotificationsModals/TableNotificationsListModal/TableNotificationsListModal";
|
||||
import type { TableNotification, User } from "metabase-types/api";
|
||||
import {
|
||||
createMockTableNotification,
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
export interface SetupOpts {
|
||||
notifications?: TableNotification[];
|
||||
isAdmin?: boolean;
|
||||
canManageSubscriptions?: boolean;
|
||||
currentUserId?: number;
|
||||
onCreate?: jest.Mock;
|
||||
onEdit?: jest.Mock;
|
||||
onDelete?: jest.Mock;
|
||||
onUnsubscribe?: jest.Mock;
|
||||
onClose?: jest.Mock;
|
||||
webhooksConfig?: any[];
|
||||
users?: User[];
|
||||
opened?: boolean;
|
||||
}
|
||||
|
||||
export const setup = ({
|
||||
notifications = [],
|
||||
isAdmin = false,
|
||||
canManageSubscriptions = false,
|
||||
currentUserId = 1,
|
||||
onCreate = jest.fn(),
|
||||
onEdit = jest.fn(),
|
||||
onDelete = jest.fn(),
|
||||
onUnsubscribe = jest.fn(),
|
||||
onClose = jest.fn(),
|
||||
webhooksConfig = [],
|
||||
users = [],
|
||||
opened = true,
|
||||
}: SetupOpts = {}) => {
|
||||
// Set up mock users and webhooks
|
||||
setupWebhookChannelsEndpoint(webhooksConfig);
|
||||
setupUserRecipientsEndpoint({ users });
|
||||
|
||||
// Create current user with appropriate permissions
|
||||
const currentUser = createMockUser({
|
||||
id: currentUserId,
|
||||
is_superuser: isAdmin,
|
||||
});
|
||||
|
||||
if (canManageSubscriptions) {
|
||||
(currentUser as UserWithApplicationPermissions).permissions = {
|
||||
can_access_setting: false,
|
||||
can_access_monitoring: false,
|
||||
can_access_subscription: true,
|
||||
};
|
||||
}
|
||||
|
||||
const settings = mockSettings();
|
||||
|
||||
return {
|
||||
...renderWithProviders(
|
||||
<TableNotificationsListModal
|
||||
notifications={notifications}
|
||||
opened={opened}
|
||||
onCreate={onCreate}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUnsubscribe={onUnsubscribe}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
{
|
||||
storeInitialState: {
|
||||
currentUser,
|
||||
settings,
|
||||
},
|
||||
},
|
||||
),
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUnsubscribe,
|
||||
onClose,
|
||||
};
|
||||
};
|
||||
|
||||
export const createNotificationForUser = (
|
||||
userId: number,
|
||||
index = 0,
|
||||
extra?: Partial<TableNotification>,
|
||||
): TableNotification => {
|
||||
return {
|
||||
...createMockTableNotification(),
|
||||
id: userId * 100 + index, // Ensure unique IDs
|
||||
creator: createMockUser({ id: userId, first_name: `User ${userId}` }),
|
||||
creator_id: userId,
|
||||
// Use payload to make each notification unique for testing
|
||||
payload: {
|
||||
event_name: "event/row.created",
|
||||
table_id: index + 1,
|
||||
},
|
||||
payload_id: null,
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Specificity to override already overridden custom styling without !important */
|
||||
.alertIcon[data-variant="subtle"] {
|
||||
&:hover {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState } from "react";
|
||||
import { usePreviousDistinct } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
useTableNotificationsQuery,
|
||||
useUnsubscribeFromNotificationMutation,
|
||||
useUpdateNotificationMutation,
|
||||
} from "metabase/api";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { DeleteConfirmModal } from "metabase/notifications/modals/shared/DeleteConfirmModal";
|
||||
import { UnsubscribeConfirmModal } from "metabase/notifications/modals/shared/UnsubscribeConfirmModal";
|
||||
import { addUndo } from "metabase/redux/undo";
|
||||
import { ActionIcon, Icon, Tooltip } from "metabase/ui";
|
||||
import type { TableId, TableNotification } from "metabase-types/api";
|
||||
|
||||
import { CreateOrEditTableNotificationModal } from "../CreateOrEditTableNotificationModal/CreateOrEditTableNotificationModal";
|
||||
import { TableNotificationsListModal } from "../TableNotificationsListModal/TableNotificationsListModal";
|
||||
|
||||
import S from "./TableNotificationsTrigger.module.css";
|
||||
|
||||
interface TableNotificationsModalProps {
|
||||
tableId: TableId;
|
||||
}
|
||||
|
||||
type AlertModalMode =
|
||||
| "list-modal"
|
||||
| "create-edit-modal"
|
||||
| "delete-confirm-modal"
|
||||
| "unsubscribe-confirm-modal";
|
||||
|
||||
export const TableNotificationsTrigger = ({
|
||||
tableId,
|
||||
...props
|
||||
}: TableNotificationsModalProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: tableNotifications } = useTableNotificationsQuery({
|
||||
table_id: tableId,
|
||||
include_inactive: false,
|
||||
payload_type: "notification/system-event",
|
||||
});
|
||||
|
||||
const [editingItem, setEditingItem] = useState<TableNotification | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [activeModal, setActiveModal] = useState<AlertModalMode | null>(null);
|
||||
|
||||
const hasNotifications = tableNotifications && tableNotifications.length > 0;
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
setActiveModal(hasNotifications ? "list-modal" : "create-edit-modal");
|
||||
};
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setActiveModal(null);
|
||||
setEditingItem(null);
|
||||
};
|
||||
const previousActiveModal = usePreviousDistinct(activeModal);
|
||||
const handleInternalModalClose = () => {
|
||||
if (previousActiveModal === "list-modal") {
|
||||
setActiveModal("list-modal");
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [updateNotification] = useUpdateNotificationMutation();
|
||||
const handleDelete = async (itemToDelete: TableNotification) => {
|
||||
const result = await updateNotification({
|
||||
...itemToDelete,
|
||||
active: false,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message: t`An error occurred`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(addUndo({ message: t`The alert was successfully deleted.` }));
|
||||
|
||||
const notificationsCount = tableNotifications?.length || 0;
|
||||
// if we have just unsubscribed from the last alert, close the popover
|
||||
if (notificationsCount <= 1) {
|
||||
handleClose();
|
||||
} else {
|
||||
handleInternalModalClose();
|
||||
}
|
||||
};
|
||||
|
||||
const [unsubscribe] = useUnsubscribeFromNotificationMutation();
|
||||
const handleUnsubscribe = async (alert: TableNotification) => {
|
||||
const result = await unsubscribe(alert.id);
|
||||
|
||||
if (result.error) {
|
||||
dispatch(
|
||||
addUndo({
|
||||
icon: "warning",
|
||||
toastColor: "error",
|
||||
message: t`An error occurred`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(addUndo({ message: t`Successfully unsubscribed.` }));
|
||||
|
||||
const notificationsCount = tableNotifications?.length || 0;
|
||||
// if we have just unsubscribed from the last alert, close the popover
|
||||
if (notificationsCount <= 1) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={hasNotifications ? t`Edit alerts` : t`Create alert`}>
|
||||
<ActionIcon
|
||||
data-testid="table-notifications-trigger"
|
||||
className={S.alertIcon}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={handleOpen}
|
||||
{...props}
|
||||
>
|
||||
<Icon name="alert" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{activeModal === "list-modal" && (
|
||||
<TableNotificationsListModal
|
||||
opened={isOpen}
|
||||
notifications={tableNotifications}
|
||||
onCreate={() => setActiveModal("create-edit-modal")}
|
||||
onEdit={(notification) => {
|
||||
setEditingItem(notification);
|
||||
setActiveModal("create-edit-modal");
|
||||
}}
|
||||
onClose={handleClose}
|
||||
onDelete={(notification) => {
|
||||
setEditingItem(notification);
|
||||
setActiveModal("delete-confirm-modal");
|
||||
}}
|
||||
onUnsubscribe={(notification) => {
|
||||
setEditingItem(notification);
|
||||
setActiveModal("unsubscribe-confirm-modal");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === "create-edit-modal" && (
|
||||
<CreateOrEditTableNotificationModal
|
||||
tableId={tableId}
|
||||
notification={editingItem}
|
||||
onClose={handleClose}
|
||||
onNotificationCreated={handleClose}
|
||||
onNotificationUpdated={handleClose}
|
||||
/>
|
||||
)}
|
||||
{activeModal === "delete-confirm-modal" && editingItem && (
|
||||
<DeleteConfirmModal
|
||||
title={t`Delete this alert?`}
|
||||
onConfirm={() => handleDelete(editingItem)}
|
||||
onClose={handleInternalModalClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal === "unsubscribe-confirm-modal" && editingItem && (
|
||||
<UnsubscribeConfirmModal
|
||||
title={t`Unsubscribe from this alert?`}
|
||||
description={t`You'll stop receiving this alert from now on. Depending on your organization’s permissions you might need to ask a moderator to be re-added in the future.`}
|
||||
onConfirm={() => handleUnsubscribe(editingItem)}
|
||||
onClose={handleInternalModalClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FieldWithMetadata } from "metabase-types/api";
|
||||
|
||||
export function canEditField(field?: FieldWithMetadata) {
|
||||
return (
|
||||
field && field.semantic_type !== "type/PK" && !field.database_is_generated
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PLUGIN_DATA_EDITING } from "metabase/plugins";
|
||||
import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
|
||||
import { isDatabaseTableEditingEnabled } from "./settings";
|
||||
import { EditTableDataContainer } from "./tables/edit";
|
||||
import { EditTableDashcardVisualization } from "./tables/edit/EditTableDashcardVisualization";
|
||||
import { BrowseTableData } from "./tables/view";
|
||||
|
||||
if (hasPremiumFeature("table_data_editing")) {
|
||||
PLUGIN_DATA_EDITING.isEnabled = () => true;
|
||||
PLUGIN_DATA_EDITING.isDatabaseTableEditingEnabled =
|
||||
isDatabaseTableEditingEnabled;
|
||||
PLUGIN_DATA_EDITING.VIEW_PAGE_COMPONENT = BrowseTableData;
|
||||
PLUGIN_DATA_EDITING.EDIT_PAGE_COMPONENT = EditTableDataContainer;
|
||||
PLUGIN_DATA_EDITING.CARD_TABLE_COMPONENT = EditTableDashcardVisualization;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type { Database as DatabaseApi } from "metabase-types/api";
|
||||
|
||||
export function isDatabaseTableEditingEnabled(
|
||||
database: Database | DatabaseApi,
|
||||
) {
|
||||
return database.settings?.["database-enable-table-editing"] ?? false;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { HeadBreadcrumbs } from "metabase/query_builder/components/view/ViewHeader/components"; // TODO: we should not use query builder components
|
||||
import type { DataSourcePart } from "metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils"; // TODO: we should not use query builder components
|
||||
import { ViewHeading } from "metabase/query_builder/components/view/ViewSection"; // TODO: we should not use query builder components
|
||||
import type DatabaseV1 from "metabase-lib/v1/metadata/Database";
|
||||
import type TableV1 from "metabase-lib/v1/metadata/Table";
|
||||
import type { Database, Table } from "metabase-types/api";
|
||||
|
||||
type TableBreadcrumbsProps = {
|
||||
database: Pick<DatabaseV1 | Database, "id" | "name"> | undefined;
|
||||
table: TableV1 | Table | undefined;
|
||||
isEditMode?: boolean;
|
||||
};
|
||||
|
||||
export const TableBreadcrumbs = ({
|
||||
database,
|
||||
table,
|
||||
isEditMode,
|
||||
}: TableBreadcrumbsProps) => {
|
||||
const breadcrumbsParts = useMemo(
|
||||
() =>
|
||||
database && table
|
||||
? getBreadcrumbsParts({
|
||||
database,
|
||||
table,
|
||||
isEditMode,
|
||||
})
|
||||
: [],
|
||||
[database, isEditMode, table],
|
||||
);
|
||||
|
||||
return (
|
||||
<ViewHeading color="medium">
|
||||
<HeadBreadcrumbs parts={breadcrumbsParts} variant="head" />
|
||||
</ViewHeading>
|
||||
);
|
||||
};
|
||||
|
||||
const getBreadcrumbsParts = ({
|
||||
database,
|
||||
table,
|
||||
isEditMode = false,
|
||||
}: {
|
||||
database: Pick<DatabaseV1 | Database, "id" | "name">;
|
||||
table: TableV1 | Table;
|
||||
isEditMode?: boolean;
|
||||
}): DataSourcePart[] => {
|
||||
const parts: DataSourcePart[] = [
|
||||
{
|
||||
icon: "database" as const,
|
||||
model: "database" as const,
|
||||
name: database.name,
|
||||
href: Urls.browseDatabase(database),
|
||||
},
|
||||
{
|
||||
icon: "table" as const,
|
||||
model: "table" as const,
|
||||
name: table.display_name,
|
||||
href: isEditMode ? Urls.browseTable(table) : undefined,
|
||||
},
|
||||
isEditMode
|
||||
? {
|
||||
get name() {
|
||||
return t`Edit`;
|
||||
},
|
||||
}
|
||||
: null,
|
||||
].filter(isNotNull);
|
||||
|
||||
return parts;
|
||||
};
|
||||
@@ -0,0 +1,511 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import { memo, useCallback, useEffect, useMemo } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { useLocation } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { NoDataError } from "metabase/common/components/errors/NoDataError";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Stack,
|
||||
Text,
|
||||
rem,
|
||||
} from "metabase/ui";
|
||||
import { ShortMessage } from "metabase/visualizations/components/Visualization/NoResultsView/NoResultsView.styled";
|
||||
import { useBuiltInActions } from "metabase-enterprise/data_editing/actions/use-built-in-actions";
|
||||
import { canEditField } from "metabase-enterprise/data_editing/helpers";
|
||||
import { DataGridActionExecuteModal } from "metabase-enterprise/table-actions/execution/DataGridActionExecuteModal";
|
||||
import { useDataGridRowActions } from "metabase-enterprise/table-actions/execution/use-datagrid-row-actions";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils";
|
||||
import { formatRowCount } from "metabase-lib/v1/queries/utils/row-count";
|
||||
import type {
|
||||
ConcreteTableId,
|
||||
DashCardVisualizationSettings,
|
||||
DatasetData,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { BuiltInTableAction } from "../types";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditTableDataGrid } from "./EditTableDataGrid";
|
||||
import { EditTableDataOverlay } from "./EditTableDataOverlay";
|
||||
import { ActionBulkUpdateRowFormModal } from "./modals/ActionBulkUpdateRowFormModal";
|
||||
import { ActionCreateRowFormModal } from "./modals/ActionCreateRowFormModal";
|
||||
import { ActionUpdateRowFormModal } from "./modals/ActionUpdateRowFormModal";
|
||||
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
|
||||
import { ForeignKeyConstraintModal } from "./modals/ForeignKeyConstraintModal";
|
||||
import { UnsavedLeaveConfirmationModal } from "./modals/UnsavedLeaveConfirmationModal";
|
||||
import { useActionUpdateRowModalFromDatasetWithObjectId } from "./modals/use-action-update-row-modal-with-object-id";
|
||||
import { useForeignKeyConstraintHandling } from "./modals/use-foreign-key-constraint-handling";
|
||||
import { useTableBulkDeleteConfirmation } from "./modals/use-table-bulk-delete-confirmation";
|
||||
import { useEditableTableColumnConfigFromVisualizationSettings } from "./use-editable-column-config";
|
||||
import { useActionFormDescription } from "./use-table-action-form-description";
|
||||
import { useTableCRUD } from "./use-table-crud";
|
||||
import { useEditingTableRowSelection } from "./use-table-row-selection";
|
||||
import { useTableSorting } from "./use-table-sorting";
|
||||
import { useTableEditingStateDashcardUpdateStrategy } from "./use-table-state-dashcard-update-strategy";
|
||||
import { useTableEditingUndoRedo } from "./use-table-undo-redo";
|
||||
import { getRowPkValues } from "./utils";
|
||||
|
||||
type EditTableDashcardVisualizationProps = {
|
||||
title: string;
|
||||
dashcardId: number;
|
||||
cardId: number;
|
||||
tableId: ConcreteTableId;
|
||||
data: DatasetData;
|
||||
className?: string;
|
||||
visualizationSettings?: DashCardVisualizationSettings; // TODO: move editable table viz settings type to Card Visualization
|
||||
question: Question;
|
||||
withLeaveUnsavedConfirmation?: boolean;
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
export const EditTableDashcardVisualization = memo(
|
||||
({
|
||||
title,
|
||||
dashcardId,
|
||||
cardId,
|
||||
tableId,
|
||||
data,
|
||||
className,
|
||||
visualizationSettings,
|
||||
question,
|
||||
withLeaveUnsavedConfirmation = true,
|
||||
isEditing,
|
||||
}: EditTableDashcardVisualizationProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const location = useLocation();
|
||||
const objectIdParam = useMemo(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const objectIdParam = searchParams.get("objectId");
|
||||
const parsedParams = parseModalCompositeObjectId(objectIdParam);
|
||||
|
||||
if (parsedParams?.dashcardId === dashcardId) {
|
||||
return parsedParams.objectId ?? undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [location.search, dashcardId]);
|
||||
|
||||
const handleCurrentObjectIdChange = useCallback(
|
||||
(objectId?: string) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
if (objectId) {
|
||||
searchParams.set(
|
||||
"objectId",
|
||||
getModalCompositeObjectId(objectId, dashcardId),
|
||||
);
|
||||
} else {
|
||||
searchParams.delete("objectId");
|
||||
}
|
||||
|
||||
dispatch(
|
||||
push({
|
||||
...location,
|
||||
search: "?" + searchParams.toString(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[location, dispatch, dashcardId],
|
||||
);
|
||||
|
||||
const stateUpdateStrategy = useTableEditingStateDashcardUpdateStrategy(
|
||||
dashcardId,
|
||||
cardId,
|
||||
);
|
||||
|
||||
const editingScope = useMemo(() => {
|
||||
return { "dashcard-id": dashcardId };
|
||||
}, [dashcardId]);
|
||||
|
||||
const {
|
||||
isInserting,
|
||||
isDeleting,
|
||||
isUpdating,
|
||||
tableFieldMetadataMap,
|
||||
cellsWithFailedUpdatesMap,
|
||||
error,
|
||||
|
||||
handleCellValueUpdate,
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowUpdateBulk,
|
||||
handleRowDelete,
|
||||
handleRowDeleteBulk,
|
||||
handleRowDeleteWithCascade,
|
||||
} = useTableCRUD({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
datasetData: data,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const {
|
||||
opened: isUpdateRowModalOpen,
|
||||
rowIndex: updateModalRowIndex,
|
||||
rowData: updateModalRowData,
|
||||
actionFormDescription: updateActionFormDescription,
|
||||
refetchActionFormDescription: refetchUpdateRowFormDescription,
|
||||
openUpdateRowModal,
|
||||
closeUpdateRowModal,
|
||||
} = useActionUpdateRowModalFromDatasetWithObjectId({
|
||||
datasetData: data,
|
||||
scope: editingScope,
|
||||
fetchOnMount: false,
|
||||
currentObjectId: objectIdParam,
|
||||
onObjectIdChange: handleCurrentObjectIdChange,
|
||||
});
|
||||
|
||||
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
|
||||
useTableEditingUndoRedo({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const columnsConfig = useEditableTableColumnConfigFromVisualizationSettings(
|
||||
visualizationSettings,
|
||||
);
|
||||
|
||||
const { hasCreateAction, hasUpdateAction, hasDeleteAction } =
|
||||
useBuiltInActions(
|
||||
visualizationSettings?.["editableTable.enabledActions"],
|
||||
);
|
||||
|
||||
const hasEditableAndVisibleColumns = useMemo(() => {
|
||||
return data.cols.some(
|
||||
(column) =>
|
||||
!columnsConfig?.isColumnReadonly(column.name) &&
|
||||
!columnsConfig?.isColumnHidden(column.name) &&
|
||||
canEditField(tableFieldMetadataMap[column.name]),
|
||||
);
|
||||
}, [data.cols, tableFieldMetadataMap, columnsConfig]);
|
||||
const hasBulkEditing = hasEditableAndVisibleColumns;
|
||||
const hasRowSelection = hasBulkEditing || hasDeleteAction;
|
||||
|
||||
const {
|
||||
rowActions,
|
||||
selectedRowAction,
|
||||
onRowActionButtonClick,
|
||||
onRowActionFormClose,
|
||||
} = useDataGridRowActions({
|
||||
actionSettings: visualizationSettings?.["editableTable.enabledActions"],
|
||||
datasetData: data,
|
||||
// For editable table actions we need to use only primary key values as input
|
||||
getActionInputFromRow: getRowPkValues,
|
||||
});
|
||||
|
||||
const { rowSelection, selectedRowIndices, setRowSelection } =
|
||||
useEditingTableRowSelection();
|
||||
|
||||
const {
|
||||
isDeleteBulkRequested,
|
||||
requestDeleteBulk,
|
||||
cancelDeleteBulk,
|
||||
onDeleteBulkConfirmation,
|
||||
} = useTableBulkDeleteConfirmation({
|
||||
handleRowDeleteBulk,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
});
|
||||
|
||||
const {
|
||||
isForeignKeyModalOpen,
|
||||
foreignKeyError,
|
||||
handleForeignKeyConfirmation,
|
||||
handleForeignKeyCancel,
|
||||
} = useForeignKeyConstraintHandling({
|
||||
onCascadeDelete: handleRowDeleteWithCascade,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
constraintError: error,
|
||||
});
|
||||
|
||||
const { getColumnSortDirection } = useTableSorting({
|
||||
question,
|
||||
});
|
||||
|
||||
const [
|
||||
isBulkEditingRequested,
|
||||
{ open: requestBulkEditing, close: closeBulkEditing },
|
||||
] = useDisclosure();
|
||||
|
||||
const [
|
||||
isCreateRowModalOpen,
|
||||
{ open: openCreateRowModal, close: closeCreateRowModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const {
|
||||
data: createRowFormDescription,
|
||||
refetch: refetchCreateRowFormDescription,
|
||||
} = useActionFormDescription({
|
||||
actionId: BuiltInTableAction.Create,
|
||||
scope: editingScope,
|
||||
fetchOnMount: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
refetchUpdateRowFormDescription();
|
||||
|
||||
if (hasCreateAction) {
|
||||
refetchCreateRowFormDescription();
|
||||
}
|
||||
}, [
|
||||
visualizationSettings, // refetch on visualizationSettings change
|
||||
hasCreateAction,
|
||||
isEditing,
|
||||
refetchCreateRowFormDescription,
|
||||
refetchUpdateRowFormDescription,
|
||||
]);
|
||||
|
||||
const shouldDisableActions = isUndoLoading || isRedoLoading;
|
||||
|
||||
return (
|
||||
<Stack className={cx(S.container, className)} gap={0}>
|
||||
<Flex
|
||||
p="0.5rem"
|
||||
px="1rem"
|
||||
style={{ borderBottom: "1px solid var(--mb-color-border)" }}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Group gap="sm" align="center">
|
||||
<Text fw="bold">{title}</Text>
|
||||
{!hasUpdateAction && <Icon name="lock" />}
|
||||
</Group>
|
||||
|
||||
{!isEditing && (
|
||||
<Group gap="sm" align="center">
|
||||
{hasBulkEditing && (
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={requestBulkEditing}
|
||||
disabled={shouldDisableActions || !selectedRowIndices.length}
|
||||
>
|
||||
<Icon
|
||||
name="pencil"
|
||||
tooltip={
|
||||
selectedRowIndices.length
|
||||
? t`Edit selected records`
|
||||
: t`Select records to edit`
|
||||
}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
{hasDeleteAction && (
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={requestDeleteBulk}
|
||||
disabled={shouldDisableActions || !selectedRowIndices.length}
|
||||
>
|
||||
<Icon
|
||||
name="trash"
|
||||
tooltip={
|
||||
selectedRowIndices.length
|
||||
? t`Delete selected records`
|
||||
: t`Select records to delete`
|
||||
}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
{(hasBulkEditing || hasDeleteAction) && (
|
||||
<Box h={rem(16)}>
|
||||
<Divider orientation="vertical" h="100%" />
|
||||
</Box>
|
||||
)}
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={undo}
|
||||
disabled={shouldDisableActions}
|
||||
loading={isUndoLoading}
|
||||
>
|
||||
<Icon name="undo" tooltip={t`Undo changes`} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={redo}
|
||||
disabled={shouldDisableActions}
|
||||
loading={isRedoLoading}
|
||||
>
|
||||
<Icon name="redo" tooltip={t`Redo changes`} />
|
||||
</ActionIcon>
|
||||
{hasCreateAction && (
|
||||
<>
|
||||
<Box h={rem(16)}>
|
||||
<Divider orientation="vertical" h="100%" />
|
||||
</Box>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
onClick={openCreateRowModal}
|
||||
disabled={shouldDisableActions}
|
||||
>
|
||||
<Icon name="add" tooltip={t`New record`} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Flex>
|
||||
{data.rows.length === 0 ? (
|
||||
<Stack
|
||||
h="100%"
|
||||
justify="center"
|
||||
align="center"
|
||||
c="var(--mb-color-text-tertiary)"
|
||||
>
|
||||
<NoDataError data-testid="no-results-image" />
|
||||
<ShortMessage>{t`No results!`}</ShortMessage>
|
||||
<Button
|
||||
leftSection={<Icon name="add" />}
|
||||
variant="filled"
|
||||
onClick={openCreateRowModal}
|
||||
disabled={shouldDisableActions}
|
||||
>{t`New Record`}</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Box pos="relative" className={S.gridWrapper}>
|
||||
<EditTableDataOverlay
|
||||
show={shouldDisableActions}
|
||||
message={currentActionLabel ?? ""}
|
||||
/>
|
||||
<EditTableDataGrid
|
||||
data={data}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
cellsWithFailedUpdatesMap={cellsWithFailedUpdatesMap}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={openUpdateRowModal}
|
||||
columnsConfig={columnsConfig}
|
||||
getColumnSortDirection={getColumnSortDirection}
|
||||
rowActions={rowActions}
|
||||
onActionClick={onRowActionButtonClick}
|
||||
hasRowSelection={hasRowSelection}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
p="xs"
|
||||
px="1rem"
|
||||
justify="flex-end"
|
||||
align="center"
|
||||
className={S.gridFooterDashcardVisualization}
|
||||
>
|
||||
<Text fz="sm" fw="bold">
|
||||
{getEditTableRowCountMessage(data)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
<ActionCreateRowFormModal
|
||||
description={createRowFormDescription}
|
||||
opened={isCreateRowModalOpen}
|
||||
onClose={closeCreateRowModal}
|
||||
isInserting={isInserting}
|
||||
onRowCreate={handleRowCreate}
|
||||
/>
|
||||
<ActionUpdateRowFormModal
|
||||
rowIndex={updateModalRowIndex}
|
||||
rowData={updateModalRowData}
|
||||
description={updateActionFormDescription}
|
||||
opened={isUpdateRowModalOpen}
|
||||
onClose={closeUpdateRowModal}
|
||||
onRowUpdate={handleRowUpdate}
|
||||
onRowDelete={handleRowDelete}
|
||||
withDelete={hasDeleteAction}
|
||||
/>
|
||||
<ActionBulkUpdateRowFormModal
|
||||
opened={isBulkEditingRequested}
|
||||
selectedRowIndices={selectedRowIndices}
|
||||
setRowSelection={setRowSelection}
|
||||
description={updateActionFormDescription}
|
||||
onClose={closeBulkEditing}
|
||||
onRowsUpdate={handleRowUpdateBulk}
|
||||
onRowsDelete={handleRowDeleteBulk}
|
||||
withDelete={hasDeleteAction}
|
||||
/>
|
||||
<DataGridActionExecuteModal
|
||||
action={selectedRowAction?.action}
|
||||
actionInput={selectedRowAction?.input}
|
||||
scope={editingScope}
|
||||
onClose={onRowActionFormClose}
|
||||
/>
|
||||
<DeleteBulkRowConfirmationModal
|
||||
opened={isDeleteBulkRequested}
|
||||
rowCount={selectedRowIndices.length}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={onDeleteBulkConfirmation}
|
||||
onClose={cancelDeleteBulk}
|
||||
/>
|
||||
{withLeaveUnsavedConfirmation && (
|
||||
<UnsavedLeaveConfirmationModal
|
||||
isUpdating={isUpdating}
|
||||
isDeleting={isDeleting}
|
||||
isInserting={isInserting}
|
||||
/>
|
||||
)}
|
||||
<ForeignKeyConstraintModal
|
||||
opened={isForeignKeyModalOpen}
|
||||
onClose={handleForeignKeyCancel}
|
||||
onConfirm={handleForeignKeyConfirmation}
|
||||
isLoading={isDeleting}
|
||||
childRecords={foreignKeyError?.children || {}}
|
||||
message={foreignKeyError?.message}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
EditTableDashcardVisualization.displayName = "EditTableDashcardVisualization";
|
||||
|
||||
function getEditTableRowCountMessage(data: DatasetData): string {
|
||||
const rowCount = data.rows.length;
|
||||
|
||||
if (data.rows_truncated > 0) {
|
||||
return t`Showing first ${formatRowCount(rowCount)}`;
|
||||
}
|
||||
if (rowCount === HARD_ROW_LIMIT) {
|
||||
return t`Showing first ${HARD_ROW_LIMIT} rows`;
|
||||
}
|
||||
return t`Showing ${formatRowCount(rowCount)}`;
|
||||
}
|
||||
|
||||
const MODAL_COMPOSITE_OBJECT_ID_SEPARATOR = "_";
|
||||
function getModalCompositeObjectId(objectId: string, dashcardId: number) {
|
||||
return `${dashcardId}${MODAL_COMPOSITE_OBJECT_ID_SEPARATOR}${objectId}`;
|
||||
}
|
||||
|
||||
function parseModalCompositeObjectId(compositeObjectId: string | null) {
|
||||
if (!compositeObjectId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// objectId can contain separator symbol itself, so we should slice the first part
|
||||
const separatorIndex = compositeObjectId.indexOf(
|
||||
MODAL_COMPOSITE_OBJECT_ID_SEPARATOR,
|
||||
);
|
||||
if (separatorIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dashcardId = compositeObjectId.slice(0, separatorIndex);
|
||||
const objectId = compositeObjectId.slice(separatorIndex + 1);
|
||||
|
||||
return { dashcardId: parseInt(dashcardId), objectId };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
.container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableRoot {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridWrapper {
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.gridFooter {
|
||||
border: 1px solid var(--mb-color-border);
|
||||
background-color: var(--mb-color-background);
|
||||
color: var(--mb-color-text-secondary);
|
||||
}
|
||||
|
||||
.gridFooterDashcardVisualization {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
}
|
||||
|
||||
/** DataGrid overrides */
|
||||
|
||||
.tableRow {
|
||||
&:hover .tableBodyCell {
|
||||
background-color: var(--mb-base-color-blue-10);
|
||||
}
|
||||
|
||||
&:hover .cellWithUpdateFail {
|
||||
background-color: var(--mb-base-color-dubloon-5);
|
||||
}
|
||||
|
||||
&:hover .tableHeaderCell {
|
||||
/* Draggable hover highlight for `SortableHeader` */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&[data-row-selected="true"] .tableBodyCell {
|
||||
background-color: var(--mb-base-color-blue-10);
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeaderContainer {
|
||||
--cell-bg-color: var(--mb-base-color-orion-5);
|
||||
--cell-hover-bg-color: var(--mb-base-color-orion-5);
|
||||
|
||||
background-color: var(--mb-base-color-orion-5);
|
||||
}
|
||||
|
||||
.tableHeaderCell {
|
||||
border-right: 1px solid var(--mb-color-border);
|
||||
|
||||
&[data-column-id="__MB_ROW_SELECT"] {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tableBodyContainer {
|
||||
background-color: var(--mb-color-bg-white);
|
||||
}
|
||||
|
||||
.tableBodyCell {
|
||||
background-color: var(--mb-color-bg-white);
|
||||
border-right: 1px solid var(--mb-color-border-alpha-30);
|
||||
|
||||
&[data-column-id="__MB_ROW_SELECT"] {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.headerCellContainer {
|
||||
background-color: var(--cell-bg-color);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.sortIndicator {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.readonlyCell {
|
||||
color: var(--mb-color-text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cellWithUpdateFail {
|
||||
background-color: var(--mb-base-color-dubloon-5);
|
||||
outline: 2px solid var(--mb-base-color-dubloon-30);
|
||||
outline-offset: -2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Link } from "react-router";
|
||||
import { t } from "ttag";
|
||||
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { ActionIcon, Icon, Tooltip } from "metabase/ui";
|
||||
import type Table from "metabase-lib/v1/metadata/Table";
|
||||
import type { Table as TableApi } from "metabase-types/api";
|
||||
|
||||
export const EditTableDataBackButton = ({
|
||||
table,
|
||||
}: {
|
||||
table: Table | TableApi;
|
||||
}) => {
|
||||
const label = t`Back to ${table.display_name}`;
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<Link to={Urls.browseTable(table)}>
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="2.625rem"
|
||||
color="border"
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon c="text-dark" name="arrow_left" />
|
||||
</ActionIcon>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { Location } from "history";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { useMount } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
skipToken,
|
||||
useGetAdhocQueryQuery,
|
||||
useGetDatabaseQuery,
|
||||
} from "metabase/api";
|
||||
import { GenericError } from "metabase/common/components/ErrorPages";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { closeNavbar } from "metabase/redux/app";
|
||||
import { Box, Flex, Stack, Text } from "metabase/ui";
|
||||
import { extractRemappedColumns } from "metabase/visualizations";
|
||||
import { isDatabaseTableEditingEnabled } from "metabase-enterprise/data_editing/settings";
|
||||
import { getRowCountMessage } from "metabase-lib/v1/queries/utils/row-count";
|
||||
|
||||
import { BuiltInTableAction } from "../types";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditTableDataGrid } from "./EditTableDataGrid";
|
||||
import { EditTableDataHeader } from "./EditTableDataHeader";
|
||||
import { EditTableDataOverlay } from "./EditTableDataOverlay";
|
||||
import { ActionBulkUpdateRowFormModal } from "./modals/ActionBulkUpdateRowFormModal";
|
||||
import { ActionCreateRowFormModal } from "./modals/ActionCreateRowFormModal";
|
||||
import { ActionUpdateRowFormModal } from "./modals/ActionUpdateRowFormModal";
|
||||
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
|
||||
import { ForeignKeyConstraintModal } from "./modals/ForeignKeyConstraintModal";
|
||||
import { UnsavedLeaveConfirmationModal } from "./modals/UnsavedLeaveConfirmationModal";
|
||||
import { useActionUpdateRowModalFromDatasetWithObjectId } from "./modals/use-action-update-row-modal-with-object-id";
|
||||
import { useForeignKeyConstraintHandling } from "./modals/use-foreign-key-constraint-handling";
|
||||
import { useTableBulkDeleteConfirmation } from "./modals/use-table-bulk-delete-confirmation";
|
||||
import { getTableEditPathname } from "./url";
|
||||
import { useStandaloneTableQuery } from "./use-standalone-table-query";
|
||||
import { useActionFormDescription } from "./use-table-action-form-description";
|
||||
import { useTableCRUD } from "./use-table-crud";
|
||||
import { useEditingTableRowSelection } from "./use-table-row-selection";
|
||||
import { useTableSorting } from "./use-table-sorting";
|
||||
import { useTableEditingStateApiUpdateStrategy } from "./use-table-state-api-update-strategy";
|
||||
import { useTableEditingUndoRedo } from "./use-table-undo-redo";
|
||||
|
||||
type EditTableDataContainerProps = {
|
||||
params: {
|
||||
dbId: string;
|
||||
tableId: string;
|
||||
objectId?: string;
|
||||
};
|
||||
location: Location<{ filter?: string }>;
|
||||
};
|
||||
|
||||
export const EditTableDataContainer = ({
|
||||
params: { dbId: dbIdParam, tableId: tableIdParam, objectId: objectIdParam },
|
||||
location,
|
||||
}: EditTableDataContainerProps) => {
|
||||
const databaseId = parseInt(dbIdParam, 10);
|
||||
const tableId = parseInt(tableIdParam, 10);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: database } = useGetDatabaseQuery({ id: databaseId });
|
||||
|
||||
const { fakeTableQuestion, fakeTableQuery, table, handleQuestionChange } =
|
||||
useStandaloneTableQuery({ tableId, databaseId, location });
|
||||
|
||||
const {
|
||||
data: rawDatasetResult,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useGetAdhocQueryQuery(fakeTableQuery || skipToken, {
|
||||
// Invalidates cache when filter changes (some records might be updated)
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const datasetData = useMemo(() => {
|
||||
return rawDatasetResult
|
||||
? extractRemappedColumns(rawDatasetResult.data)
|
||||
: undefined;
|
||||
}, [rawDatasetResult]);
|
||||
|
||||
const handleCurrentObjectIdChange = useCallback(
|
||||
(objectId?: string) => {
|
||||
dispatch(
|
||||
push({
|
||||
...location,
|
||||
pathname: getTableEditPathname(databaseId, tableId, objectId),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[databaseId, tableId, location, dispatch],
|
||||
);
|
||||
|
||||
const stateUpdateStrategy =
|
||||
useTableEditingStateApiUpdateStrategy(fakeTableQuery);
|
||||
|
||||
const editingScope = useMemo(() => {
|
||||
return { "table-id": tableId };
|
||||
}, [tableId]);
|
||||
|
||||
const { rowSelection, selectedRowIndices, setRowSelection } =
|
||||
useEditingTableRowSelection();
|
||||
|
||||
const {
|
||||
isInserting,
|
||||
isDeleting,
|
||||
isUpdating,
|
||||
tableFieldMetadataMap,
|
||||
cellsWithFailedUpdatesMap,
|
||||
error,
|
||||
|
||||
handleCellValueUpdate,
|
||||
handleRowCreate,
|
||||
handleRowUpdate,
|
||||
handleRowUpdateBulk,
|
||||
handleRowDelete,
|
||||
handleRowDeleteBulk,
|
||||
handleRowDeleteWithCascade,
|
||||
} = useTableCRUD({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
datasetData,
|
||||
stateUpdateStrategy,
|
||||
setRowSelection,
|
||||
});
|
||||
|
||||
const {
|
||||
isForeignKeyModalOpen,
|
||||
foreignKeyError,
|
||||
handleForeignKeyConfirmation,
|
||||
handleForeignKeyCancel,
|
||||
} = useForeignKeyConstraintHandling({
|
||||
onCascadeDelete: handleRowDeleteWithCascade,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
constraintError: error,
|
||||
});
|
||||
|
||||
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
|
||||
useTableEditingUndoRedo({
|
||||
tableId,
|
||||
scope: editingScope,
|
||||
stateUpdateStrategy,
|
||||
});
|
||||
|
||||
const [
|
||||
isCreateRowModalOpen,
|
||||
{ open: openCreateRowModal, close: closeCreateRowModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const { data: createRowFormDescription } = useActionFormDescription({
|
||||
actionId: BuiltInTableAction.Create,
|
||||
scope: editingScope,
|
||||
});
|
||||
|
||||
const {
|
||||
opened: isUpdateRowModalOpen,
|
||||
rowIndex: updateModalRowIndex,
|
||||
rowData: updateModalRowData,
|
||||
actionFormDescription: updateActionFormDescription,
|
||||
openUpdateRowModal,
|
||||
closeUpdateRowModal,
|
||||
} = useActionUpdateRowModalFromDatasetWithObjectId({
|
||||
datasetData,
|
||||
scope: editingScope,
|
||||
currentObjectId: objectIdParam,
|
||||
onObjectIdChange: handleCurrentObjectIdChange,
|
||||
});
|
||||
|
||||
const {
|
||||
isDeleteBulkRequested,
|
||||
requestDeleteBulk,
|
||||
cancelDeleteBulk,
|
||||
onDeleteBulkConfirmation,
|
||||
} = useTableBulkDeleteConfirmation({
|
||||
handleRowDeleteBulk,
|
||||
selectedRowIndices,
|
||||
setRowSelection,
|
||||
});
|
||||
|
||||
const { getColumnSortDirection, handleChangeColumnSort } = useTableSorting({
|
||||
question: fakeTableQuestion,
|
||||
handleQuestionChange,
|
||||
});
|
||||
|
||||
const [
|
||||
isBulkEditingRequested,
|
||||
{ open: requestBulkEditing, close: closeBulkEditing },
|
||||
] = useDisclosure();
|
||||
|
||||
useMount(() => {
|
||||
dispatch(closeNavbar());
|
||||
});
|
||||
|
||||
// Do not trigger leave confirmation modals on modal URL change
|
||||
const handleIsLeaveLocationAllowed = useCallback(
|
||||
(location: Location | undefined) => {
|
||||
if (
|
||||
location?.pathname.startsWith(getTableEditPathname(databaseId, tableId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[databaseId, tableId],
|
||||
);
|
||||
|
||||
if (!database || isLoading || !fakeTableQuestion) {
|
||||
// TODO: show loader
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!rawDatasetResult || !datasetData || !tableFieldMetadataMap) {
|
||||
// TODO: show error
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldDisableTable = isUndoLoading || isRedoLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack className={S.container} gap={0} data-testid="edit-table-data-root">
|
||||
{table && (
|
||||
<EditTableDataHeader
|
||||
database={database}
|
||||
table={table}
|
||||
question={fakeTableQuestion}
|
||||
isLoading={isFetching}
|
||||
isUndoLoading={isUndoLoading}
|
||||
isRedoLoading={isRedoLoading}
|
||||
selectedRowIndices={selectedRowIndices}
|
||||
onCreate={openCreateRowModal}
|
||||
onQuestionChange={handleQuestionChange}
|
||||
refetchTableDataQuery={refetch}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onRequestDeleteBulk={requestDeleteBulk}
|
||||
onRequestBulkEditing={requestBulkEditing}
|
||||
/>
|
||||
)}
|
||||
{isDatabaseTableEditingEnabled(database) ? (
|
||||
<>
|
||||
<Box pos="relative" className={S.gridWrapper}>
|
||||
<EditTableDataOverlay
|
||||
show={shouldDisableTable}
|
||||
message={currentActionLabel ?? ""}
|
||||
/>
|
||||
<EditTableDataGrid
|
||||
data={datasetData}
|
||||
fieldMetadataMap={tableFieldMetadataMap}
|
||||
cellsWithFailedUpdatesMap={cellsWithFailedUpdatesMap}
|
||||
getColumnSortDirection={getColumnSortDirection}
|
||||
onCellValueUpdate={handleCellValueUpdate}
|
||||
onRowExpandClick={openUpdateRowModal}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
hasRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onColumnSort={handleChangeColumnSort}
|
||||
/>
|
||||
</Box>
|
||||
<Flex
|
||||
py="0.5rem"
|
||||
px="1.5rem"
|
||||
h="2.5rem"
|
||||
justify="flex-end"
|
||||
align="center"
|
||||
className={S.gridFooter}
|
||||
>
|
||||
<Text fw="bold" size="md" c="inherit" component="span">
|
||||
{getRowCountMessage(rawDatasetResult)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</>
|
||||
) : (
|
||||
<GenericError
|
||||
title={t`Table editing is not enabled for this database`}
|
||||
message={t`Please ask your admin to enable table editing`}
|
||||
details={undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<ActionUpdateRowFormModal
|
||||
rowIndex={updateModalRowIndex}
|
||||
rowData={updateModalRowData}
|
||||
description={updateActionFormDescription}
|
||||
opened={isUpdateRowModalOpen}
|
||||
onClose={closeUpdateRowModal}
|
||||
onRowUpdate={handleRowUpdate}
|
||||
onRowDelete={handleRowDelete}
|
||||
withDelete
|
||||
/>
|
||||
<ActionCreateRowFormModal
|
||||
description={createRowFormDescription}
|
||||
opened={isCreateRowModalOpen}
|
||||
onClose={closeCreateRowModal}
|
||||
isInserting={isInserting}
|
||||
onRowCreate={handleRowCreate}
|
||||
/>
|
||||
<ActionBulkUpdateRowFormModal
|
||||
opened={isBulkEditingRequested}
|
||||
selectedRowIndices={selectedRowIndices}
|
||||
setRowSelection={setRowSelection}
|
||||
description={updateActionFormDescription}
|
||||
onClose={closeBulkEditing}
|
||||
onRowsUpdate={handleRowUpdateBulk}
|
||||
onRowsDelete={handleRowDeleteBulk}
|
||||
withDelete
|
||||
/>
|
||||
<DeleteBulkRowConfirmationModal
|
||||
opened={isDeleteBulkRequested}
|
||||
rowCount={selectedRowIndices.length}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={onDeleteBulkConfirmation}
|
||||
onClose={cancelDeleteBulk}
|
||||
/>
|
||||
<UnsavedLeaveConfirmationModal
|
||||
isUpdating={isUpdating}
|
||||
isDeleting={isDeleting}
|
||||
isInserting={isInserting}
|
||||
isLocationAllowed={handleIsLeaveLocationAllowed}
|
||||
/>
|
||||
<ForeignKeyConstraintModal
|
||||
opened={isForeignKeyModalOpen}
|
||||
onClose={handleForeignKeyCancel}
|
||||
onConfirm={handleForeignKeyConfirmation}
|
||||
isLoading={isDeleting}
|
||||
childRecords={foreignKeyError?.children || {}}
|
||||
message={foreignKeyError?.message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/* copied from metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.module.css */
|
||||
|
||||
.FilterButton {
|
||||
&[data-variant="default"] {
|
||||
transition: background 300ms linear;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-filter);
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 10%, white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FilterButtonAttachment {
|
||||
&[data-variant="default"] {
|
||||
padding: 0.5rem;
|
||||
transition: background 300ms linear;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 10%, white);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-expanded="true"] {
|
||||
.FilterCountChip {
|
||||
color: var(--mb-color-text-selected);
|
||||
background-color: var(--mb-color-filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FilterCountChip {
|
||||
font-size: 0.6875rem;
|
||||
border-radius: 10px;
|
||||
line-height: 1rem;
|
||||
padding-inline: 0.5rem;
|
||||
color: var(--mb-color-text-primary);
|
||||
background-color: color-mix(in srgb, var(--mb-color-filter) 15%, white);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { getFilterItems } from "metabase/querying/filters/components/FilterPanel/utils";
|
||||
import { MultiStageFilterPicker } from "metabase/querying/filters/components/FilterPicker/MultiStageFilterPicker";
|
||||
import { Button, Icon, Popover, Tooltip } from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
import ViewTitleHeaderS from "./EditTableDataFilterButton.module.css";
|
||||
|
||||
interface EditTableDataFilterButtonProps {
|
||||
className?: string;
|
||||
question: Question;
|
||||
isExpanded: boolean;
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
onQuestionChange: (newQuestion: Question) => void;
|
||||
}
|
||||
|
||||
// copied from metabase/query_builder/components/view/ViewHeader/components/FilterHeaderButton.tsx
|
||||
export function EditTableDataFilterButton({
|
||||
className,
|
||||
question,
|
||||
isExpanded,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onQuestionChange,
|
||||
}: EditTableDataFilterButtonProps) {
|
||||
const [isOpened, { close, toggle }] = useDisclosure();
|
||||
const query = question.query();
|
||||
const items = useMemo(() => (query ? getFilterItems(query) : []), [query]);
|
||||
const hasFilters = items.length > 0;
|
||||
const label = isExpanded ? t`Hide filters` : t`Show filters`;
|
||||
|
||||
const handleQueryChange = (newQuery: Lib.Query) => {
|
||||
const newQuestion = question.setQuery(newQuery);
|
||||
onQuestionChange(newQuestion);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button.Group>
|
||||
<Popover opened={isOpened} position="bottom-start" onDismiss={close}>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
className={cx(className, ViewTitleHeaderS.FilterButton)}
|
||||
leftSection={<Icon name={hasFilters ? "filter_plus" : "filter"} />}
|
||||
onClick={toggle}
|
||||
data-testid="table-edit-filter-header"
|
||||
>
|
||||
{t`Filter`}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MultiStageFilterPicker
|
||||
query={query}
|
||||
canAppendStage={false}
|
||||
onChange={handleQueryChange}
|
||||
onClose={close}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
{hasFilters && (
|
||||
<Tooltip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={ViewTitleHeaderS.FilterButtonAttachment}
|
||||
onClick={isExpanded ? onCollapse : onExpand}
|
||||
data-testid="filters-visibility-control"
|
||||
data-expanded={isExpanded}
|
||||
style={{ borderLeft: "none" }} // mantine puts a double border between buttons in groups
|
||||
>
|
||||
<div className={ViewTitleHeaderS.FilterCountChip}>
|
||||
{items?.length}
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button.Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import {
|
||||
type ColumnOptions,
|
||||
DataGrid,
|
||||
type DataGridRowAction,
|
||||
type RowIdColumnOptions,
|
||||
useDataGridInstance,
|
||||
} from "metabase/data-grid";
|
||||
import { ROW_ID_COLUMN_ID } from "metabase/data-grid/constants";
|
||||
import { formatValue } from "metabase/lib/formatting/value";
|
||||
import { Box, Icon } from "metabase/ui";
|
||||
import type { OrderByDirection } from "metabase-lib";
|
||||
import type {
|
||||
DatasetColumn,
|
||||
DatasetData,
|
||||
Field,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
RowValues,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { canEditField } from "../../helpers";
|
||||
import type { CellUniqKey, UpdateCellValueHandlerParams } from "../types";
|
||||
|
||||
import S from "./EditTableData.module.css";
|
||||
import { EditingBodyCellWrapper } from "./EditingBodyCell";
|
||||
import type { EditableTableColumnConfig } from "./use-editable-column-config";
|
||||
import {
|
||||
ROW_SELECT_COLUMN_ID,
|
||||
useTableColumnRowSelect,
|
||||
} from "./use-table-column-row-select";
|
||||
import { useTableEditing } from "./use-table-editing";
|
||||
import {
|
||||
getCellUniqKey,
|
||||
getPkColumns,
|
||||
getRowUniqueKeyByPkIndexes,
|
||||
} from "./utils";
|
||||
|
||||
type EditTableDataGridProps = {
|
||||
data: DatasetData;
|
||||
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
|
||||
onCellValueUpdate: (params: UpdateCellValueHandlerParams) => void;
|
||||
onRowExpandClick: (rowIndex: number) => void;
|
||||
columnsConfig?: EditableTableColumnConfig;
|
||||
getColumnSortDirection?: (
|
||||
column: DatasetColumn,
|
||||
) => OrderByDirection | undefined;
|
||||
cellsWithFailedUpdatesMap?: Record<CellUniqKey, true>;
|
||||
rowActions?: DataGridRowAction[];
|
||||
onActionClick?: (action: DataGridRowAction, row: Row<RowValues>) => void;
|
||||
hasRowSelection: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
onColumnSort?: (field: Field) => void;
|
||||
};
|
||||
|
||||
export const EditTableDataGrid = ({
|
||||
data,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onRowExpandClick,
|
||||
columnsConfig,
|
||||
getColumnSortDirection,
|
||||
cellsWithFailedUpdatesMap,
|
||||
rowActions,
|
||||
onActionClick,
|
||||
hasRowSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
onColumnSort,
|
||||
}: EditTableDataGridProps) => {
|
||||
const { cols, rows } = data;
|
||||
|
||||
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
|
||||
useTableEditing();
|
||||
|
||||
const columnOrder = useMemo(
|
||||
() =>
|
||||
columnsConfig?.columnOrder.length
|
||||
? [ROW_SELECT_COLUMN_ID, ...columnsConfig.columnOrder]
|
||||
: [ROW_SELECT_COLUMN_ID, ...cols.map(({ name }) => name)],
|
||||
[cols, columnsConfig],
|
||||
);
|
||||
|
||||
const columnVisibility = useMemo(
|
||||
() => (columnsConfig ? columnsConfig.columnVisibilityMap : undefined),
|
||||
[columnsConfig],
|
||||
);
|
||||
|
||||
const columnSizingMap = useMemo(() => ({}), []);
|
||||
|
||||
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
|
||||
const { indexes: pkColumnIndexes } = getPkColumns(cols);
|
||||
|
||||
return cols.map((column, columnIndex) => {
|
||||
const isEditableColumn =
|
||||
!columnsConfig || !columnsConfig.isColumnReadonly(column.name);
|
||||
|
||||
const sortDirection = getColumnSortDirection?.(column);
|
||||
|
||||
const options: ColumnOptions<RowValues, RowValue> = {
|
||||
id: column.name,
|
||||
name: column.display_name,
|
||||
accessorFn: (row: RowValues) => row[columnIndex],
|
||||
formatter: (value) => formatValue(value, { column }),
|
||||
wrap: false,
|
||||
sortDirection,
|
||||
header: function EditingHeader() {
|
||||
return (
|
||||
<Box className={S.headerCellContainer}>
|
||||
<Ellipsified>{column.display_name}</Ellipsified>
|
||||
{sortDirection != null ? (
|
||||
<Icon
|
||||
className={S.sortIndicator}
|
||||
data-testid="header-sort-indicator"
|
||||
name={sortDirection === "asc" ? "chevronup" : "chevrondown"}
|
||||
size={10}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
editingCell: (cellContext) => (
|
||||
<EditingBodyCellWrapper
|
||||
cellContext={cellContext}
|
||||
column={column}
|
||||
field={fieldMetadataMap?.[column.name]}
|
||||
onCellValueUpdate={onCellValueUpdate}
|
||||
onCellEditCancel={onCellEditCancel}
|
||||
/>
|
||||
),
|
||||
getIsCellEditing: (cellId: string) => editingCellId === cellId,
|
||||
getCellClassNameByCellId: (cellContext) => {
|
||||
const rowIndex = cellContext.row.index;
|
||||
const columnName = cellContext.column.id;
|
||||
const rowData = rows[rowIndex];
|
||||
const rowUniqueKey = getRowUniqueKeyByPkIndexes(
|
||||
pkColumnIndexes,
|
||||
rowData,
|
||||
);
|
||||
|
||||
const cellUniqKey = getCellUniqKey(rowUniqueKey, columnName);
|
||||
|
||||
return cellsWithFailedUpdatesMap?.[cellUniqKey]
|
||||
? S.cellWithUpdateFail
|
||||
: undefined;
|
||||
},
|
||||
};
|
||||
|
||||
if (!isEditableColumn) {
|
||||
options.getCellClassName = () => S.readonlyCell;
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
}, [
|
||||
cols,
|
||||
columnsConfig,
|
||||
getColumnSortDirection,
|
||||
fieldMetadataMap,
|
||||
onCellValueUpdate,
|
||||
onCellEditCancel,
|
||||
editingCellId,
|
||||
rows,
|
||||
cellsWithFailedUpdatesMap,
|
||||
]);
|
||||
|
||||
const rowId: RowIdColumnOptions = useMemo(
|
||||
() => ({
|
||||
variant: "expandButton",
|
||||
onRowExpandClick,
|
||||
}),
|
||||
[onRowExpandClick],
|
||||
);
|
||||
|
||||
const columnRowSelectOptions = useTableColumnRowSelect(hasRowSelection);
|
||||
|
||||
const tableProps = useDataGridInstance({
|
||||
data: rows,
|
||||
rowId,
|
||||
columnOrder,
|
||||
columnSizingMap,
|
||||
columnsOptions,
|
||||
columnVisibility,
|
||||
columnPinning: { left: [ROW_SELECT_COLUMN_ID, ROW_ID_COLUMN_ID] },
|
||||
enableRowSelection: hasRowSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
columnRowSelectOptions: columnRowSelectOptions,
|
||||
rowActionsColumn:
|
||||
rowActions?.length && onActionClick
|
||||
? { actions: rowActions, onActionClick }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const handleBodyCellClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
{
|
||||
cellId,
|
||||
columnId,
|
||||
}: {
|
||||
cellId: string;
|
||||
columnId: string;
|
||||
},
|
||||
) => {
|
||||
const field = fieldMetadataMap?.[columnId];
|
||||
|
||||
if (columnsConfig && columnsConfig.isColumnReadonly(columnId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disables editing for some columns, such as primary keys
|
||||
if (!canEditField(field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevents event from bubbling up inside editing cell
|
||||
// Otherwise requires special handling in EditingBodyCell
|
||||
if (editingCellId !== cellId) {
|
||||
onCellClickToEdit(cellId);
|
||||
}
|
||||
},
|
||||
[onCellClickToEdit, editingCellId, fieldMetadataMap, columnsConfig],
|
||||
);
|
||||
|
||||
const handleHeaderCellClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>, columnId?: string) => {
|
||||
if (columnId) {
|
||||
const field = fieldMetadataMap?.[columnId];
|
||||
|
||||
if (field) {
|
||||
onColumnSort?.(field);
|
||||
}
|
||||
}
|
||||
},
|
||||
[fieldMetadataMap, onColumnSort],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
{...tableProps}
|
||||
classNames={{
|
||||
headerContainer: S.tableHeaderContainer,
|
||||
headerCell: S.tableHeaderCell,
|
||||
bodyContainer: S.tableBodyContainer,
|
||||
bodyCell: S.tableBodyCell,
|
||||
row: S.tableRow,
|
||||
}}
|
||||
styles={{
|
||||
// Overrides theme constants and default white bg
|
||||
bodyCell: {
|
||||
backgroundColor: undefined,
|
||||
},
|
||||
}}
|
||||
theme={{ cell: { backgroundColor: "" } }}
|
||||
onBodyCellClick={handleBodyCellClick}
|
||||
onHeaderCellClick={handleHeaderCellClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip"; // TODO: we should not use query builder components
|
||||
import { QuestionFiltersHeader } from "metabase/query_builder/components/view/ViewHeader/components"; // TODO: we should not use query builder components
|
||||
import { getFilterItems } from "metabase/querying/filters/components/FilterPanel/utils";
|
||||
import { ActionIcon, Button, Flex, Group, Icon, Stack } from "metabase/ui";
|
||||
import { TableNotificationsTrigger } from "metabase-enterprise/data_editing/alerts/TableNotificationsModals/TableNotificationsTrigger/TableNotificationsTrigger";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type TableV1 from "metabase-lib/v1/metadata/Table";
|
||||
import type { Database } from "metabase-types/api";
|
||||
|
||||
import { TableBreadcrumbs } from "../common/TableBreadcrumbs";
|
||||
|
||||
import { EditTableDataFilterButton } from "./EditTableDataFilterButton";
|
||||
|
||||
interface EditTableDataHeaderProps {
|
||||
database: Database;
|
||||
table: TableV1;
|
||||
question: Question;
|
||||
isLoading: boolean;
|
||||
isUndoLoading: boolean;
|
||||
isRedoLoading: boolean;
|
||||
selectedRowIndices: number[];
|
||||
onCreate: () => void;
|
||||
onQuestionChange: (newQuestion: Question) => void;
|
||||
refetchTableDataQuery: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onRequestDeleteBulk: () => void;
|
||||
onRequestBulkEditing: () => void;
|
||||
}
|
||||
|
||||
export const EditTableDataHeader = ({
|
||||
database,
|
||||
table,
|
||||
question,
|
||||
isLoading,
|
||||
isUndoLoading,
|
||||
isRedoLoading,
|
||||
selectedRowIndices,
|
||||
onCreate,
|
||||
onQuestionChange,
|
||||
refetchTableDataQuery,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onRequestDeleteBulk,
|
||||
onRequestBulkEditing,
|
||||
}: EditTableDataHeaderProps) => {
|
||||
const hasFilters = useMemo(
|
||||
() =>
|
||||
question.query() ? getFilterItems(question.query()).length > 0 : false,
|
||||
[question],
|
||||
);
|
||||
const [
|
||||
areFiltersExpanded,
|
||||
{ open: onExpandFilters, close: onCollapseFilters },
|
||||
] = useDisclosure(hasFilters);
|
||||
|
||||
const shouldDisableActions = isUndoLoading || isRedoLoading;
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="0.5rem 1rem 0.5rem 2rem"
|
||||
mih="4rem"
|
||||
bg="var(--mb-color-background)"
|
||||
data-testid="table-data-view-header"
|
||||
>
|
||||
<TableBreadcrumbs database={database} table={table} isEditMode />
|
||||
|
||||
<Group>
|
||||
<EditTableDataFilterButton
|
||||
question={question}
|
||||
isExpanded={areFiltersExpanded}
|
||||
onExpand={onExpandFilters}
|
||||
onCollapse={onCollapseFilters}
|
||||
onQuestionChange={onQuestionChange}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<Icon name="add" />}
|
||||
variant="filled"
|
||||
onClick={() => onCreate()}
|
||||
disabled={shouldDisableActions}
|
||||
>{t`New record`}</Button>
|
||||
<Button
|
||||
leftSection={<Icon name="pencil" />}
|
||||
onClick={onRequestBulkEditing}
|
||||
disabled={shouldDisableActions || !selectedRowIndices.length}
|
||||
>{t`Edit`}</Button>
|
||||
<Button
|
||||
leftSection={<Icon name="trash" />}
|
||||
variant="filled"
|
||||
color="error"
|
||||
onClick={onRequestDeleteBulk}
|
||||
disabled={shouldDisableActions || !selectedRowIndices.length}
|
||||
>{t`Delete`}</Button>
|
||||
<Flex gap="xs">
|
||||
<RunButtonWithTooltip
|
||||
iconSize={16}
|
||||
onlyIcon
|
||||
medium
|
||||
compact
|
||||
isRunning={isLoading}
|
||||
onRun={refetchTableDataQuery}
|
||||
/>
|
||||
<TableNotificationsTrigger tableId={table.id} />
|
||||
<ActionIcon
|
||||
onClick={onUndo}
|
||||
size="lg"
|
||||
loading={isUndoLoading}
|
||||
disabled={shouldDisableActions}
|
||||
>
|
||||
<Icon name="undo" tooltip={t`Undo changes`} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
onClick={onRedo}
|
||||
size="lg"
|
||||
loading={isRedoLoading}
|
||||
disabled={shouldDisableActions}
|
||||
>
|
||||
<Icon name="redo" tooltip={t`Redo changes`} />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Group>
|
||||
<QuestionFiltersHeader
|
||||
expanded={areFiltersExpanded}
|
||||
question={question}
|
||||
updateQuestion={onQuestionChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* +1 for DataGrid `headerContainer` (which has z-index: 2) */
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
color: var(--mb-color-brand);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mb-color-bg-white) 75%,
|
||||
transparent
|
||||
);
|
||||
transition:
|
||||
opacity 0.5s,
|
||||
background-color 0.5s;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--mb-color-brand);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Flex, Loader, Text } from "metabase/ui";
|
||||
|
||||
import S from "./EditTableDataOverlay.module.css";
|
||||
|
||||
type EditTableDataOverlayProps = {
|
||||
message: string;
|
||||
show?: boolean;
|
||||
};
|
||||
|
||||
export function EditTableDataOverlay({
|
||||
show = true,
|
||||
message,
|
||||
}: EditTableDataOverlayProps) {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex className={S.overlay}>
|
||||
<Loader size="lg" color="currentColor" />
|
||||
<Text className={S.message}>{message}</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.inlineEditingTextInput {
|
||||
transition: none;
|
||||
outline: 2px solid var(--mb-base-color-blue-50);
|
||||
outline-offset: -2px;
|
||||
border-color: transparent;
|
||||
box-sizing: border-box;
|
||||
font-weight: 700;
|
||||
background-color: var(--mb-base-color-white);
|
||||
border-radius: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
/* Should be in sync with DEFAULT_FONT_SIZE */
|
||||
font-size: 12.5px;
|
||||
|
||||
/* Properly alignment with BaseCell, to avoid text flickering (BaseCell has only border-bottom 1px) */
|
||||
border-top-width: 0;
|
||||
|
||||
/* Keep outline consistent for inline editing, since focused and non focused state is semantically the same */
|
||||
&:focus {
|
||||
outline: 2px solid var(--mb-base-color-blue-50);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CellContext } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type {
|
||||
DatasetColumn,
|
||||
FieldWithMetadata,
|
||||
RowValue,
|
||||
RowValues,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { UpdateCellValueHandlerParams } from "../types";
|
||||
|
||||
import S from "./EditingBodyCell.module.css";
|
||||
import { EditingBodyCellConditional } from "./inputs";
|
||||
|
||||
interface EditingBodyCellWrapperProps<TRow, TValue> {
|
||||
column: DatasetColumn;
|
||||
field?: FieldWithMetadata;
|
||||
cellContext: CellContext<TRow, TValue>;
|
||||
onCellValueUpdate: (params: UpdateCellValueHandlerParams) => void;
|
||||
onCellEditCancel: () => void;
|
||||
}
|
||||
|
||||
export const EditingBodyCellWrapper = (
|
||||
props: EditingBodyCellWrapperProps<RowValues, RowValue>,
|
||||
) => {
|
||||
const {
|
||||
onCellEditCancel,
|
||||
onCellValueUpdate,
|
||||
column,
|
||||
field,
|
||||
cellContext: {
|
||||
getValue,
|
||||
column: { id: columnName },
|
||||
row: { index: rowIndex },
|
||||
},
|
||||
} = props;
|
||||
|
||||
const initialValue = getValue<RowValue>();
|
||||
|
||||
const doCellValueUpdate = useCallback(
|
||||
(value: RowValue) => {
|
||||
if (value !== initialValue) {
|
||||
onCellValueUpdate({
|
||||
updatedData: { [columnName]: value },
|
||||
rowIndex,
|
||||
columnName,
|
||||
});
|
||||
}
|
||||
|
||||
// Hide the editing cell after submitting the value
|
||||
onCellEditCancel();
|
||||
},
|
||||
[initialValue, onCellEditCancel, onCellValueUpdate, columnName, rowIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<EditingBodyCellConditional
|
||||
autoFocus
|
||||
inputProps={{
|
||||
variant: "unstyled",
|
||||
size: "sm",
|
||||
}}
|
||||
classNames={{
|
||||
textInputElement: S.inlineEditingTextInput,
|
||||
selectTextInputElement: S.inlineEditingTextInput,
|
||||
dateTextInputElement: S.inlineEditingTextInput,
|
||||
}}
|
||||
field={field}
|
||||
initialValue={initialValue}
|
||||
datasetColumn={column}
|
||||
onSubmit={doCellValueUpdate}
|
||||
onCancel={onCellEditCancel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Button, Group, Icon, Text } from "metabase/ui";
|
||||
|
||||
import { getUpdateApiErrorMessage } from "./utils";
|
||||
|
||||
type ErrorUpdateToastProps = {
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export const ErrorUpdateToast = ({ error }: ErrorUpdateToastProps) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const errorMessage = getUpdateApiErrorMessage(error);
|
||||
|
||||
if (showDetails) {
|
||||
return (
|
||||
<Text c="text-white">
|
||||
{t`Couldn't save table changes:`}
|
||||
<br />
|
||||
{errorMessage}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap="2.5rem">
|
||||
<Group gap="0.5rem">
|
||||
<Icon name="warning" c="danger" size={12} />
|
||||
<Text c="text-white" fw={700}>{t`Couldn't save table changes`}</Text>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
size="compact-lg"
|
||||
color="var(--mb-base-color-orion-80)"
|
||||
variant="filled"
|
||||
autoContrast
|
||||
radius="0.5rem"
|
||||
onClick={() => setShowDetails(true)}
|
||||
>{t`More info`}</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { EditTableDataContainer } from "./EditTableDataContainer";
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Input } from "metabase/ui";
|
||||
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
export const EditingBodyCellBasicInput = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onChangeValue,
|
||||
}: EditingBodyPrimitiveProps) => {
|
||||
return (
|
||||
<Input
|
||||
defaultValue={(initialValue ?? "").toString()}
|
||||
autoFocus={autoFocus}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
} else if (event.key === "Enter") {
|
||||
onSubmit(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
// Convert empty string to null
|
||||
onChangeValue?.(event.currentTarget.value || null);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onSubmit(event.currentTarget.value || null);
|
||||
}}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.textInputElement,
|
||||
}}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSearchFieldValuesQuery } from "metabase/api";
|
||||
import { useDebouncedValue } from "metabase/common/hooks/use-debounced-value";
|
||||
import { getFieldOptions } from "metabase/querying/filters/components/FilterValuePicker/utils";
|
||||
import {
|
||||
Box,
|
||||
Combobox,
|
||||
Icon,
|
||||
Input,
|
||||
Loader,
|
||||
type SelectOption,
|
||||
Text,
|
||||
TextInput,
|
||||
useCombobox,
|
||||
} from "metabase/ui";
|
||||
import type { RemappingHydratedDatasetColumn } from "metabase/visualizations/types";
|
||||
import type { FieldValue } from "metabase-types/api";
|
||||
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
type EditingBodyCellCategorySelectProps = EditingBodyPrimitiveProps & {
|
||||
withCreateNew?: boolean;
|
||||
getDropdownLabelText?: (item: SelectOption) => string;
|
||||
getSelectedLabelText?: (item: SelectOption) => string;
|
||||
};
|
||||
|
||||
const DefaultItemLabelTextGetter = (item: SelectOption) => item.label;
|
||||
const DefaultSelectedLabelTextGetter = (item: SelectOption) => item.value;
|
||||
|
||||
export const EditingBodyCellCategorySelect = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
datasetColumn,
|
||||
withCreateNew = true,
|
||||
classNames,
|
||||
field,
|
||||
getDropdownLabelText = DefaultItemLabelTextGetter,
|
||||
getSelectedLabelText = DefaultSelectedLabelTextGetter,
|
||||
onSubmit,
|
||||
onChangeValue,
|
||||
onCancel,
|
||||
}: EditingBodyCellCategorySelectProps) => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialValue?.toString() ?? "");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const focusSearchInput = useCallback(() => {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
const combobox = useCombobox({
|
||||
defaultOpened: autoFocus,
|
||||
onDropdownClose: onCancel,
|
||||
onDropdownOpen: focusSearchInput,
|
||||
});
|
||||
|
||||
const { options, isLoading, isFetching } = useCategorySelectSearchOptions({
|
||||
search,
|
||||
fieldId: datasetColumn?.id,
|
||||
// Mostly used for FKs, e.g. an ID is remapped to a name
|
||||
searchFieldId: datasetColumn.remapped_to_column?.id ?? datasetColumn.id,
|
||||
getDropdownLabelText,
|
||||
});
|
||||
|
||||
const handleOptionSubmit = useCallback(
|
||||
(value: string | null) => {
|
||||
setValue(value ?? "");
|
||||
onSubmit(value);
|
||||
onChangeValue?.(value);
|
||||
combobox.toggleDropdown();
|
||||
},
|
||||
[onSubmit, setValue, onChangeValue, combobox],
|
||||
);
|
||||
|
||||
const optionValueToOptionMap = useMemo(
|
||||
() =>
|
||||
options.reduce(
|
||||
(map, item) => ({
|
||||
...map,
|
||||
[item.value]: item,
|
||||
}),
|
||||
{} as Record<string, SelectOption>,
|
||||
),
|
||||
[options],
|
||||
);
|
||||
|
||||
const inputLabel = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Input.Placeholder c="text-light">{t`Loading...`}</Input.Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value && inputProps?.placeholder) {
|
||||
return (
|
||||
<Input.Placeholder c="text-light">
|
||||
{inputProps.placeholder}
|
||||
</Input.Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
// Lookup ad-hoc search results (e.g. might be different from the initial value)
|
||||
if (value in optionValueToOptionMap) {
|
||||
return getSelectedLabelText(optionValueToOptionMap[value]);
|
||||
}
|
||||
|
||||
// Lookup column remapped values (usually when FK value is not presented in the current search results)
|
||||
const maybeHydratedDatasetColumn =
|
||||
datasetColumn as RemappingHydratedDatasetColumn;
|
||||
const intValue = parseInt(value, 10);
|
||||
if (
|
||||
maybeHydratedDatasetColumn.remapping?.has(value) ||
|
||||
maybeHydratedDatasetColumn.remapping?.has(intValue)
|
||||
) {
|
||||
const remappedValue =
|
||||
maybeHydratedDatasetColumn.remapping.get(value) ??
|
||||
maybeHydratedDatasetColumn.remapping.get(intValue);
|
||||
|
||||
return (
|
||||
<Text truncate="end">
|
||||
{getSelectedLabelText({
|
||||
value: value,
|
||||
label: remappedValue,
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to the raw value instead of a label
|
||||
return <Text truncate="end">{value}</Text>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
isLoading,
|
||||
value,
|
||||
optionValueToOptionMap,
|
||||
inputProps?.placeholder,
|
||||
getSelectedLabelText,
|
||||
datasetColumn,
|
||||
]);
|
||||
|
||||
const isNullable = field?.database_is_nullable;
|
||||
const shouldDisplayClearButton =
|
||||
isNullable && !!value && !inputProps?.disabled;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
position="bottom-start"
|
||||
onOptionSubmit={handleOptionSubmit}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<Input
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
onClick={() => combobox.openDropdown()}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.selectTextInputElement,
|
||||
}}
|
||||
style={{ overflow: "hidden" }} // for label truncation
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
shouldDisplayClearButton && (
|
||||
<Icon
|
||||
name="close"
|
||||
color="var(--mb-color-text-light)"
|
||||
onClick={() => handleOptionSubmit(null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...inputProps}
|
||||
>
|
||||
{inputLabel}
|
||||
</Input>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown mah="none" miw={250}>
|
||||
<Box p="0.5rem" pb="0" bg="white" pos="sticky" top={0}>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={t`Search the list`}
|
||||
leftSection={<Icon name="search" />}
|
||||
rightSection={isFetching ? <Loader size="xs" /> : undefined}
|
||||
// Auto focus works when dropdown is initially rendered as opened (e.g. inline editing)
|
||||
// whereas `onDropdownOpen` is called only when dropdown is opened by clicking the button (e.g. modal editing)
|
||||
autoFocus
|
||||
ref={searchInputRef}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Combobox.Options p="0.5rem">
|
||||
{options.length > 0 ? (
|
||||
options.map((item) => (
|
||||
<Combobox.Option
|
||||
selected={value === item.value}
|
||||
value={item.value}
|
||||
key={item.value}
|
||||
>
|
||||
{getDropdownLabelText(item)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : isLoading ? (
|
||||
<Combobox.Empty>{t`Loading values...`}</Combobox.Empty>
|
||||
) : withCreateNew ? (
|
||||
<Combobox.Option value={search}>
|
||||
{t`Add option:`} <strong>{search}</strong>
|
||||
</Combobox.Option>
|
||||
) : (
|
||||
<Combobox.Empty>{t`Nothing found`}</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
const SEARCH_LIMIT_DEFAULT = 20;
|
||||
const SEARCH_DEBOUNCE = 500;
|
||||
|
||||
type UseCategorySelectSearchOptionsProps = {
|
||||
search: string;
|
||||
fieldId?: number;
|
||||
searchFieldId?: number;
|
||||
limit?: number;
|
||||
getDropdownLabelText: (item: SelectOption) => string;
|
||||
};
|
||||
|
||||
function useCategorySelectSearchOptions({
|
||||
search,
|
||||
fieldId,
|
||||
searchFieldId,
|
||||
limit = SEARCH_LIMIT_DEFAULT,
|
||||
getDropdownLabelText,
|
||||
}: UseCategorySelectSearchOptionsProps) {
|
||||
const debouncedSearch = useDebouncedValue(search, SEARCH_DEBOUNCE);
|
||||
const [searchValueToFetch, setSearchValueToFetch] = useState(debouncedSearch);
|
||||
|
||||
// Values that are fetched without search value (all values with some limit)
|
||||
const [initialFieldValues, setInitialFieldValues] = useState<
|
||||
FieldValue[] | undefined
|
||||
>(undefined);
|
||||
|
||||
// Local search is performed if there are less than `limit` initial values
|
||||
const shouldPerformLocalSearch =
|
||||
initialFieldValues && initialFieldValues.length < limit;
|
||||
|
||||
// Update the search value to fetch on every debounced search value change
|
||||
// if the local search is disabled
|
||||
useEffect(() => {
|
||||
if (!shouldPerformLocalSearch) {
|
||||
setSearchValueToFetch(debouncedSearch);
|
||||
}
|
||||
}, [debouncedSearch, shouldPerformLocalSearch]);
|
||||
|
||||
const {
|
||||
data: fieldValues,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useSearchFieldValuesQuery(
|
||||
{
|
||||
fieldId: fieldId ?? -1, // type guard
|
||||
searchFieldId: searchFieldId ?? -1, // type guard
|
||||
// empty string is invalid from the API perspective
|
||||
value: searchValueToFetch === "" ? undefined : searchValueToFetch,
|
||||
limit: SEARCH_LIMIT_DEFAULT,
|
||||
},
|
||||
{ skip: fieldId === undefined },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFieldValues && fieldValues) {
|
||||
setInitialFieldValues(fieldValues);
|
||||
}
|
||||
}, [initialFieldValues, fieldValues]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (fieldValues) {
|
||||
const options = getFieldOptions(fieldValues);
|
||||
|
||||
if (shouldPerformLocalSearch) {
|
||||
return options.filter((item) =>
|
||||
getDropdownLabelText(item)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim()),
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [shouldPerformLocalSearch, fieldValues, search, getDropdownLabelText]);
|
||||
|
||||
return {
|
||||
options,
|
||||
isLoading,
|
||||
isFetching:
|
||||
(isFetching || search !== debouncedSearch) && !shouldPerformLocalSearch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { canEditField } from "../../../helpers";
|
||||
|
||||
import { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
|
||||
import { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
|
||||
import { EditingBodyCellDatetime } from "./EditingBodyCellDatetime";
|
||||
import { EditingBodyCellFKSelect } from "./EditingBodyCellFKSelect";
|
||||
import { EditingBodyCellTextarea } from "./EditingBodyCellTextarea";
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
export const EditingBodyCellConditional = (
|
||||
props: EditingBodyPrimitiveProps,
|
||||
) => {
|
||||
const { datasetColumn: column, field, inputProps, withTextarea } = props;
|
||||
|
||||
const disabled = !canEditField(field);
|
||||
const placeholder = field?.database_default
|
||||
? t`Auto populated`
|
||||
: field?.database_is_nullable
|
||||
? t`Optional`
|
||||
: props.inputProps?.placeholder;
|
||||
|
||||
if (
|
||||
column.semantic_type === "type/State" ||
|
||||
column.semantic_type === "type/Country" ||
|
||||
column.semantic_type === "type/Category"
|
||||
) {
|
||||
return (
|
||||
<EditingBodyCellCategorySelect
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.semantic_type === "type/FK") {
|
||||
return (
|
||||
<EditingBodyCellFKSelect
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
column.effective_type === "type/Date" ||
|
||||
column.effective_type === "type/DateTime" ||
|
||||
column.effective_type === "type/DateTimeWithLocalTZ"
|
||||
) {
|
||||
return (
|
||||
<EditingBodyCellDatetime
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (withTextarea && column.semantic_type === "type/Description") {
|
||||
return (
|
||||
<EditingBodyCellTextarea
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditingBodyCellBasicInput
|
||||
{...props}
|
||||
inputProps={{ placeholder, disabled, ...inputProps }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { DatesProvider, useDatesContext } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import { type KeyboardEvent, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_DATE_STYLE,
|
||||
DEFAULT_TIME_STYLE,
|
||||
} from "metabase/lib/formatting/datetime-utils";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { DateInput } from "metabase/ui";
|
||||
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
const DEFAULT_DATETIME_STYLE = `${DEFAULT_DATE_STYLE}, ${DEFAULT_TIME_STYLE}`;
|
||||
|
||||
export const EditingBodyCellDatetime = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
datasetColumn,
|
||||
classNames,
|
||||
onSubmit,
|
||||
onChangeValue,
|
||||
onCancel,
|
||||
}: EditingBodyPrimitiveProps) => {
|
||||
const isDateTime =
|
||||
datasetColumn.effective_type === "type/DateTime" ||
|
||||
datasetColumn.effective_type === "type/DateTimeWithLocalTZ";
|
||||
|
||||
const { locale, firstDayOfWeek, consistentWeeks } = useDatesContext();
|
||||
const reportTimezone = useSelector((state) =>
|
||||
getSetting(state, "report-timezone-long"),
|
||||
);
|
||||
|
||||
const { restoreTimezone, initialDate } = useMemo(
|
||||
() => ({
|
||||
initialDate: initialValue ? new Date(initialValue.toString()) : null,
|
||||
restoreTimezone: (date: Date | null) => {
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dayjs(date)
|
||||
.tz(reportTimezone)
|
||||
.format("YYYY-MM-DDTHH:mm:ss.SSSZ");
|
||||
},
|
||||
}),
|
||||
[initialValue, reportTimezone],
|
||||
);
|
||||
|
||||
const valueFormat = isDateTime ? DEFAULT_DATETIME_STYLE : DEFAULT_DATE_STYLE;
|
||||
|
||||
const [value, setValue] = useState<Date | null>(initialDate);
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: Date | null) => {
|
||||
setValue(value);
|
||||
onChangeValue?.(restoreTimezone(value));
|
||||
},
|
||||
[onChangeValue, restoreTimezone],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, [setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onSubmit(restoreTimezone(value));
|
||||
}, [value, onSubmit, restoreTimezone]);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
} else if (event.key === "Enter") {
|
||||
onSubmit(restoreTimezone(value));
|
||||
}
|
||||
},
|
||||
[value, onCancel, onSubmit, restoreTimezone],
|
||||
);
|
||||
|
||||
return (
|
||||
<DatesProvider
|
||||
settings={{
|
||||
timezone: reportTimezone,
|
||||
locale,
|
||||
firstDayOfWeek,
|
||||
consistentWeeks,
|
||||
}}
|
||||
>
|
||||
<DateInput
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
valueFormat={valueFormat}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.dateTextInputElement,
|
||||
}}
|
||||
// Keeps popover mounted when focused to improve time editing UX
|
||||
popoverProps={{ opened: isFocused }}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyUp={handleKeyUp}
|
||||
{...inputProps}
|
||||
/>
|
||||
</DatesProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { SelectOption } from "metabase/ui";
|
||||
|
||||
import { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
export const EditingBodyCellFKSelect = (props: EditingBodyPrimitiveProps) => {
|
||||
const getDropdownLabelText = useCallback(
|
||||
(item: SelectOption) =>
|
||||
item.label !== item.value ? `${item.label} [${item.value}]` : item.value,
|
||||
[],
|
||||
);
|
||||
|
||||
const getSelectedLabelText = useCallback(
|
||||
(item: SelectOption) => item.label,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<EditingBodyCellCategorySelect
|
||||
{...props}
|
||||
getDropdownLabelText={getDropdownLabelText}
|
||||
getSelectedLabelText={getSelectedLabelText}
|
||||
// Temporary enable create new for FKs (see WRK-490)
|
||||
withCreateNew={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Textarea } from "metabase/ui";
|
||||
|
||||
import type { EditingBodyPrimitiveProps } from "./types";
|
||||
|
||||
const MAX_ROWS = 8;
|
||||
const MIN_ROWS = 1;
|
||||
|
||||
export const EditingBodyCellTextarea = ({
|
||||
autoFocus,
|
||||
inputProps,
|
||||
initialValue,
|
||||
classNames,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onChangeValue,
|
||||
}: EditingBodyPrimitiveProps) => {
|
||||
return (
|
||||
<Textarea
|
||||
defaultValue={(initialValue ?? "").toString()}
|
||||
autoFocus={autoFocus}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
// Convert empty string to null
|
||||
onChangeValue?.(event.currentTarget.value || null);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onSubmit(event.currentTarget.value || null);
|
||||
}}
|
||||
classNames={{
|
||||
wrapper: classNames?.wrapper,
|
||||
input: classNames?.textInputElement,
|
||||
}}
|
||||
{...inputProps}
|
||||
// Match the line height for single line text (should look like a regular input)
|
||||
styles={{ input: { lineHeight: "165%" } }}
|
||||
maxRows={MAX_ROWS}
|
||||
minRows={MIN_ROWS}
|
||||
autosize
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
|
||||
export { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
|
||||
export { EditingBodyCellFKSelect } from "./EditingBodyCellFKSelect";
|
||||
export { EditingBodyCellDatetime } from "./EditingBodyCellDatetime";
|
||||
export { EditingBodyCellConditional } from "./EditingBodyCellConditional";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user