Compare commits
923 Commits
l10n_devel
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
794c7b0895 | ||
|
|
85d7d17f8d | ||
|
|
e08dd865b8 | ||
|
|
a2d1c45cb4 | ||
|
|
a0eb9ce5bf | ||
|
|
dbc5d90bda | ||
|
|
e2682b9a45 | ||
|
|
e21dedd9ba | ||
|
|
9ade3ce18e | ||
|
|
e938163cca | ||
|
|
ab18470596 | ||
|
|
cd542d452a | ||
|
|
e9b92254a7 | ||
|
|
7a2d47fbee | ||
|
|
3a575e21e5 | ||
|
|
fb19d721c8 | ||
|
|
ee9c0ff8ba | ||
|
|
88dd98da71 | ||
|
|
57cbd6e121 | ||
|
|
0ec451d274 | ||
|
|
2658d10d3a | ||
|
|
be13e430dc | ||
|
|
3f89ecb03b | ||
|
|
cac55d63d1 | ||
|
|
9643e178dc | ||
|
|
cb63d5b19f | ||
|
|
b674f9a068 | ||
|
|
343dbb7490 | ||
|
|
e95a63befd | ||
|
|
63098d52cb | ||
|
|
a3546ecfa5 | ||
|
|
516700c45f | ||
|
|
661e636f40 | ||
|
|
9615e9d465 | ||
|
|
c49341c740 | ||
|
|
41b4b7d612 | ||
|
|
41ff44ecfc | ||
|
|
aeefe064a4 | ||
|
|
61e60518bd | ||
|
|
66c8d6208c | ||
|
|
21fa9cdf6a | ||
|
|
8642c3a699 | ||
|
|
e0e041dea7 | ||
|
|
c1d0010c4a | ||
|
|
85d7d2a87f | ||
|
|
7f54082cfa | ||
|
|
d6ce0e15bb | ||
|
|
e7dd63887e | ||
|
|
4044a1a309 | ||
|
|
0a538f3274 | ||
|
|
c1beb6d14c | ||
|
|
b6775d0543 | ||
|
|
611fbfc558 | ||
|
|
ba0e0a7b8f | ||
|
|
0b24393480 | ||
|
|
40cd3bea08 | ||
|
|
f1b3f9ac25 | ||
|
|
081f7c6241 | ||
|
|
ab8d0e85d2 | ||
|
|
33ced6aba0 | ||
|
|
27dec9adf8 | ||
|
|
b372f0a7f8 | ||
|
|
b80f3b9995 | ||
|
|
bfd5bd45f1 | ||
|
|
22a581ff5f | ||
|
|
b9c9d9d187 | ||
|
|
3304889e39 | ||
|
|
13a813c703 | ||
|
|
285b82d76e | ||
|
|
5b32269e81 | ||
|
|
11a9f56d5a | ||
|
|
4257a0b809 | ||
|
|
8572b0b5e4 | ||
|
|
d76264b05d | ||
|
|
4a2f1d19a6 | ||
|
|
6fd24be817 | ||
|
|
f9b9248d45 | ||
|
|
7e94cef894 | ||
|
|
d3b2f61b09 | ||
|
|
0aa6e01571 | ||
|
|
f72ebdecd6 | ||
|
|
8b9c901f8c | ||
|
|
bc77387f58 | ||
|
|
b3885c16e3 | ||
|
|
61838ed0dc | ||
|
|
c3c28d6b3b | ||
|
|
bedbdba511 | ||
|
|
2e62e31e0c | ||
|
|
d735d1d4b3 | ||
|
|
744f7133d9 | ||
|
|
7617ab7bfd | ||
|
|
7127d2aca5 | ||
|
|
070e01720d | ||
|
|
ab3caff2d0 | ||
|
|
c8c140a9d6 | ||
|
|
634784840e | ||
|
|
923ee81055 | ||
|
|
24e2773198 | ||
|
|
e087af08b2 | ||
|
|
b3158671ee | ||
|
|
a0e6ece014 | ||
|
|
8a63cf6f63 | ||
|
|
f5e0598738 | ||
|
|
b867e9dd82 | ||
|
|
9d0aa81004 | ||
|
|
ea2dd0819a | ||
|
|
b064ae303f | ||
|
|
3f248973ad | ||
|
|
11f7531346 | ||
|
|
029f319454 | ||
|
|
554a76dd97 | ||
|
|
0d401ccf69 | ||
|
|
b5bdc783bb | ||
|
|
c2dec3ff19 | ||
|
|
20a7422cca | ||
|
|
810c47681d | ||
|
|
cb2e008c75 | ||
|
|
caf769a1cc | ||
|
|
20e12d68da | ||
|
|
3fbd0587ac | ||
|
|
bfd00a53a9 | ||
|
|
6c1b0c380c | ||
|
|
336ad9383e | ||
|
|
6ce7553230 | ||
|
|
a7514171da | ||
|
|
a89e4c67a0 | ||
|
|
53f95b78c6 | ||
|
|
df721618f3 | ||
|
|
870be0ebb8 | ||
|
|
483f718012 | ||
|
|
9f128ba5b7 | ||
|
|
6ed1c9ac6b | ||
|
|
7b5d30bab4 | ||
|
|
1bf173ed19 | ||
|
|
488992b1b2 | ||
|
|
cfeabb11b3 | ||
|
|
73e030549e | ||
|
|
1810916414 | ||
|
|
d77599f4a0 | ||
|
|
eb858b00f6 | ||
|
|
a5dcd7ee42 | ||
|
|
c1f0016bcf | ||
|
|
dd311d8d29 | ||
|
|
e7cc710016 | ||
|
|
3cc8c89ddb | ||
|
|
1cc683cd14 | ||
|
|
8d8f9e57d4 | ||
|
|
c9e0becfc8 | ||
|
|
0915d78f2e | ||
|
|
0081152d42 | ||
|
|
085eedc314 | ||
|
|
bf9023d2ce | ||
|
|
ccdd68d4ff | ||
|
|
25b354fbbe | ||
|
|
6a6fe28645 | ||
|
|
4d2424d326 | ||
|
|
09c9d96313 | ||
|
|
c61c30355f | ||
|
|
f5fa276383 | ||
|
|
9ebf9f577d | ||
|
|
faadfa79a9 | ||
|
|
75e698ea9b | ||
|
|
8650d04715 | ||
|
|
f2641606a6 | ||
|
|
b273218637 | ||
|
|
4b67db0dd8 | ||
|
|
56cadd897a | ||
|
|
9ca4b1098b | ||
|
|
208e32eb00 | ||
|
|
25ee45e4c3 | ||
|
|
6e7d5dabb9 | ||
|
|
63fffa9843 | ||
|
|
d05e5b5d4f | ||
|
|
f53fecc699 | ||
|
|
2c189b3c93 | ||
|
|
c304643b86 | ||
|
|
e0f2398a31 | ||
|
|
bf92d47c2c | ||
|
|
ae8c540013 | ||
|
|
903a535dd6 | ||
|
|
57b5bfc30d | ||
|
|
417c2862e9 | ||
|
|
0bb8070779 | ||
|
|
1f0dc529ce | ||
|
|
2aa4644825 | ||
|
|
41f77fba8c | ||
|
|
6c62424ce4 | ||
|
|
7213e51e3c | ||
|
|
96ece7bbd8 | ||
|
|
8ea319a68f | ||
|
|
97a6be8dc9 | ||
|
|
4fc61d4d44 | ||
|
|
10c7c15894 | ||
|
|
1f914ebadb | ||
|
|
0251b3d8f9 | ||
|
|
3bd28aa269 | ||
|
|
9ff6a864a4 | ||
|
|
3e0512ba59 | ||
|
|
d3f51690ed | ||
|
|
b496da2e50 | ||
|
|
4768eb27a1 | ||
|
|
b8c1e0ec38 | ||
|
|
322697d1e0 | ||
|
|
775797732d | ||
|
|
70f261c79e | ||
|
|
c69fcda6b7 | ||
|
|
fc8ba633c4 | ||
|
|
b2f3de5eff | ||
|
|
25e2981ef7 | ||
|
|
e138ed2e26 | ||
|
|
15d717ec6c | ||
|
|
2967bf400d | ||
|
|
a0ed458050 | ||
|
|
1792df7570 | ||
|
|
296dd33d4d | ||
|
|
3f27c7b789 | ||
|
|
06ecfb52b0 | ||
|
|
1436448b68 | ||
|
|
544c091179 | ||
|
|
7baf3f5ebb | ||
|
|
fb3bc88edb | ||
|
|
6a401a8233 | ||
|
|
97ecc8c871 | ||
|
|
3eaf1b5d18 | ||
|
|
512c40a97b | ||
|
|
9e01f775d5 | ||
|
|
64f41c7173 | ||
|
|
e39a38ab66 | ||
|
|
25aa1b3132 | ||
|
|
575d57efd4 | ||
|
|
8c0ed88dbe | ||
|
|
e08852d817 | ||
|
|
9523b6b392 | ||
|
|
d1d5934cee | ||
|
|
ed499bd9df | ||
|
|
8f8e463a28 | ||
|
|
3b752b0fb5 | ||
|
|
428f0b3517 | ||
|
|
fa419b9892 | ||
|
|
df89609b3e | ||
|
|
a8e858bb15 | ||
|
|
97e9aaf6db | ||
|
|
de56caaba2 | ||
|
|
59c32bc4bb | ||
|
|
2e04c70855 | ||
|
|
34d08bdea9 | ||
|
|
500c848422 | ||
|
|
e686680a81 | ||
|
|
79b9cce118 | ||
|
|
37b925f974 | ||
|
|
3431b3a007 | ||
|
|
cd99cf6079 | ||
|
|
0efb8bc2e2 | ||
|
|
74b5570411 | ||
|
|
79d8c489ae | ||
|
|
34b50346a0 | ||
|
|
3cb81c506c | ||
|
|
9d276403f4 | ||
|
|
bdc5fd50ac | ||
|
|
f512817f02 | ||
|
|
cb771d8c9a | ||
|
|
8dc29681ff | ||
|
|
2a2058f789 | ||
|
|
858d800d6d | ||
|
|
f877dd80da | ||
|
|
935dcd2876 | ||
|
|
a8d38e4d48 | ||
|
|
5893a3d95d | ||
|
|
99d251cbb1 | ||
|
|
102b9605bf | ||
|
|
129d0c26cf | ||
|
|
6957f2c901 | ||
|
|
73fada0560 | ||
|
|
eda11d24b8 | ||
|
|
ecdb8a73bd | ||
|
|
be2e78fe8a | ||
|
|
5d2bc5055e | ||
|
|
bf7ed63282 | ||
|
|
2a36299f1d | ||
|
|
1ee15c55e9 | ||
|
|
89d5eade51 | ||
|
|
595532922e | ||
|
|
84e679c871 | ||
|
|
aea0cc6fd0 | ||
|
|
7ed25d10b4 | ||
|
|
5163d60f67 | ||
|
|
05d96777cb | ||
|
|
a4edff132e | ||
|
|
67de9922b1 | ||
|
|
6c33986659 | ||
|
|
bb678e1987 | ||
|
|
b5c870bdb4 | ||
|
|
2c0899220e | ||
|
|
7f5edf116e | ||
|
|
7737d04fcc | ||
|
|
4d53e43dee | ||
|
|
b39f7d7a31 | ||
|
|
fbd5cb1098 | ||
|
|
fab46b2725 | ||
|
|
16ab46898b | ||
|
|
dd8d968506 | ||
|
|
cb30673e37 | ||
|
|
8e9e853a49 | ||
|
|
4774d6168b | ||
|
|
5dd6e87c42 | ||
|
|
8622b7b88c | ||
|
|
f2b47ebc34 | ||
|
|
0455163a92 | ||
|
|
0aaf1966dd | ||
|
|
f3fd4f20d5 | ||
|
|
fa3d78eb0a | ||
|
|
f50e0957f2 | ||
|
|
5f12a70c41 | ||
|
|
7cb87d7185 | ||
|
|
5439daad78 | ||
|
|
4fde6a2a57 | ||
|
|
e20c5f0eb9 | ||
|
|
28f60f059d | ||
|
|
67c446f8b2 | ||
|
|
20a7b137b6 | ||
|
|
a0ebcc200d | ||
|
|
d8a103495f | ||
|
|
963e82ec03 | ||
|
|
24438c9f3e | ||
|
|
845cefcb67 | ||
|
|
808c84df15 | ||
|
|
55b02def4e | ||
|
|
c60d549184 | ||
|
|
db8851aa9a | ||
|
|
a62daf7c8d | ||
|
|
f6cf255d19 | ||
|
|
abbc0430f8 | ||
|
|
8a21e8291f | ||
|
|
78160ca54b | ||
|
|
99863e6c7c | ||
|
|
312e6a9240 | ||
|
|
bfb6fac112 | ||
|
|
8729f9cdf3 | ||
|
|
fd6e22de75 | ||
|
|
6dd1d4f1e3 | ||
|
|
5fdff36113 | ||
|
|
c65b1aee20 | ||
|
|
938de94638 | ||
|
|
d513667c3f | ||
|
|
59fadaefa7 | ||
|
|
eb67904747 | ||
|
|
a56fd4d430 | ||
|
|
43d9ae3f2b | ||
|
|
795b75c861 | ||
|
|
1b9166bb24 | ||
|
|
50d8596104 | ||
|
|
05f3b9b416 | ||
|
|
3ab76d795f | ||
|
|
e6b7a6d74e | ||
|
|
f91eec298d | ||
|
|
3b2296960d | ||
|
|
b947a41ec3 | ||
|
|
1ba03d358a | ||
|
|
607c5babcb | ||
|
|
d96e9e39a0 | ||
|
|
a979e843df | ||
|
|
dd9911a883 | ||
|
|
8d20dae571 | ||
|
|
298a69758c | ||
|
|
265a8c90b3 | ||
|
|
bb998cce65 | ||
|
|
d4fa0b2b61 | ||
|
|
84f6b9ae52 | ||
|
|
f88a61bd8c | ||
|
|
bfd72acdb7 | ||
|
|
c0017a446a | ||
|
|
2487a3f499 | ||
|
|
ab9fbeae92 | ||
|
|
c8553a953d | ||
|
|
5619ce706f | ||
|
|
79e9b39964 | ||
|
|
41ad71c69f | ||
|
|
f1126f96fc | ||
|
|
7a46faa747 | ||
|
|
fc51371e55 | ||
|
|
9b1023c861 | ||
|
|
4592c40f4e | ||
|
|
567965ed50 | ||
|
|
acd2e28600 | ||
|
|
f2ac96a4c8 | ||
|
|
6a1693dcc6 | ||
|
|
b3b717d183 | ||
|
|
8fdbf36add | ||
|
|
f80a004ef6 | ||
|
|
36b7490f07 | ||
|
|
295d13a421 | ||
|
|
2711f8a613 | ||
|
|
41f7cd80b6 | ||
|
|
9a5267893e | ||
|
|
bd526715ea | ||
|
|
7b5d512e30 | ||
|
|
02d708f961 | ||
|
|
8fa4f9d555 | ||
|
|
8eb157d11d | ||
|
|
6a8971baab | ||
|
|
4953b2a860 | ||
|
|
a8e10a11f5 | ||
|
|
bf050e7301 | ||
|
|
beacb925f4 | ||
|
|
6f32222675 | ||
|
|
e2e5a3c198 | ||
|
|
a989a6bafc | ||
|
|
b8d701a56b | ||
|
|
2574d2f1f7 | ||
|
|
56947b47b8 | ||
|
|
8fe4b398cd | ||
|
|
5a3bff0630 | ||
|
|
27f459fff2 | ||
|
|
f954dac6a3 | ||
|
|
c1eace1d2e | ||
|
|
081e9d9204 | ||
|
|
5b81fd1ae3 | ||
|
|
895d09d04a | ||
|
|
a0f7599aa5 | ||
|
|
003f07b6a2 | ||
|
|
5d04af5d8d | ||
|
|
45422c0cf1 | ||
|
|
d7a27be0f2 | ||
|
|
1546a0bf23 | ||
|
|
9a429e48b2 | ||
|
|
5f4a7b6514 | ||
|
|
22da73bc0a | ||
|
|
f6482d5563 | ||
|
|
1fa8b45542 | ||
|
|
4f9a505e4c | ||
|
|
3c930bc172 | ||
|
|
11120774c3 | ||
|
|
532ac17049 | ||
|
|
891abbef44 | ||
|
|
30af657041 | ||
|
|
5441b3050f | ||
|
|
63e8c3d11f | ||
|
|
b5b7c10e94 | ||
|
|
715e368e80 | ||
|
|
54e4587eb4 | ||
|
|
c2a454ee0d | ||
|
|
7e6a94b163 | ||
|
|
03a8d28289 | ||
|
|
d71eb249ba | ||
|
|
69fb59c899 | ||
|
|
787d9b2dac | ||
|
|
20eb1fd7c1 | ||
|
|
9c709ac2f8 | ||
|
|
c1b8c2c534 | ||
|
|
0db605ef36 | ||
|
|
462f1e30f3 | ||
|
|
a4bed869dd | ||
|
|
b4e97540f3 | ||
|
|
e02179371b | ||
|
|
ec828d9993 | ||
|
|
aed32274ad | ||
|
|
fe2ebaed5b | ||
|
|
34e0e8f41a | ||
|
|
c3ae467da1 | ||
|
|
c74c98e4ff | ||
|
|
ea1a302fb1 | ||
|
|
b32a2f0eac | ||
|
|
729607fb68 | ||
|
|
7e06f188c4 | ||
|
|
f574a2caf6 | ||
|
|
dccc7acc73 | ||
|
|
908c408847 | ||
|
|
c19afe3496 | ||
|
|
a8addfb8b7 | ||
|
|
8dc39e144b | ||
|
|
e19b72965d | ||
|
|
17e9393e96 | ||
|
|
11072dcee3 | ||
|
|
dc31abeedd | ||
|
|
ede91aee21 | ||
|
|
1365b139ba | ||
|
|
972ad7012d | ||
|
|
491573946f | ||
|
|
3f05c90c14 | ||
|
|
2d17b29909 | ||
|
|
8e1c071f14 | ||
|
|
169820f8e7 | ||
|
|
199a43302e | ||
|
|
fd5704d128 | ||
|
|
2c8a7c0e7d | ||
|
|
83c5121b39 | ||
|
|
e4589968d5 | ||
|
|
9da6353a0a | ||
|
|
27650682f8 | ||
|
|
1642385558 | ||
|
|
c53a78e4d7 | ||
|
|
72a3d5623b | ||
|
|
10145f63c0 | ||
|
|
3f66824512 | ||
|
|
7ddc78a7d0 | ||
|
|
0f2ffc0856 | ||
|
|
d09f7abd2d | ||
|
|
8260b84477 | ||
|
|
71d7d1f163 | ||
|
|
3e87c14cb7 | ||
|
|
e37d817b19 | ||
|
|
6d54842fd7 | ||
|
|
e7ac9a0b8c | ||
|
|
9a8adfa445 | ||
|
|
72ad711cab | ||
|
|
4abe45a8e4 | ||
|
|
67df48e170 | ||
|
|
2f8fbf8a3e | ||
|
|
7ef57e5ad7 | ||
|
|
99ae84ade7 | ||
|
|
3f17dded78 | ||
|
|
422c39f6a1 | ||
|
|
c45594a3b5 | ||
|
|
33635792e8 | ||
|
|
016c5eb159 | ||
|
|
b2cbc94357 | ||
|
|
30f6b829a0 | ||
|
|
8dcc83b732 | ||
|
|
3e516d7f9b | ||
|
|
756e7034dc | ||
|
|
58bfe3f4a6 | ||
|
|
27ef110197 | ||
|
|
041502c285 | ||
|
|
24d38e58ca | ||
|
|
2271cc74fd | ||
|
|
7e940791a4 | ||
|
|
b30e808784 | ||
|
|
1fa0e2463b | ||
|
|
dedd99fed0 | ||
|
|
46e6aa621f | ||
|
|
b24d7d686b | ||
|
|
4fc624bf6f | ||
|
|
fe0944c057 | ||
|
|
712b630331 | ||
|
|
e17cb5601c | ||
|
|
bf5a1bd83d | ||
|
|
852e58e62d | ||
|
|
f3c25b4524 | ||
|
|
5e097c4cb5 | ||
|
|
2583ef1f90 | ||
|
|
f71c556db8 | ||
|
|
587d6547bd | ||
|
|
ae78b48f1b | ||
|
|
a91e862358 | ||
|
|
648f1f0c4d | ||
|
|
dd22535ef8 | ||
|
|
beb1de9545 | ||
|
|
0127ca4a5d | ||
|
|
5340d893e1 | ||
|
|
d528569268 | ||
|
|
3ff3c1cbef | ||
|
|
9146da919c | ||
|
|
c037dcf502 | ||
|
|
903abe0b16 | ||
|
|
4d2afa9dbe | ||
|
|
974f01f3b6 | ||
|
|
f7aa34bda1 | ||
|
|
efe0688b29 | ||
|
|
04719048ea | ||
|
|
2e4699b6a6 | ||
|
|
4026cc3a8b | ||
|
|
201f3187ae | ||
|
|
c1a56258e0 | ||
|
|
6bd8b705be | ||
|
|
7d8f3e3e2c | ||
|
|
5db4bb9c83 | ||
|
|
cbd4ceb48b | ||
|
|
d289faead6 | ||
|
|
75f880efca | ||
|
|
5d16951788 | ||
|
|
d763279734 | ||
|
|
f021dcc365 | ||
|
|
88348f3231 | ||
|
|
50f15f6f83 | ||
|
|
8c4914c2eb | ||
|
|
c3c1afa1cd | ||
|
|
999fca4bda | ||
|
|
3c0615ea4a | ||
|
|
38a2289995 | ||
|
|
394a77e611 | ||
|
|
cc0758268e | ||
|
|
e80ee038a8 | ||
|
|
c38af44f77 | ||
|
|
321c3d55ee | ||
|
|
d9a7a6975c | ||
|
|
ed0942d768 | ||
|
|
c06bf4cf65 | ||
|
|
27e738bcc7 | ||
|
|
9e33bdc029 | ||
|
|
b2574a7388 | ||
|
|
102dc88eef | ||
|
|
b75761f044 | ||
|
|
67cd1dc16d | ||
|
|
d51b186dc5 | ||
|
|
d1c2c06e65 | ||
|
|
f452618612 | ||
|
|
027f36d790 | ||
|
|
aaf4d85521 | ||
|
|
4d04820aa9 | ||
|
|
70dc3edb70 | ||
|
|
a29ddb0872 | ||
|
|
03feb833c2 | ||
|
|
36c04bdc1f | ||
|
|
08f175634b | ||
|
|
820f32a875 | ||
|
|
535db2216d | ||
|
|
5d8b42a381 | ||
|
|
83f25c9462 | ||
|
|
d0441a3409 | ||
|
|
2295a1328a | ||
|
|
534a5772a9 | ||
|
|
0975880eba | ||
|
|
c519ec2a1e | ||
|
|
4348e36a75 | ||
|
|
8e7df85e57 | ||
|
|
91d35ff15f | ||
|
|
800f7f5361 | ||
|
|
340cd7a6df | ||
|
|
8e47ca01a2 | ||
|
|
a3cc232da8 | ||
|
|
deb74eaf87 | ||
|
|
e0e3413209 | ||
|
|
4c8aad4ebb | ||
|
|
fdf98c1c21 | ||
|
|
813a717ce9 | ||
|
|
2e3cd38034 | ||
|
|
438a6f03b1 | ||
|
|
563d22a209 | ||
|
|
6947d41101 | ||
|
|
2e1f38f4b4 | ||
|
|
553761a8a7 | ||
|
|
97848b7018 | ||
|
|
e6a4121918 | ||
|
|
13352db959 | ||
|
|
cfaa913877 | ||
|
|
c66928f4f0 | ||
|
|
b16d89862d | ||
|
|
a0741af53c | ||
|
|
30bca0279e | ||
|
|
1f7d82a0bd | ||
|
|
1b31f2ccc9 | ||
|
|
65229b7d29 | ||
|
|
0fbdbc5a86 | ||
|
|
bfc500dfe7 | ||
|
|
86a51d1684 | ||
|
|
0eac87ba25 | ||
|
|
85f08bf062 | ||
|
|
837f6d07fd | ||
|
|
3f16756d35 | ||
|
|
6295521022 | ||
|
|
fb11a89635 | ||
|
|
4a8bdb61af | ||
|
|
292269e4a1 | ||
|
|
253623c263 | ||
|
|
5b3b789726 | ||
|
|
8155afaa4e | ||
|
|
095be4dd83 | ||
|
|
85a0e7f9ce | ||
|
|
1f8978e61e | ||
|
|
aafbd91dc4 | ||
|
|
5e6d05da53 | ||
|
|
0e80f7bc36 | ||
|
|
daea3045d8 | ||
|
|
6b0cd3142b | ||
|
|
fe88c1f418 | ||
|
|
cc1a262757 | ||
|
|
1f6d319959 | ||
|
|
3ab9ce57a4 | ||
|
|
1630b3f4fa | ||
|
|
5692e56287 | ||
|
|
b2c9d541a4 | ||
|
|
2ba939f586 | ||
|
|
538e5224e1 | ||
|
|
b6f660ee26 | ||
|
|
4f7abdc58b | ||
|
|
6d48587320 | ||
|
|
85592330e2 | ||
|
|
e8114d5d3e | ||
|
|
fc5dc103b8 | ||
|
|
9e787d4c31 | ||
|
|
487f4d1c22 | ||
|
|
b6fc94d2e0 | ||
|
|
327315068c | ||
|
|
d26c4cc450 | ||
|
|
465e14530f | ||
|
|
27dbf50d80 | ||
|
|
1198b0e922 | ||
|
|
d5354838e6 | ||
|
|
8d7aabb29e | ||
|
|
55afea6641 | ||
|
|
0aa9b3882c | ||
|
|
b6636c1b33 | ||
|
|
dd198e2246 | ||
|
|
22b15e7cc1 | ||
|
|
34229ac4c3 | ||
|
|
4308f38d53 | ||
|
|
d2c5016988 | ||
|
|
950209e4c7 | ||
|
|
2810cb4f3f | ||
|
|
83a55e6d42 | ||
|
|
4171740a65 | ||
|
|
eb27fb81c9 | ||
|
|
98c998b838 | ||
|
|
feb3dc53cb | ||
|
|
049935b60b | ||
|
|
9d68631a74 | ||
|
|
8b7958bbb6 | ||
|
|
b403afc692 | ||
|
|
383e395535 | ||
|
|
e1bb4efbda | ||
|
|
7cbdd88364 | ||
|
|
02c6d57e76 | ||
|
|
67d0601fef | ||
|
|
a15be81f08 | ||
|
|
b192881de0 | ||
|
|
509d81b012 | ||
|
|
9cfcc1f72a | ||
|
|
6018181a35 | ||
|
|
fbeeb0a5c3 | ||
|
|
7fdcbd70d0 | ||
|
|
aad724d1a8 | ||
|
|
ebf49e189b | ||
|
|
d63a356da5 | ||
|
|
274271f80a | ||
|
|
4c03293c97 | ||
|
|
89afe5614b | ||
|
|
c61bb6df99 | ||
|
|
146bfe33d0 | ||
|
|
313e4620a4 | ||
|
|
0cc6f5d23a | ||
|
|
28a2385104 | ||
|
|
5038dd93a1 | ||
|
|
316a1b6292 | ||
|
|
06fc790387 | ||
|
|
381073c764 | ||
|
|
ae5702abf0 | ||
|
|
0415c75e9d | ||
|
|
2c3a354009 | ||
|
|
28465bb1a6 | ||
|
|
fd6e34b0c4 | ||
|
|
6dd3a6af57 | ||
|
|
78167274b1 | ||
|
|
7f6c42b853 | ||
|
|
fce245316a | ||
|
|
77981e1f01 | ||
|
|
700c48816c | ||
|
|
04ac2c476f | ||
|
|
a48e9e5a49 | ||
|
|
2d124b8f36 | ||
|
|
a16c83a51c | ||
|
|
20f65744a4 | ||
|
|
53284913fa | ||
|
|
865fdb4c9a | ||
|
|
8ba5f214fb | ||
|
|
cd63d475ad | ||
|
|
f3e4d311d3 | ||
|
|
23597cec0a | ||
|
|
641ad86f1d | ||
|
|
d2e7ee7232 | ||
|
|
8b359ac378 | ||
|
|
b2f01d90c9 | ||
|
|
068a41d8e6 | ||
|
|
70051fe5f2 | ||
|
|
af9aa8df76 | ||
|
|
b5592d6f91 | ||
|
|
ea219b8c5b | ||
|
|
df1787a833 | ||
|
|
9417f76547 | ||
|
|
b8b8b85c8b | ||
|
|
39af981753 | ||
|
|
02810a821f | ||
|
|
9c742fc313 | ||
|
|
984e5aa953 | ||
|
|
1264551c8a | ||
|
|
41561b1dc1 | ||
|
|
db2971a5d9 | ||
|
|
134142453c | ||
|
|
29c4dc2f2b | ||
|
|
8e3bbea28b | ||
|
|
fbda167795 | ||
|
|
eb9089a3ec | ||
|
|
cf1c863525 | ||
|
|
49902e915b | ||
|
|
2b83c4bb42 | ||
|
|
0afefdefec | ||
|
|
513bf1c312 | ||
|
|
9fe0925f92 | ||
|
|
b2cd9f35ce | ||
|
|
d9018ff7bb | ||
|
|
2567f75a55 | ||
|
|
c500bc53ee | ||
|
|
7c9dc58791 | ||
|
|
8e5cee8d06 | ||
|
|
c0cca6f238 | ||
|
|
534bc291bc | ||
|
|
b2faee1248 | ||
|
|
36eb9fd89a | ||
|
|
4767ca00e2 | ||
|
|
2e450082be | ||
|
|
755ac8de7a | ||
|
|
8099e23dfb | ||
|
|
deb2c7f921 | ||
|
|
a296498102 | ||
|
|
8828d53fd7 | ||
|
|
1b3ccb41c0 | ||
|
|
eb802e01a6 | ||
|
|
698e6d274a | ||
|
|
d5b65d2910 | ||
|
|
dd7bb45af8 | ||
|
|
aaa6cdf574 | ||
|
|
91163b657a | ||
|
|
be07eb606b | ||
|
|
d03db34406 | ||
|
|
827d865b9b | ||
|
|
8fa03b6478 | ||
|
|
b4cb599c27 | ||
|
|
b939428949 | ||
|
|
afed0f86ea | ||
|
|
fa1430f1a8 | ||
|
|
b57525fabe | ||
|
|
8dd9905606 | ||
|
|
976c195a71 | ||
|
|
cdc1e286a4 | ||
|
|
5dd984e21f | ||
|
|
39f94615c6 | ||
|
|
c5f44440b3 | ||
|
|
737533d3db | ||
|
|
90ca4ef236 | ||
|
|
3fb08b35ab | ||
|
|
13212ec6e5 | ||
|
|
f04e19a001 | ||
|
|
d73f46742a | ||
|
|
5fa9d980f3 | ||
|
|
06e3a4eb4c | ||
|
|
d632f6e95f | ||
|
|
78d83a9f24 | ||
|
|
584a1182c0 | ||
|
|
ddf5acc9ef | ||
|
|
49f14fdd98 | ||
|
|
80fb83a431 | ||
|
|
bd2102ac20 | ||
|
|
ecb0a564fe | ||
|
|
9da4452311 | ||
|
|
5c8eb2603f | ||
|
|
6ea221f67e | ||
|
|
9b77397563 | ||
|
|
5f63b65017 | ||
|
|
1f8b5e583f | ||
|
|
c6bb72ca85 | ||
|
|
00d7bb5c84 | ||
|
|
0f33cfb647 | ||
|
|
5013fb703a | ||
|
|
92fc5a65ee | ||
|
|
3b31cb444b | ||
|
|
fa266c315e | ||
|
|
99d685a27a | ||
|
|
79ebba3319 | ||
|
|
482ebf07cf | ||
|
|
c8fc81655b | ||
|
|
65e0500094 | ||
|
|
0635405c64 | ||
|
|
9475349028 | ||
|
|
c26cf84d15 | ||
|
|
6e444c2129 | ||
|
|
c184868ee4 | ||
|
|
764468333a | ||
|
|
6040ddebd3 | ||
|
|
ffb9cd9ed4 | ||
|
|
a96b6ff8cc | ||
|
|
10e6f73dc3 | ||
|
|
4606250025 | ||
|
|
0b37b43932 | ||
|
|
8f6042d342 | ||
|
|
28eb577ee9 | ||
|
|
dbac204a35 | ||
|
|
be0b6f7e48 | ||
|
|
b108f47c7b | ||
|
|
a28ad43a50 | ||
|
|
1891b404c0 | ||
|
|
18b59372aa | ||
|
|
b18851a0ff | ||
|
|
eb6fcd5229 | ||
|
|
a8ca6f442f | ||
|
|
c81c2dfe19 | ||
|
|
82765be52d | ||
|
|
5666f55117 | ||
|
|
0557912621 | ||
|
|
1733b75837 | ||
|
|
101fc39e0f | ||
|
|
2cf2cfac45 | ||
|
|
2ac1caa944 | ||
|
|
47d5c70d02 | ||
|
|
2cbe4355fc | ||
|
|
c087f6fe10 | ||
|
|
18ca7518c3 | ||
|
|
0939bf8ff7 | ||
|
|
77a0119309 | ||
|
|
5f7a2e671c | ||
|
|
bd22cf47df | ||
|
|
64d4499627 | ||
|
|
4d250f6ee8 | ||
|
|
4951c74712 | ||
|
|
6610c58c7c | ||
|
|
1e16325daa | ||
|
|
8492e6be56 | ||
|
|
c80ce12f93 | ||
|
|
2b2d20e06e | ||
|
|
8fbf5c2b41 | ||
|
|
44cfd1d5cb | ||
|
|
1b26e17dc9 | ||
|
|
e093d73077 | ||
|
|
a82eabfe9d | ||
|
|
06b488221e | ||
|
|
c4b07a935d | ||
|
|
9d7e75c02a | ||
|
|
a5d4c45cda | ||
|
|
c5d09133e5 | ||
|
|
209bda2bac | ||
|
|
4a072f4664 | ||
|
|
3c4b6b84a9 | ||
|
|
4819dfca35 | ||
|
|
076cdc759f | ||
|
|
644ce7926c |
4
.github/helpers/update_pot_file.sh
vendored
4
.github/helpers/update_pot_file.sh
vendored
@@ -5,7 +5,7 @@ cd ~ || exit
|
||||
echo "Setting Up Bench..."
|
||||
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch develop
|
||||
|
||||
cd ./frappe-bench || exit
|
||||
|
||||
@@ -38,4 +38,4 @@ gh auth setup-git
|
||||
git push -u upstream "${branch_name}"
|
||||
|
||||
echo "Creating a PR..."
|
||||
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/helpdesk
|
||||
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/helpdesk
|
||||
|
||||
15
.github/workflows/assets.yml
vendored
Normal file
15
.github/workflows/assets.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Pre-build assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop, main]
|
||||
|
||||
jobs:
|
||||
assets:
|
||||
name: Pre-build assets
|
||||
uses: frappe/pilot/.github/workflows/app-assets.yml@develop
|
||||
with:
|
||||
# main supports Frappe v15 and v16; Pilot benches run v16 or later.
|
||||
frappe-branch: ${{ github.ref_name == 'main' && 'version-16' || 'develop' }}
|
||||
permissions:
|
||||
contents: write
|
||||
29
.github/workflows/build.yml
vendored
29
.github/workflows/build.yml
vendored
@@ -13,21 +13,15 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Entire Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
@@ -38,8 +32,7 @@ jobs:
|
||||
|
||||
- name: Set Branch
|
||||
run: |
|
||||
export APPS_JSON='[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]'
|
||||
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
|
||||
echo '[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]' > apps.json
|
||||
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Image Tag
|
||||
@@ -51,10 +44,10 @@ jobs:
|
||||
repository: frappe/frappe_docker
|
||||
path: builds
|
||||
|
||||
- name: Build and push
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
load: true
|
||||
context: builds
|
||||
file: builds/images/layered/Containerfile
|
||||
tags: >
|
||||
@@ -62,4 +55,14 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
|
||||
build-args: |
|
||||
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
secret-files: |
|
||||
apps_json=apps.json
|
||||
|
||||
- name: Verify apps are installed
|
||||
run: |
|
||||
apps=$(docker run --rm --entrypoint ls ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} apps)
|
||||
echo "$apps"
|
||||
grep -qx helpdesk <<< "$apps" && grep -qx telephony <<< "$apps"
|
||||
|
||||
- name: Push
|
||||
run: docker push --all-tags ghcr.io/${{ github.repository }}
|
||||
|
||||
2
.github/workflows/generate-pot-file.yml
vendored
2
.github/workflows/generate-pot-file.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch: ["develop"]
|
||||
branch: ["develop", "main-hotfix"]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
187
.github/workflows/ui-tests.yml
vendored
Normal file
187
.github/workflows/ui-tests.yml
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
name: UI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- "desk/**"
|
||||
- "e2e/**"
|
||||
- "playwright.config.ts"
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- ".github/workflows/ui-tests.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ui-tests-helpdesk-${{ github.event.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FRAPPE_BRANCH: develop
|
||||
SITE: helpdesk.test
|
||||
|
||||
jobs:
|
||||
ui-tests:
|
||||
name: Playwright E2E Tests (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
|
||||
services:
|
||||
redis-cache:
|
||||
image: redis/redis-stack-server:latest
|
||||
ports:
|
||||
- 13000:6379
|
||||
redis-queue:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 11000:6379
|
||||
mariadb:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-
|
||||
|
||||
- name: Cache yarn
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/yarn
|
||||
key: ${{ runner.os }}-yarn-ui-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-ui-
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: Install MariaDB Client
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y mariadb-client
|
||||
|
||||
- name: Setup Bench
|
||||
run: |
|
||||
pip install frappe-bench
|
||||
bench init --skip-redis-config-generation --skip-assets --frappe-branch "$FRAPPE_BRANCH" --python "$(which python)" ~/frappe-bench
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
- name: Install Helpdesk
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
bench get-app telephony
|
||||
bench get-app helpdesk $GITHUB_WORKSPACE
|
||||
bench setup requirements --dev
|
||||
bench new-site --db-root-password root --admin-password admin --install-app helpdesk $SITE
|
||||
bench use $SITE
|
||||
bench --site $SITE set-config host_name "http://localhost:8000"
|
||||
bench build --apps frappe,helpdesk
|
||||
env:
|
||||
CI: "Yes"
|
||||
|
||||
- name: Start Frappe Server
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
sed -i 's/^watch:/# watch:/g' Procfile
|
||||
sed -i 's/^schedule:/# schedule:/g' Procfile
|
||||
bench start &> bench_start.log &
|
||||
timeout 120 bash -c "until curl -s http://localhost:8000 > /dev/null; do sleep 2; done"
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
yarn install --frozen-lockfile --ignore-scripts
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright Tests
|
||||
run: npx playwright test --shard=${{ matrix.shard }}/2
|
||||
env:
|
||||
# localhost is a secure context, which crypto.randomUUID in frappe/ui needs.
|
||||
BASE_URL: http://localhost:8000
|
||||
ADMIN_PASSWORD: admin
|
||||
|
||||
- name: Upload Blob Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-${{ matrix.shard }}
|
||||
path: blob-report/
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results-${{ matrix.shard }}
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Show Bench Logs on Failure
|
||||
if: failure()
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
cat bench_start.log || true
|
||||
cat logs/*.log || true
|
||||
|
||||
merge-reports:
|
||||
name: Merge Playwright Reports
|
||||
needs: ui-tests
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install Playwright
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Download Blob Reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge Into HTML Report
|
||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -32,6 +32,7 @@ share/python-wheels/
|
||||
.playwright-mcp
|
||||
.claude-flow
|
||||
.claude
|
||||
.claude-worktrees
|
||||
CLAUDE.md
|
||||
|
||||
|
||||
@@ -41,4 +42,8 @@ helpdesk/public/frontend
|
||||
helpdesk/public/node_modules
|
||||
helpdesk/www/helpdesk/index.html
|
||||
desk/components.d.ts
|
||||
desk/stats.html
|
||||
desk/stats.html
|
||||
# Playwright
|
||||
e2e/.auth
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
85
.mergify.yml
85
.mergify.yml
@@ -1,45 +1,42 @@
|
||||
pull_request_rules:
|
||||
- name: Auto-close PRs on stable branch
|
||||
conditions:
|
||||
- and:
|
||||
- and:
|
||||
- author!=RitvikSardana
|
||||
- author!=frappe-pr-bot
|
||||
- author!=mergify[bot]
|
||||
- or:
|
||||
- base=main
|
||||
actions:
|
||||
close:
|
||||
comment:
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
|
||||
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label="backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label="backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: Auto-close PRs on stable branch
|
||||
conditions:
|
||||
- and:
|
||||
- author != RitvikSardana
|
||||
- author != frappe-pr-bot
|
||||
- author != mergify[bot]
|
||||
- author != aerodeval
|
||||
- or:
|
||||
- base = main
|
||||
actions:
|
||||
close:
|
||||
comment:
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label = "backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label = "backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label = "backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -157,8 +157,12 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
|
||||
### Icons
|
||||
|
||||
- **Primary icon system**: Lucide icons via unplugin-icons for consistency and modern design
|
||||
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage, prefer Lucide for new components)
|
||||
- Use Lucide icons for all new components:
|
||||
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage only)
|
||||
- **Rule**: Never introduce `FeatherIcon` in new code. New components must use Lucide
|
||||
icons (`<LucideInfo />`, `<LucidePlus />`, …) — no exceptions, including small
|
||||
helper icons like tooltip/info glyphs.
|
||||
- Use Lucide icons for all new components. They are globally auto-registered
|
||||
(see `desk/components.d.ts`), so no import is needed when used as a template tag:
|
||||
```vue
|
||||
<template>
|
||||
<LucidePlus class="size-4" />
|
||||
@@ -211,7 +215,9 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
|
||||
|
||||
**Data Query Patterns**:
|
||||
|
||||
- Always prefer `frappe.qb.get_query()` over `frappe.db.get_all()` for new code
|
||||
- Always prefer `frappe.get_list()` over `frappe.get_all()` for new code. It handles perms out of the box and is more flexible.
|
||||
- `frappe.get_all()` is fine for child tables (e.g. `Has Role`, `HD Customer Member`), which have no permissions of their own.
|
||||
- When `frappe.get_all()` is used on a parent DocType, make sure the caller may see the result some other way, for example a `frappe.has_permission(...)` check first, or `@agent_only` / `@agent_manager_only` (`helpdesk/utils.py`) on the endpoint.
|
||||
- Use `frappe.qb.get_query(..., ignore_permissions=False)` when permission checks are needed
|
||||
- Example query patterns from helpdesk context
|
||||
|
||||
@@ -261,6 +267,7 @@ class HDFoo(Document):
|
||||
- Methods that mutate a related document (e.g. an Assignment Rule) receive that doc as an argument and mutate it in memory. The **caller** is responsible for the final `doc.save()` — this keeps saves to a minimum (one save per hook).
|
||||
- Never fetch the same document twice in one hook. Fetch once, pass the doc through the call chain.
|
||||
- `@staticmethod` is allowed for pure transformations (e.g. building a condition string) that don't touch `self` or any Frappe doc.
|
||||
- If there is an issue in framework/ui or frappe-ui or frappe framework, dont do a workaround. Instead, raise a blocker in the spec and wait for a fix. Do not implement workarounds that will be removed later. If possible, raise a PR to fix the issue in the framework instead of implementing a workaround in helpdesk.
|
||||
|
||||
**Save discipline:**
|
||||
|
||||
@@ -367,7 +374,7 @@ const ticket = createDocumentResource({
|
||||
- Use ESLint with Vue 3 recommended rules and Prettier for consistency
|
||||
- Write meaningful commit messages and maintain clean git history
|
||||
- Document complex logic with JSDoc/TSDoc comments
|
||||
- Only add comments that explain why something is done, not what is done
|
||||
- Keep a comment to 2 lines at most. If it needs more to explain, simplify the code instead
|
||||
- Keep dependencies updated and audit for security vulnerabilities
|
||||
|
||||
## References
|
||||
|
||||
3
desk/auto-imports.d.ts
vendored
3
desk/auto-imports.d.ts
vendored
@@ -7,5 +7,8 @@
|
||||
export {}
|
||||
declare global {
|
||||
const LucideAlignJustify: typeof import('~icons/lucide/align-justify')['default']
|
||||
const LucideCornerUpLeft: typeof import('~icons/lucide/corner-up-left')['default']
|
||||
const LucideFileSearch: typeof import('~icons/lucide/file-search')['default']
|
||||
const LucideMessageCircle: typeof import('~icons/lucide/message-circle')['default']
|
||||
const LucideTag: typeof import('~icons/lucide/tag')['default']
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
"serve": "vite preview",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@framework/ui": "link:../../frappe/ui",
|
||||
"@headlessui/vue": "^1.7.22",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tiptap/core": "^3.26.0",
|
||||
@@ -19,12 +21,13 @@
|
||||
"@tiptap/starter-kit": "^3.26.0",
|
||||
"@twilio/voice-sdk": "^2.15.0",
|
||||
"@vee-validate/zod": "^4.8.2",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vueuse/core": "^13.8.0",
|
||||
"@vueuse/integrations": "^13.8.0",
|
||||
"@vitejs/plugin-vue": "^4.6.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/integrations": "^14.1.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"frappe-ui": "1.0.0-beta.14",
|
||||
"gemoji": "^8.1.0",
|
||||
"echarts": "^6.1.0",
|
||||
"grid-layout-plus": "^1.1.0",
|
||||
"frappe-ui": "1.0.0-rc.1",
|
||||
"mime": "^3.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"sanitize-html": "^2.10.0",
|
||||
@@ -36,17 +39,23 @@
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue-jsx": "^3.0.1",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.4.5",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "2.8.4",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-pwa": "0.15.0"
|
||||
"unplugin-icons": "^22.1.0",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-pwa": "0.17.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"prosemirror-model": "1.25.4"
|
||||
"prosemirror-model": "1.25.9",
|
||||
"prosemirror-state": "1.4.4",
|
||||
"prosemirror-view": "1.41.9",
|
||||
"prosemirror-transform": "1.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
70
desk/patches/frappe-ui+1.0.0-rc.1.patch
Normal file
70
desk/patches/frappe-ui+1.0.0-rc.1.patch
Normal file
@@ -0,0 +1,70 @@
|
||||
diff --git a/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts b/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
|
||||
index 91c8dc4..2883b93 100644
|
||||
--- a/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
|
||||
+++ b/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@tiptap/suggestion'
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
+ autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
@@ -40,6 +41,9 @@ export function createSuggestionRenderer(
|
||||
let isActive = false
|
||||
// Monotonic token; a stale onUpdate from a superseded query bails.
|
||||
let renderToken = 0
|
||||
+ // Monotonic token; a stale computePosition settling later bails.
|
||||
+ let positionToken = 0
|
||||
+ let cleanupAutoUpdate: (() => void) | null = null
|
||||
|
||||
function getListExpose(): SuggestionListExpose | null {
|
||||
const ref = renderer?.ref as SuggestionListExpose | null | undefined
|
||||
@@ -52,6 +56,7 @@ export function createSuggestionRenderer(
|
||||
const rect = getReferenceClientRect()
|
||||
if (!rect) return
|
||||
const reference = { getBoundingClientRect: () => rect }
|
||||
+ const token = ++positionToken
|
||||
void computePosition(reference, floatingEl, {
|
||||
placement: floatingOptions?.placement ?? 'bottom-start',
|
||||
middleware: [
|
||||
@@ -60,7 +65,7 @@ export function createSuggestionRenderer(
|
||||
shift({ padding: 8 }),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
- if (!floatingEl) return
|
||||
+ if (!floatingEl || token !== positionToken) return
|
||||
Object.assign(floatingEl.style, {
|
||||
position: 'absolute',
|
||||
left: `${x}px`,
|
||||
@@ -93,7 +98,19 @@ export function createSuggestionRenderer(
|
||||
floatingEl.style.position = 'absolute'
|
||||
document.body.appendChild(floatingEl)
|
||||
getReferenceClientRect = props.clientRect as () => DOMRect | null
|
||||
- updatePosition()
|
||||
+
|
||||
+ // Positions once, then keeps the popup on the caret through scroll, window
|
||||
+ // resize and its own height changes (`flip()` measures a height that async
|
||||
+ // rows and late fonts change after the fact).
|
||||
+ cleanupAutoUpdate = autoUpdate(
|
||||
+ {
|
||||
+ getBoundingClientRect: () =>
|
||||
+ getReferenceClientRect?.() ?? new DOMRect(),
|
||||
+ contextElement: props.editor?.view?.dom as HTMLElement | undefined,
|
||||
+ },
|
||||
+ floatingEl,
|
||||
+ updatePosition,
|
||||
+ )
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -139,6 +156,9 @@ export function createSuggestionRenderer(
|
||||
onExit() {
|
||||
isActive = false
|
||||
renderToken++
|
||||
+ positionToken++
|
||||
+ cleanupAutoUpdate?.()
|
||||
+ cleanupAutoUpdate = null
|
||||
floatingEl?.remove()
|
||||
renderer?.destroy()
|
||||
floatingEl = null
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<PortalRoot />
|
||||
<router-view />
|
||||
</FrappeUIProvider>
|
||||
<Dialogs />
|
||||
</template>
|
||||
@@ -9,12 +9,11 @@
|
||||
import { Dialogs } from "@/components/dialogs";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { useFavicon } from "@vueuse/core";
|
||||
import { FrappeUIProvider, setConfig, toast, useTheme } from "frappe-ui";
|
||||
import { FrappeUIProvider, setConfig, toast, useColorScheme } from "frappe-ui";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, defineAsyncComponent, h, onMounted } from "vue";
|
||||
import { h, onMounted } from "vue";
|
||||
import Wifi from "~icons/lucide/wifi";
|
||||
import WifiOff from "~icons/lucide/wifi-off";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import { __ } from "./translation";
|
||||
import { isCustomerPortal } from "./utils";
|
||||
|
||||
@@ -26,39 +25,21 @@ useFavicon(favicon);
|
||||
if (!localStorage.getItem("theme")) {
|
||||
localStorage.setItem("theme", "light");
|
||||
}
|
||||
useTheme();
|
||||
useColorScheme();
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("online", () => {
|
||||
toast.create({
|
||||
message: __("You are now online."),
|
||||
icon: h(Wifi, { class: "text-ink-base" }),
|
||||
toast(__("You are now online."), {
|
||||
icon: () => h(Wifi, { class: "text-ink-base" }),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
toast.create({
|
||||
message: __("You are now offline."),
|
||||
icon: h(WifiOff, { class: "text-ink-base" }),
|
||||
toast(__("You are now offline."), {
|
||||
icon: () => h(WifiOff, { class: "text-ink-base" }),
|
||||
});
|
||||
});
|
||||
!isCustomerPortal.value && setConfig("localTimezone", window.timezone?.user);
|
||||
setConfig("systemTimezone", window.timezone?.system || null);
|
||||
});
|
||||
|
||||
const AgentPortalRoot = defineAsyncComponent(
|
||||
() => import("@/roots/AgentRoot.vue")
|
||||
);
|
||||
const CustomerPortalRoot = defineAsyncComponent(
|
||||
() => import("@/roots/CustomerPortalRoot.vue")
|
||||
);
|
||||
|
||||
const PortalRoot = computed(() => {
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.hasDeskAccess && authStore.isAgent) {
|
||||
return AgentPortalRoot;
|
||||
} else {
|
||||
return CustomerPortalRoot;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<span>
|
||||
<a :href="isShowable ? undefined : url" target="_blank">
|
||||
<a :href="preview ? undefined : url" target="_blank">
|
||||
<Button
|
||||
:label="label"
|
||||
theme="gray"
|
||||
@@ -18,12 +18,22 @@
|
||||
<Dialog v-model:open="showDialog" :title="label" size="4xl">
|
||||
<template #default>
|
||||
<div
|
||||
v-if="isText"
|
||||
v-if="preview === 'text'"
|
||||
class="prose prose-sm max-w-none whitespace-pre-wrap"
|
||||
>
|
||||
{{ content }}
|
||||
</div>
|
||||
<img v-if="isImage" :src="url" class="m-auto rounded border" />
|
||||
<img
|
||||
v-if="preview === 'image'"
|
||||
:src="url"
|
||||
class="m-auto rounded-4 border"
|
||||
/>
|
||||
<video
|
||||
v-if="preview === 'video'"
|
||||
:src="url"
|
||||
controls
|
||||
class="m-auto max-h-[70vh] rounded-4 border"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</span>
|
||||
@@ -66,6 +76,27 @@ const ICONS: Record<AttachmentKind, Component> = {
|
||||
file: markRaw(LucideFile),
|
||||
};
|
||||
|
||||
// browsers render only some image/video mimes (.dwg is image/vnd.dwg, .avi is
|
||||
// video/x-msvideo) — anything absent here becomes a download link like pdf
|
||||
type Preview = "image" | "video" | "text";
|
||||
|
||||
const PREVIEWS: Record<string, Preview> = {
|
||||
"image/png": "image",
|
||||
"image/jpeg": "image",
|
||||
"image/gif": "image",
|
||||
"image/webp": "image",
|
||||
"image/svg+xml": "image",
|
||||
"image/avif": "image",
|
||||
"image/bmp": "image",
|
||||
"image/apng": "image",
|
||||
"image/vnd.microsoft.icon": "image",
|
||||
"video/mp4": "video",
|
||||
"video/webm": "video",
|
||||
"video/ogg": "video",
|
||||
"video/x-m4v": "video",
|
||||
"text/plain": "text",
|
||||
};
|
||||
|
||||
function getKind(mime: string): AttachmentKind {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
@@ -77,16 +108,13 @@ function getKind(mime: string): AttachmentKind {
|
||||
|
||||
const showDialog = ref(false);
|
||||
const mimeType = getMime(props.label) || "";
|
||||
const kind = getKind(mimeType);
|
||||
const isImage = kind === "image";
|
||||
const isText = kind === "text";
|
||||
const isShowable = props.url && (isText || isImage);
|
||||
const icon = ICONS[kind];
|
||||
const icon = ICONS[getKind(mimeType)];
|
||||
const preview = props.url ? PREVIEWS[mimeType] : undefined;
|
||||
const content = ref("");
|
||||
|
||||
function toggleDialog() {
|
||||
if (!isShowable) return;
|
||||
if (isText) {
|
||||
if (!preview) return;
|
||||
if (preview === "text") {
|
||||
fetch(props.url).then((res) => res.text().then((t) => (content.value = t)));
|
||||
}
|
||||
showDialog.value = !showDialog.value;
|
||||
|
||||
33
desk/src/components/AttachmentList.vue
Normal file
33
desk/src/components/AttachmentList.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<AttachmentItem
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.file_url"
|
||||
:label="attachment.file_name"
|
||||
:url="attachment.file_url"
|
||||
>
|
||||
<template #suffix>
|
||||
<LucideX
|
||||
class="size-3.5 cursor-pointer text-ink-gray-6 transition-colors hover:text-ink-gray-9"
|
||||
@click.stop.prevent="emit('remove', attachment)"
|
||||
/>
|
||||
</template>
|
||||
</AttachmentItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem } from "@/components";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
|
||||
interface Attachment {
|
||||
file_url: string;
|
||||
file_name: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<{ attachments?: Attachment[] }>(), {
|
||||
attachments: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ remove: [attachment: Attachment] }>();
|
||||
</script>
|
||||
@@ -1,287 +0,0 @@
|
||||
<template>
|
||||
<Combobox v-slot="{ open: isComboboxOpen }" v-model="selectedValue" nullable>
|
||||
<Popover v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full -ms-0.5">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="
|
||||
() => {
|
||||
!disabled && togglePopover();
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex items-center truncate">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
v-if="selectedValue"
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span v-else class="text-base leading-5 text-ink-gray-4">
|
||||
{{ placeholder || "" }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="lucide-chevron-down h-4 w-4 text-ink-gray-7"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-base py-1 text-base shadow-2xl"
|
||||
>
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full pe-6 bg-transparent"
|
||||
type="text"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value;
|
||||
}
|
||||
"
|
||||
/>
|
||||
<button
|
||||
class="absolute inset-y-0 end-3 top-px flex items-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<span class="lucide-x size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
v-show="group.items.length > 0"
|
||||
:key="group.key"
|
||||
class="mt-1.5"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm-medium text-ink-gray-4"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="option"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-1.5 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { Popover } from "frappe-ui";
|
||||
import { computed, nextTick, ref, useAttrs, useSlots, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "md",
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "subtle",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "update:query", "change"]);
|
||||
|
||||
const query = ref("");
|
||||
const showOptions = ref(false);
|
||||
const search = ref(null);
|
||||
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
|
||||
const valuePropPassed = computed(() => "value" in attrs);
|
||||
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue;
|
||||
},
|
||||
set(val) {
|
||||
query.value = "";
|
||||
if (val) {
|
||||
showOptions.value = false;
|
||||
}
|
||||
emit(valuePropPassed.value ? "change" : "update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
function close() {
|
||||
showOptions.value = false;
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options || props.options.length == 0) return [];
|
||||
|
||||
let groups = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: "", items: props.options }];
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
};
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
});
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) {
|
||||
return options;
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value];
|
||||
return searchTexts.some((text) =>
|
||||
(text || "").toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === "string") {
|
||||
let allOptions = groups.value.flatMap((group) => group.items);
|
||||
let selectedOption = allOptions.find((o) => o.value === option);
|
||||
return selectedOption?.label || option;
|
||||
}
|
||||
return option?.label;
|
||||
}
|
||||
|
||||
watch(query, (q) => {
|
||||
emit("update:query", q);
|
||||
});
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? "text-ink-gray-5" : "text-ink-gray-8";
|
||||
});
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
let sizeClasses = {
|
||||
sm: "text-base rounded h-7",
|
||||
md: "text-base rounded h-8",
|
||||
lg: "text-md rounded-md h-10",
|
||||
xl: "text-xl rounded-md h-10",
|
||||
}[props.size];
|
||||
|
||||
let paddingClasses = {
|
||||
sm: "py-1.5 px-2",
|
||||
md: "py-1.5 px-2.5",
|
||||
lg: "py-1.5 px-3",
|
||||
xl: "py-1.5 px-3",
|
||||
}[props.size];
|
||||
|
||||
let variant = props.disabled ? "disabled" : props.variant;
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
"border border-outline-gray-1 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3",
|
||||
outline:
|
||||
"border border-outline-gray-2 bg-surface-base placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3",
|
||||
disabled: [
|
||||
"border bg-surface-sidebar placeholder-ink-gray-3",
|
||||
props.variant === "outline"
|
||||
? "border-outline-gray-2"
|
||||
: "border-transparent",
|
||||
],
|
||||
}[variant];
|
||||
|
||||
return [
|
||||
sizeClasses,
|
||||
paddingClasses,
|
||||
variantClasses,
|
||||
textColor.value,
|
||||
"transition-colors w-full",
|
||||
];
|
||||
});
|
||||
|
||||
defineExpose({ query });
|
||||
</script>
|
||||
@@ -6,10 +6,10 @@
|
||||
variant="subtle"
|
||||
@update:model-value="agentStatusStore.setMyStatus"
|
||||
>
|
||||
<template #item-prefix="{ option }">
|
||||
<template #item-prefix="{ item }">
|
||||
<div
|
||||
class="size-2 rounded-full flex-shrink-0"
|
||||
:class="agentStatusStore.statusColor(option.value)"
|
||||
:class="agentStatusStore.statusColor(item.value)"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<Popover
|
||||
placement="right-start"
|
||||
class="flex w-full"
|
||||
trigger="hover"
|
||||
:hoverDelay="0.1"
|
||||
:leaveDelay="0.1"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
class="group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-8 hover:bg-surface-gray-2"
|
||||
@click.prevent="togglePopover()"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="flex items-center size-4">
|
||||
<span
|
||||
class="size-2.5 mx-auto shrink-0 rounded-full"
|
||||
:class="agentStatusStore.statusColor(agentStatusStore.myStatus)"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate">
|
||||
{{
|
||||
agentStatusStore.myStatus
|
||||
? __(agentStatusStore.myStatus)
|
||||
: __("Set status")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<template #body="{ close }">
|
||||
<div
|
||||
class="flex flex-col mx-3 p-1.5 rounded-lg border border-outline-gray-1 bg-surface-base shadow-xl min-w-[140px]"
|
||||
>
|
||||
<button
|
||||
v-for="option in agentStatusStore.statusOptions"
|
||||
:key="option"
|
||||
class="flex items-center gap-2 rounded p-1.5 text-sm text-ink-gray-8 hover:bg-surface-gray-2"
|
||||
@click="selectStatus(option, close)"
|
||||
>
|
||||
<span
|
||||
class="size-2.5 shrink-0 rounded-full"
|
||||
:class="agentStatusStore.statusColor(option)"
|
||||
/>
|
||||
<span class="whitespace-nowrap">{{ __(option) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover } from "frappe-ui";
|
||||
import { useAgentStatusStore } from "@/stores/agentStatus";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
const agentStatusStore = useAgentStatusStore();
|
||||
|
||||
function selectStatus(status: string, close: () => void) {
|
||||
agentStatusStore.setMyStatus(status);
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
@@ -76,7 +76,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const currentDuration = ref(__("Last month"));
|
||||
// the untranslated key: it is lowercased straight into the API period
|
||||
const currentDuration = ref("Last month");
|
||||
|
||||
const chartData = computed(() => {
|
||||
const isDataFetched = resource.fetched;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
alt="Brand Logo"
|
||||
class="h-8 w-8 shrink-0 object-cover"
|
||||
/>
|
||||
<HDLogo v-else class="h-8 w-8 shrink-0 rounded" />
|
||||
<HDLogo v-else class="h-8 w-8 shrink-0 rounded-4" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
<div
|
||||
@click="showCallLogDetailModal = true"
|
||||
class="flex flex-col gap-2 cursor-pointer border-transparent shadow rounded-md bg-surface-elevation-1 px-3 py-2.5 text-ink-gray-9"
|
||||
class="flex flex-col gap-2 cursor-pointer border-transparent shadow rounded-5 bg-surface-elevation-1 px-3 py-2.5 text-ink-gray-9"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex gap-2 items-center text-base-medium">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full h-full rounded-md p-4"
|
||||
class="flex flex-col w-full h-full rounded-5 p-4"
|
||||
:class="[orientation == 'horizontal' && 'pt-3']"
|
||||
>
|
||||
<slot name="title">
|
||||
@@ -22,21 +22,26 @@
|
||||
</slot>
|
||||
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
|
||||
<div class="flex items-center gap-1" :class="percentageChange.color">
|
||||
<span :class="percentageChange.icon" class="size-4" />
|
||||
<span
|
||||
v-if="percentageChange.icon"
|
||||
:class="percentageChange.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
<div>{{ percentageChange.value }}%</div>
|
||||
</div>
|
||||
<Dropdown :options="durationOptions">
|
||||
<div
|
||||
class="flex items-center gap-0.5 text-ink-gray-5 hover:text-ink-gray-6 cursor-pointer shrink-0"
|
||||
>
|
||||
<div class="rtl:flex rtl:gap-1">
|
||||
<span>vs</span> <span>{{ currentDuration.toLowerCase() }}</span>
|
||||
<div class="flex gap-1">
|
||||
<span>vs</span>
|
||||
<span>{{ __(currentDuration).toLowerCase() }}</span>
|
||||
</div>
|
||||
<span class="lucide-chevron-down size-4" aria-hidden="true" />
|
||||
<LucideChevronDown class="size-4" />
|
||||
</div>
|
||||
<template #item-label="{ item }">
|
||||
<div
|
||||
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
|
||||
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded-4 px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
|
||||
>
|
||||
<span>
|
||||
{{ item.label }}
|
||||
@@ -44,10 +49,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #item-suffix="{ item }">
|
||||
<span
|
||||
<LucideCheck
|
||||
v-if="item.label == __(currentDuration)"
|
||||
class="lucide-check size-4"
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
/>
|
||||
</template>
|
||||
</Dropdown>
|
||||
@@ -76,19 +80,23 @@
|
||||
</div>
|
||||
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
|
||||
<div class="flex items-center gap-1" :class="percentageChange.color">
|
||||
<span :class="percentageChange.icon" class="size-4" />
|
||||
<span
|
||||
v-if="percentageChange.icon"
|
||||
:class="percentageChange.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
<div>{{ percentageChange.value }}%</div>
|
||||
</div>
|
||||
<Dropdown :options="durationOptions">
|
||||
<div
|
||||
class="flex items-center gap-0.5 text-ink-gray-5 hover:text-ink-gray-6 cursor-pointer shrink-0"
|
||||
>
|
||||
vs {{ currentDuration.toLowerCase() }}
|
||||
<span class="lucide-chevron-down size-4" aria-hidden="true" />
|
||||
vs {{ __(currentDuration).toLowerCase() }}
|
||||
<LucideChevronDown class="size-4" />
|
||||
</div>
|
||||
<template #item-label="{ item }">
|
||||
<div
|
||||
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
|
||||
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded-4 px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
|
||||
>
|
||||
<span>
|
||||
{{ item.label }}
|
||||
@@ -96,10 +104,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #item-suffix="{ item }">
|
||||
<span
|
||||
<LucideCheck
|
||||
v-if="item.label == __(currentDuration)"
|
||||
class="lucide-check size-4"
|
||||
aria-hidden="true"
|
||||
class="size-4"
|
||||
/>
|
||||
</template>
|
||||
</Dropdown>
|
||||
@@ -109,9 +116,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import { __ } from "@/translation";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { Dropdown, ECharts } from "frappe-ui";
|
||||
import { Dropdown } from "frappe-ui";
|
||||
import { ECharts } from "frappe-ui/experimental";
|
||||
import { computed, type PropType } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -150,27 +160,14 @@ const currentDuration = computed(() => props.currentDuration);
|
||||
|
||||
const emit = defineEmits(["changeDuration"]);
|
||||
|
||||
const durationOptions = [
|
||||
{
|
||||
label: __("Last week"),
|
||||
onClick: () => {
|
||||
if (currentDuration.value == __("Last week")) return;
|
||||
emit("changeDuration", __("Last week"));
|
||||
},
|
||||
// the emitted value is the untranslated key: it travels to the API as the
|
||||
// period, so only the label goes through __()
|
||||
const DURATIONS = ["Last week", "Last month", "Last 3 months"];
|
||||
|
||||
const durationOptions = DURATIONS.map((duration) => ({
|
||||
label: __(duration),
|
||||
onClick: () => {
|
||||
if (currentDuration.value !== duration) emit("changeDuration", duration);
|
||||
},
|
||||
{
|
||||
label: __("Last month"),
|
||||
onClick: () => {
|
||||
if (currentDuration.value == __("Last month")) return;
|
||||
emit("changeDuration", __("Last month"));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Last 3 months"),
|
||||
onClick: () => {
|
||||
if (currentDuration.value == __("Last 3 months")) return;
|
||||
emit("changeDuration", __("Last 3 months"));
|
||||
},
|
||||
},
|
||||
];
|
||||
}));
|
||||
</script>
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
<template>
|
||||
<div class="flex-col text-base flex-1" ref="commentBoxRef">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-ink-gray-5 flex items-center gap-2">
|
||||
<Avatar
|
||||
size="md"
|
||||
:label="commenter"
|
||||
:image="getUser(commentedBy).user_image"
|
||||
/>
|
||||
<p>
|
||||
<span class="font-medium text-ink-gray-8">
|
||||
{{ commenter }}
|
||||
</span>
|
||||
<span> {{ __(" commented") }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
|
||||
<span class="ps-0.5 text-sm text-ink-gray-5">
|
||||
{{ timeAgo(creation) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div v-if="authStore.userId === commentedBy && !editable">
|
||||
<Dropdown
|
||||
:placement="'right'"
|
||||
:options="dropdownOptions"
|
||||
@click="isConfirmingDelete = false"
|
||||
>
|
||||
<Button
|
||||
icon="lucide-more-horizontal"
|
||||
class="text-ink-gray-5"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:id="`comment-${name}`"
|
||||
class="rounded-md bg-surface-gray-1 transition-colors px-3 py-1.5"
|
||||
>
|
||||
<Editor v-model="_content" :extensions="extensions" :editable="editable">
|
||||
<template #default="{ editor }">
|
||||
<EditorBubbleMenu :editor="editor" :items="textEditorMenuItems" />
|
||||
<EditorContent
|
||||
:editor="editor"
|
||||
:class="[
|
||||
'prose-f shrink text-p-sm transition-all duration-300 ease-in-out block w-full content',
|
||||
getFontFamily(_content),
|
||||
]"
|
||||
@keydown.ctrl.enter.capture.stop="handleSaveComment"
|
||||
@keydown.meta.enter.capture.stop="handleSaveComment"
|
||||
/>
|
||||
<div v-if="editable" class="flex flex-row-reverse gap-2">
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
isMobileView
|
||||
? 'Save'
|
||||
: isMac
|
||||
? 'Save (⌘ + ⏎)'
|
||||
: 'Save (Ctrl + ⏎)'
|
||||
"
|
||||
@click="handleSaveComment"
|
||||
variant="solid"
|
||||
/>
|
||||
</div>
|
||||
<Button label="Discard" @click="handleDiscard" />
|
||||
</div>
|
||||
</template>
|
||||
</Editor>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 mb-2"
|
||||
v-if="!editable && Boolean(attachments.length)"
|
||||
>
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
:label="a.file_name"
|
||||
:url="a.file_url"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 my-2"
|
||||
v-if="!editable && enableCommentReactions"
|
||||
>
|
||||
<Popover>
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
class="flex h-full items-center justify-center rounded-full bg-surface-gray-2 px-1 py-1 text-ink-gray-6 transition hover:bg-surface-gray-3"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<ReactionIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="bg-surface-base rounded-lg shadow-lg p-2 border border-outline-gray-2"
|
||||
>
|
||||
<div class="grid grid-cols-6 gap-2">
|
||||
<button
|
||||
v-for="emoji in emojiList"
|
||||
:key="emoji"
|
||||
class="size-6 flex items-center justify-center rounded hover:bg-surface-gray-2 text-md transition-colors"
|
||||
@click="handleReaction(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<template v-for="reaction in reactionsList" :key="reaction.emoji">
|
||||
<Tooltip>
|
||||
<template #body>
|
||||
<div
|
||||
class="bg-surface-gray-10 px-2 py-1 text-center text-p-xs text-ink-base shadow-xl rounded"
|
||||
>
|
||||
<span v-for="(user, idx) in reaction.users" :key="user.user"
|
||||
>{{ user.full_name
|
||||
}}<span v-if="idx < reaction.users.length - 1">, </span></span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors"
|
||||
:class="
|
||||
reaction.current_user_reacted
|
||||
? 'bg-surface-blue-2 text-ink-blue-6 hover:bg-surface-blue-3'
|
||||
: 'bg-surface-gray-3 text-ink-gray-6 hover:bg-surface-gray-4'
|
||||
"
|
||||
v-if="reaction.count !== 0"
|
||||
@click="handleReaction(reaction.emoji)"
|
||||
>
|
||||
<span>{{ reaction.emoji }}</span>
|
||||
<span class="font-medium">{{ reaction.count }}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem } from "@/components";
|
||||
import ReactionIcon from "@/components/icons/ReactionIcon.vue";
|
||||
import { useDevice } from "@/composables";
|
||||
import { useScreenSize } from "@/composables/screen";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { updateRes as updateComment } from "@/stores/knowledgeBase";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { __ } from "@/translation";
|
||||
import { CommentActivity } from "@/types";
|
||||
import { textEditorMenuItems } from "@/editor-menu";
|
||||
import {
|
||||
CleanStyles,
|
||||
ComponentUtils,
|
||||
HandleExcelPaste,
|
||||
} from "@/tiptap-extensions";
|
||||
import {
|
||||
ConfirmDelete,
|
||||
dateFormat,
|
||||
dateTooltipFormat,
|
||||
getFontFamily,
|
||||
isContentEmpty,
|
||||
timeAgo,
|
||||
} from "@/utils";
|
||||
import {
|
||||
Avatar,
|
||||
Dropdown,
|
||||
Popover,
|
||||
Tooltip,
|
||||
createResource,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import {
|
||||
Editor,
|
||||
EditorBubbleMenu,
|
||||
EditorContent,
|
||||
RichTextKit,
|
||||
} from "frappe-ui/editor";
|
||||
import { PropType, computed, onMounted, ref } from "vue";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const props = defineProps({
|
||||
activity: {
|
||||
type: Object as PropType<CommentActivity>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const { getUser } = useUserStore();
|
||||
const { enableCommentReactions } = useConfigStore();
|
||||
|
||||
const { name, creation, content, commenter, commentedBy, attachments } =
|
||||
props.activity;
|
||||
|
||||
const { isMac } = useDevice();
|
||||
const { isMobileView } = useScreenSize();
|
||||
const isTicketMergedComment = computed(() => {
|
||||
const regex = /has been merged with ticket #\d+/;
|
||||
return regex.test(content);
|
||||
});
|
||||
const agentStore = useAgentStore();
|
||||
const userMentions = computed(() => agentStore.dropdown ?? []);
|
||||
const extensions = [
|
||||
RichTextKit.configure({ mention: { items: userMentions } }),
|
||||
ComponentUtils,
|
||||
HandleExcelPaste,
|
||||
CleanStyles,
|
||||
];
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
const isConfirmingDelete = ref(false);
|
||||
const editable = ref(false);
|
||||
const _content = ref(content);
|
||||
|
||||
const emojiList = ["👍", "👎", "❤️", "🎉", "👀", "✅"];
|
||||
|
||||
const dropdownOptions = computed(() => [
|
||||
{
|
||||
label: "Edit",
|
||||
onClick: () => handleEditMode(),
|
||||
icon: "lucide-edit-2",
|
||||
condition: () => !isTicketMergedComment.value,
|
||||
},
|
||||
...ConfirmDelete({
|
||||
onConfirmDelete: () => deleteComment.submit(),
|
||||
isConfirmingDelete,
|
||||
}),
|
||||
]);
|
||||
// editor.commands.focus('end')
|
||||
|
||||
const reactions = ref<
|
||||
Array<{
|
||||
emoji: string;
|
||||
count: number;
|
||||
users: Array<{ user: string; full_name: string }>;
|
||||
current_user_reacted: boolean;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const reactionsList = computed(() => reactions.value || []);
|
||||
|
||||
const fetchReactions = createResource({
|
||||
url: "helpdesk.helpdesk.doctype.hd_ticket_comment.hd_ticket_comment.get_reactions",
|
||||
makeParams: () => ({ comment: name }),
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
reactions.value = data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const toggleReaction = createResource({
|
||||
url: "helpdesk.helpdesk.doctype.hd_ticket_comment.hd_ticket_comment.toggle_reaction",
|
||||
makeParams: (emoji: string) => ({ comment: name, emoji }),
|
||||
onSuccess() {
|
||||
fetchReactions.reload();
|
||||
},
|
||||
});
|
||||
|
||||
function handleReaction(emoji: string) {
|
||||
const previousReaction = reactions.value.find(
|
||||
(r) => r.current_user_reacted && r.emoji !== emoji
|
||||
);
|
||||
if (previousReaction) {
|
||||
previousReaction.count = Math.max(0, previousReaction.count - 1);
|
||||
previousReaction.current_user_reacted = false;
|
||||
}
|
||||
|
||||
const existingReaction = reactions.value.find((r) => r.emoji === emoji);
|
||||
if (existingReaction) {
|
||||
if (existingReaction.current_user_reacted) {
|
||||
existingReaction.count = Math.max(0, existingReaction.count - 1);
|
||||
existingReaction.current_user_reacted = false;
|
||||
} else {
|
||||
existingReaction.count += 1;
|
||||
existingReaction.current_user_reacted = true;
|
||||
}
|
||||
} else {
|
||||
reactions.value.push({
|
||||
emoji,
|
||||
count: 1,
|
||||
current_user_reacted: true,
|
||||
users: [
|
||||
{
|
||||
user: authStore.userId,
|
||||
full_name: getUser(authStore.userId).full_name,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
toggleReaction.submit(emoji);
|
||||
}
|
||||
|
||||
const commentBoxRef = ref(null);
|
||||
const lastSavedContent = ref(content);
|
||||
const commentBoxState = ref(content);
|
||||
|
||||
function handleEditMode() {
|
||||
editable.value = true;
|
||||
commentBoxState.value = _content.value;
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
_content.value = commentBoxState.value;
|
||||
editable.value = false;
|
||||
}
|
||||
|
||||
const deleteComment = createResource({
|
||||
url: "frappe.client.delete",
|
||||
makeParams: () => ({
|
||||
doctype: "HD Ticket Comment",
|
||||
name: name,
|
||||
}),
|
||||
onSuccess() {
|
||||
emit("update");
|
||||
toast.success(__("Comment deleted successfully."));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSaveComment() {
|
||||
if (lastSavedContent.value === _content.value) {
|
||||
editable.value = false;
|
||||
return;
|
||||
}
|
||||
if (isContentEmpty(_content.value)) {
|
||||
toast.error(__("Comment cannot be empty."));
|
||||
return;
|
||||
}
|
||||
|
||||
updateComment.submit(
|
||||
{
|
||||
doctype: "HD Ticket Comment",
|
||||
name: name,
|
||||
fieldname: "content",
|
||||
value: _content.value,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
editable.value = false;
|
||||
lastSavedContent.value = _content.value;
|
||||
emit("update");
|
||||
toast.success(__("Comment updated successfully."));
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
onMounted(() => {
|
||||
// hack to persist the width of comment box to prevent it from resizing when the content is updated
|
||||
commentBoxRef.value.style.width = "0px";
|
||||
});
|
||||
</script>
|
||||
@@ -1,96 +1,92 @@
|
||||
<template>
|
||||
<Editor
|
||||
v-if="agentsList.data"
|
||||
ref="editorRef"
|
||||
v-model="newComment"
|
||||
:extensions="extensions"
|
||||
:placeholder="placeholder"
|
||||
:editable="editable"
|
||||
:upload-function="(file:any)=>uploadFunction(file, doctype, ticketId)"
|
||||
:placeholder="placeholder"
|
||||
:upload-function="
|
||||
(file: any, options: any) =>
|
||||
track(uploadFunction(file, doctype, ticketId, true, options))
|
||||
"
|
||||
>
|
||||
<template #default="{ editor }">
|
||||
<EditorContent
|
||||
:editor="editor"
|
||||
:class="[
|
||||
'prose-sm max-w-none',
|
||||
editable &&
|
||||
'min-h-[7rem] mx-5 max-h-[44vh] overflow-y-auto border-t py-3',
|
||||
getFontFamily(newComment),
|
||||
]"
|
||||
/>
|
||||
<!-- Attachments -->
|
||||
<div class="flex flex-wrap gap-2 my-2 ms-5">
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
:label="a.file_name"
|
||||
:url="!['MOV', 'MP4'].includes(a.file_type) ? a.file_url : null"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="lucide-x h-3.5"
|
||||
aria-hidden="true"
|
||||
@click.stop="removeAttachment(a)"
|
||||
/>
|
||||
</template>
|
||||
</AttachmentItem>
|
||||
<template #default="{ isEmpty }">
|
||||
<!-- Scroll here so selected nodes aren't clipped. -->
|
||||
<div :class="editable && 'max-h-[44vh] overflow-y-auto'">
|
||||
<EditorContent
|
||||
:class="[
|
||||
'prose-sm max-w-none',
|
||||
editable && 'min-h-[7rem] mx-5 border-t py-3',
|
||||
getFontFamily(newComment),
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Attachments -->
|
||||
<AttachmentList
|
||||
class="my-2 ms-5"
|
||||
:attachments="attachments"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
<div v-if="editable" class="flex flex-col gap-2 border-t">
|
||||
<div class="px-4">
|
||||
<!-- Fixed Menu -->
|
||||
<div class="flex justify-between overflow-hidden py-2.5">
|
||||
<div class="flex items-center overflow-x-auto w-[60%]">
|
||||
<div class="flex justify-between gap-2 overflow-hidden py-2.5">
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1.5 p-1">
|
||||
<FileUploader
|
||||
:upload-args="{
|
||||
doctype: doctype,
|
||||
docname: ticketId,
|
||||
private: true,
|
||||
}"
|
||||
:doctype="doctype"
|
||||
:docname="ticketId"
|
||||
private
|
||||
@success="(f) => attachments.push(f)"
|
||||
>
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ void (loading = uploading) }}
|
||||
<button
|
||||
class="flex rounded p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
@click="openFileSelector()"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
|
||||
<AttachmentIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
style="stroke-width: 1.5 !important"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div class="ml-0.5 mr-1 h-5 w-px bg-surface-gray-3" />
|
||||
<div class="h-4 w-[2px] border-s ml-1" />
|
||||
</div>
|
||||
<EditorFixedMenu :editor="editor" :items="textEditorMenuItems" />
|
||||
<EditorFixedMenu :items="fullToolbar" />
|
||||
<EditorTableMenu />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-x-2 w-[40%]">
|
||||
<div class="flex shrink-0 items-center justify-end gap-x-2">
|
||||
<Button
|
||||
label="Discard"
|
||||
@click="
|
||||
() => {
|
||||
newComment = '';
|
||||
attachments = [];
|
||||
dropUnused(null);
|
||||
emit('discard');
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="label"
|
||||
:disabled="isDisabled"
|
||||
:loading="loading"
|
||||
@click="
|
||||
() => {
|
||||
loading = true;
|
||||
submitComment();
|
||||
newComment = '';
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- A disabled button fires no pointer events, so the span
|
||||
carries the hover for the tooltip -->
|
||||
<Tooltip
|
||||
:text="isUploading ? __('Please wait, media is uploading') : ''"
|
||||
>
|
||||
<span class="inline-flex">
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="label"
|
||||
:disabled="isDisabled"
|
||||
:loading="loading"
|
||||
@click="submitComment()"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,27 +95,34 @@
|
||||
</Editor>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { FileUploader, createResource } from "frappe-ui";
|
||||
import {
|
||||
FileUploader,
|
||||
LoadingIndicator,
|
||||
Tooltip,
|
||||
createResource,
|
||||
dayjs,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
EditorFixedMenu,
|
||||
RichTextKit,
|
||||
EditorTableMenu,
|
||||
} from "frappe-ui/editor";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { addPendingActivity } from "@framework/ui/ActivityTimeline";
|
||||
import { useOnboarding } from "@framework/ui";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { AttachmentItem } from "@/components/";
|
||||
import { AttachmentList } from "@/components/";
|
||||
import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
|
||||
import { AttachmentIcon } from "@/components/icons/";
|
||||
import { useTyping } from "@/composables/realtime";
|
||||
import { textEditorMenuItems } from "@/editor-menu";
|
||||
import { useUploadTracker } from "@/composables/useUploadTracker";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
CleanStyles,
|
||||
ComponentUtils,
|
||||
HandleExcelPaste,
|
||||
} from "@/tiptap-extensions";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { __ } from "@/translation";
|
||||
import { capture } from "@/telemetry";
|
||||
import {
|
||||
getFontFamily,
|
||||
isContentEmpty,
|
||||
@@ -129,9 +132,11 @@ import {
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
|
||||
const { agents: agentsList, dropdown } = storeToRefs(useAgentStore());
|
||||
const { isManager } = useAuthStore();
|
||||
const authStore = useAuthStore();
|
||||
const { isManager } = authStore;
|
||||
const { getUser } = useUserStore();
|
||||
|
||||
const props = defineProps({
|
||||
ticketId: {
|
||||
@@ -156,34 +161,25 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["submit", "discard"]);
|
||||
const emit = defineEmits(["submit", "discard", "sending", "restore"]);
|
||||
|
||||
const newComment = useStorage("commentBoxContent" + props.ticketId, null);
|
||||
|
||||
// Mentions as a reactive getter so the `@` list stays in sync as agents load.
|
||||
const extensions = buildEditorExtensions({
|
||||
mentions: () => dropdown.value ?? [],
|
||||
});
|
||||
|
||||
// Initialize typing composable
|
||||
const { onUserType, cleanup } = useTyping(props.ticketId);
|
||||
|
||||
const attachments = ref([]);
|
||||
const { isUploading, track, dropUnused } = useUploadTracker();
|
||||
const isDisabled = computed(() => {
|
||||
return isContentEmpty(newComment.value) || loading.value;
|
||||
return isContentEmpty(newComment.value) || loading.value || isUploading.value;
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
// Watch for changes in comment content to trigger typing events
|
||||
watch(newComment, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue && newValue) {
|
||||
onUserType();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const label = computed(() => {
|
||||
return loading.value ? "Sending..." : props.label;
|
||||
});
|
||||
|
||||
function removeAttachment(attachment) {
|
||||
attachments.value = attachments.value.filter((a) => a !== attachment);
|
||||
removeAttachmentFromServer(attachment.name);
|
||||
@@ -193,28 +189,58 @@ async function submitComment() {
|
||||
if (isContentEmpty(newComment.value)) {
|
||||
return false;
|
||||
}
|
||||
// The keyboard shortcut reaches here without passing the disabled button
|
||||
if (isUploading.value || loading.value) return false;
|
||||
|
||||
const content = newComment.value;
|
||||
const sentAttachments = attachments.value;
|
||||
const pendingRow = addPendingActivity(props.doctype, props.ticketId, {
|
||||
type: "comment",
|
||||
timestamp: dayjs().format("YYYY-MM-DD HH:mm:ss"),
|
||||
author: {
|
||||
email: getUser(authStore.userId).email,
|
||||
// the users list may still be a stub with a guessed name and no avatar
|
||||
fullname: authStore.userName,
|
||||
image: authStore.userImage,
|
||||
},
|
||||
data: { name: "", content, attachments: sentAttachments },
|
||||
});
|
||||
|
||||
// drop before clearing, or an unmount mid-send deletes what the comment carries
|
||||
dropUnused(content);
|
||||
newComment.value = null;
|
||||
attachments.value = [];
|
||||
loading.value = true;
|
||||
emit("sending");
|
||||
|
||||
const comment = createResource({
|
||||
url: "run_doc_method",
|
||||
makeParams: () => ({
|
||||
dt: props.doctype,
|
||||
dn: props.ticketId,
|
||||
method: "new_comment",
|
||||
args: {
|
||||
content: newComment.value,
|
||||
attachments: attachments.value,
|
||||
},
|
||||
args: { content, attachments: sentAttachments },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onSuccess: (res: { message?: string } | string) => {
|
||||
// run_doc_method answers with the whole body, since it always carries `docs`
|
||||
const name = typeof res === "string" ? res : res?.message;
|
||||
// the real row replaces the pending one the moment it arrives; unkeyed,
|
||||
// it would outlive it
|
||||
name ? pendingRow.resolve(`comment:${name}`) : pendingRow.drop();
|
||||
capture("comment_added");
|
||||
if (isManager) {
|
||||
updateOnboardingStep("comment_on_ticket");
|
||||
updateOnboardingStep?.("comment_on_ticket");
|
||||
}
|
||||
emit("submit");
|
||||
loading.value = false;
|
||||
attachments.value = [];
|
||||
newComment.value = null;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__("Could not add the comment"));
|
||||
pendingRow.drop();
|
||||
newComment.value = content;
|
||||
attachments.value = sentAttachments;
|
||||
loading.value = false;
|
||||
emit("restore");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,15 +250,12 @@ async function submitComment() {
|
||||
const editorRef = ref(null);
|
||||
const editor = computed(() => editorRef.value?.editor);
|
||||
|
||||
const extensions = [
|
||||
RichTextKit.configure({
|
||||
heading: { levels: [2, 3, 4, 5, 6] },
|
||||
mention: { items: dropdown },
|
||||
}),
|
||||
ComponentUtils,
|
||||
HandleExcelPaste,
|
||||
CleanStyles,
|
||||
];
|
||||
// Watch for changes in comment content to trigger typing events
|
||||
watch(newComment, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue && newValue) {
|
||||
onUserType();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
@@ -246,6 +269,9 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup();
|
||||
// the saved draft still references what it shows, so only what it dropped goes
|
||||
dropUnused(newComment.value);
|
||||
if (isContentEmpty(newComment.value)) {
|
||||
localStorage.removeItem("commentBoxContent" + props.ticketId);
|
||||
}
|
||||
|
||||
@@ -51,10 +51,15 @@
|
||||
:to-emails="toEmails"
|
||||
:cc-emails="ccEmails"
|
||||
:bcc-emails="bccEmails"
|
||||
@submit="
|
||||
@sending="
|
||||
() => {
|
||||
showEmailBox = false;
|
||||
emit('update');
|
||||
}
|
||||
"
|
||||
@submit="() => emit('update')"
|
||||
@restore="
|
||||
() => {
|
||||
showEmailBox = true;
|
||||
}
|
||||
"
|
||||
@discard="
|
||||
@@ -88,10 +93,15 @@
|
||||
:editable="showCommentBox"
|
||||
:doctype="doctype"
|
||||
placeholder="@John could you please look into this?"
|
||||
@submit="
|
||||
@sending="
|
||||
() => {
|
||||
showCommentBox = false;
|
||||
emit('update');
|
||||
}
|
||||
"
|
||||
@submit="() => emit('update')"
|
||||
@restore="
|
||||
() => {
|
||||
showCommentBox = true;
|
||||
}
|
||||
"
|
||||
@discard="
|
||||
@@ -141,16 +151,14 @@ function toggleCommentBox() {
|
||||
showCommentBox.value = !showCommentBox.value;
|
||||
}
|
||||
|
||||
// the editors emit `submit` once the write lands; reloading here too would
|
||||
// fetch twice, and the guards' `false` never reaches this side anyway
|
||||
function submitEmail() {
|
||||
if (emailEditorRef.value.submitMail()) {
|
||||
emit("update");
|
||||
}
|
||||
emailEditorRef.value.submitMail();
|
||||
}
|
||||
|
||||
function submitComment() {
|
||||
if (commentTextEditorRef.value.submitComment()) {
|
||||
emit("update");
|
||||
}
|
||||
commentTextEditorRef.value.submitComment();
|
||||
}
|
||||
|
||||
function splitIfString(str: string | string[]) {
|
||||
@@ -226,39 +234,48 @@ defineExpose({
|
||||
editor: emailEditorRef,
|
||||
});
|
||||
|
||||
const IGNORED_SELECTORS = [
|
||||
".tippy-box",
|
||||
".tippy-content",
|
||||
".PopoverContent",
|
||||
'[role="dialog"]',
|
||||
'[role="presentation"]',
|
||||
'[role="menu"]',
|
||||
".dialog-overlay",
|
||||
// Grammarly suggestions appear outside the box, allow them to stop collapsing.
|
||||
"grammarly-extension",
|
||||
"grammarly-popups",
|
||||
"[data-grammarly-part]",
|
||||
];
|
||||
|
||||
// `ignore` is only consulted on pointerdown, which dialogs stop, so the click
|
||||
// through has to be checked too. Without this a dialog button closes the box.
|
||||
function isIgnored(event: Event): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return Boolean(target?.closest?.(IGNORED_SELECTORS.join(", ")));
|
||||
}
|
||||
|
||||
onClickOutside(
|
||||
emailBoxRef,
|
||||
() => {
|
||||
if (showEmailBox.value) {
|
||||
(event) => {
|
||||
if (showEmailBox.value && !isIgnored(event)) {
|
||||
showEmailBox.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
ignore: [
|
||||
".tippy-box",
|
||||
".tippy-content",
|
||||
".PopoverContent",
|
||||
'[role="dialog"]',
|
||||
".dialog-overlay",
|
||||
],
|
||||
ignore: IGNORED_SELECTORS,
|
||||
}
|
||||
);
|
||||
|
||||
onClickOutside(
|
||||
commentBoxRef,
|
||||
() => {
|
||||
if (showCommentBox.value) {
|
||||
(event) => {
|
||||
if (showCommentBox.value && !isIgnored(event)) {
|
||||
showCommentBox.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
ignore: [
|
||||
".tippy-box",
|
||||
".tippy-content",
|
||||
".PopoverContent",
|
||||
'[role="dialog"]',
|
||||
".dialog-overlay",
|
||||
],
|
||||
ignore: IGNORED_SELECTORS,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -3,82 +3,70 @@
|
||||
<Editor
|
||||
ref="editorRef"
|
||||
v-model="internalContent"
|
||||
:extensions="editorExtensions"
|
||||
:upload-function="uploadFn"
|
||||
:extensions="extensions"
|
||||
v-bind="uploadFn ? { uploadFunction: uploadFn } : {}"
|
||||
:placeholder="placeholder"
|
||||
>
|
||||
<template #default="{ editor }">
|
||||
<template #default>
|
||||
<EditorBubbleMenu :items="commentToolbar" />
|
||||
<div
|
||||
class="flex items-center overflow-x-auto rounded-t border border-b-0 border-[--surface-gray-2] px-2 py-1"
|
||||
class="flex items-center overflow-x-auto rounded-t-4 border border-b-0 border-outline-gray-1 bg-surface-gray-1 px-1 py-1"
|
||||
>
|
||||
<div
|
||||
v-if="showAttachments"
|
||||
class="inline-flex items-center gap-1.5 pe-1"
|
||||
>
|
||||
<FileUploader
|
||||
:upload-args="{ private: true }"
|
||||
@success="addAttachment"
|
||||
>
|
||||
<FileUploader private @success="addAttachment">
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ syncUploadingState(uploading) }}
|
||||
<button
|
||||
class="flex rounded p-1 text-ink-gray-8 transition-colors hover:bg-surface-gray-3 disabled:opacity-40"
|
||||
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors hover:bg-surface-gray-3 disabled:opacity-40"
|
||||
:disabled="uploading"
|
||||
@click="openFileSelector()"
|
||||
>
|
||||
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
|
||||
<AttachmentIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
style="stroke-width: 1.5 !important"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div class="h-4 w-[2px] border-s" />
|
||||
<div class="h-4 w-[2px] border-s ml-1" />
|
||||
</div>
|
||||
<EditorFixedMenu :editor="editor" :items="savedReplyMenuItems" />
|
||||
<EditorFixedMenu :items="fullToolbar" />
|
||||
<EditorTableMenu />
|
||||
</div>
|
||||
<EditorContent :editor="editor" :class="editorClass" />
|
||||
<EditorContent :class="editorClass" />
|
||||
</template>
|
||||
</Editor>
|
||||
<div
|
||||
<AttachmentList
|
||||
v-if="showAttachments && attachments?.length"
|
||||
class="flex flex-wrap gap-2 mt-2"
|
||||
>
|
||||
<AttachmentItem
|
||||
v-for="attachment in attachments ?? []"
|
||||
:key="attachment.file_url"
|
||||
:label="attachment.file_name"
|
||||
:url="attachment.file_url"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="lucide-x h-3.5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
@click.self.stop="removeAttachment(attachment)"
|
||||
/>
|
||||
</template>
|
||||
</AttachmentItem>
|
||||
</div>
|
||||
class="mt-2"
|
||||
:attachments="attachments ?? []"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem } from "@/components";
|
||||
import { AttachmentIcon } from "@/components/icons";
|
||||
import { savedReplyMenuItems } from "@/editor-menu";
|
||||
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
|
||||
import { AttachmentList } from "@/components";
|
||||
import {
|
||||
CleanStyles,
|
||||
ComponentUtils,
|
||||
HandleExcelPaste,
|
||||
} from "@/tiptap-extensions";
|
||||
import { isContentEmpty, uploadFunction } from "@/utils";
|
||||
import { FileUploader, type UploadedFile } from "frappe-ui";
|
||||
buildEditorExtensions,
|
||||
commentToolbar,
|
||||
fullToolbar,
|
||||
} from "@/components/editor/config";
|
||||
import { AttachmentIcon } from "@/components/icons";
|
||||
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
|
||||
import { isContentEmpty } from "@/utils";
|
||||
import { FileUploader, LoadingIndicator, type UploadedFile } from "frappe-ui";
|
||||
import {
|
||||
Editor,
|
||||
EditorBubbleMenu,
|
||||
EditorContent,
|
||||
EditorFixedMenu,
|
||||
RichTextKit,
|
||||
EditorTableMenu,
|
||||
} from "frappe-ui/editor";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
|
||||
@@ -91,7 +79,6 @@ const props = withDefaults(
|
||||
maxHeight?: string;
|
||||
extensions?: any[];
|
||||
showSignature?: boolean;
|
||||
type?: "Saved Reply" | "Email";
|
||||
showAttachments?: boolean;
|
||||
uploadFn?: UploadFunction;
|
||||
}>(),
|
||||
@@ -99,11 +86,9 @@ const props = withDefaults(
|
||||
placeholder: "",
|
||||
minHeight: "min-h-[180px]",
|
||||
maxHeight: "max-h-80",
|
||||
extensions: () => [ComponentUtils, HandleExcelPaste, CleanStyles],
|
||||
extensions: () => [],
|
||||
showSignature: false,
|
||||
type: "Saved Reply",
|
||||
showAttachments: false,
|
||||
uploadFn: (file: File) => uploadFunction(file),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -115,31 +100,20 @@ const attachments = defineModel<UploadedFile[] | null>("attachments", {
|
||||
});
|
||||
const isUploading = ref(false);
|
||||
|
||||
const savedReplyClass = [
|
||||
"!prose-sm max-w-full overflow-auto py-1.5 px-2",
|
||||
"rounded-b border border-[--surface-gray-2] bg-surface-gray-2",
|
||||
"placeholder-ink-gray-4",
|
||||
"hover:border-outline-elevation-2 hover:shadow-sm",
|
||||
"focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0",
|
||||
"focus-visible:ring-2 focus-visible:ring-outline-gray-3",
|
||||
"text-ink-gray-8 transition-colors -mt-0.5",
|
||||
];
|
||||
const extensions = buildEditorExtensions({ extra: props.extensions });
|
||||
|
||||
const emailClass = [
|
||||
"!prose-sm max-w-full overflow-auto py-1.5 px-2",
|
||||
"rounded-b border border-[--surface-gray-2] bg-surface-base",
|
||||
"placeholder-ink-gray-4",
|
||||
"text-ink-gray-8 transition-colors -mt-0.5",
|
||||
const contentClass = [
|
||||
"!prose-sm max-w-full overflow-auto py-1.5 px-3",
|
||||
"rounded-b-4 border border-outline-gray-1 bg-surface-base",
|
||||
"placeholder-ink-gray-4 text-ink-gray-8 -mt-0.5",
|
||||
];
|
||||
|
||||
const editorClass = computed(() => [
|
||||
...(props.type === "Email" ? emailClass : savedReplyClass),
|
||||
...contentClass,
|
||||
props.minHeight,
|
||||
props.maxHeight,
|
||||
]);
|
||||
|
||||
const editorExtensions = computed(() => [RichTextKit, ...props.extensions]);
|
||||
|
||||
const userResource = getUserEmailInfo();
|
||||
|
||||
function getDefaultContent(signature: string): string {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<template>
|
||||
<div v-if="normalActions.length" class="flex gap-2">
|
||||
<!-- onClick rides along in `action`; binding it twice would run it twice -->
|
||||
<Button
|
||||
v-for="action in normalActions"
|
||||
:key="action.label"
|
||||
:label="action.label"
|
||||
@click="action.onClick()"
|
||||
v-bind="action"
|
||||
>
|
||||
<template v-if="action.icon" #prefix>
|
||||
<span
|
||||
:class="['h-4 w-4', lucideClass(action.icon)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Icon :icon="action.icon" class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -20,12 +17,9 @@
|
||||
<Dropdown v-slot="{ open }" :options="g.action">
|
||||
<Button :label="g.label">
|
||||
<template #suffix>
|
||||
<span
|
||||
:class="[
|
||||
'h-4',
|
||||
open ? 'lucide-chevron-up' : 'lucide-chevron-down',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
<component
|
||||
:is="open ? LucideChevronUp : LucideChevronDown"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
@@ -38,9 +32,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LucideChevronUp from "~icons/lucide/chevron-up";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import Icon from "@/components/Icon.vue";
|
||||
import { Dropdown } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import { lucideClass } from "@/utils";
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<Button
|
||||
variant="solid"
|
||||
theme="red"
|
||||
icon-left="trash-2"
|
||||
icon-left="lucide-trash-2"
|
||||
:label="__('Delete')"
|
||||
:loading="isDeleting"
|
||||
@click="confirmDelete"
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { __ } from "@/translation";
|
||||
import { Button, Checkbox, createListResource, Dialog } from "frappe-ui";
|
||||
import { Button, Checkbox, createResource, Dialog } from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -45,16 +45,18 @@ const props = defineProps<{
|
||||
|
||||
const open = defineModel<boolean>({ default: false });
|
||||
|
||||
const resource = createListResource({
|
||||
doctype: "HD Ticket",
|
||||
filters: { [props.linkField]: props.name },
|
||||
fields: ["name"],
|
||||
limit: 99999, // We just want the count
|
||||
const resource = createResource({
|
||||
url: "frappe.client.get_count",
|
||||
params: {
|
||||
doctype: "HD Ticket",
|
||||
filters: { [props.linkField]: props.name },
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
|
||||
const count = computed<number>(() => {
|
||||
if (resource.loading) return 0;
|
||||
return resource.data?.length ?? 0;
|
||||
return resource.data ?? 0;
|
||||
});
|
||||
|
||||
const deleteLinkedTickets = ref(false);
|
||||
|
||||
@@ -36,9 +36,9 @@ function handleDiscard() {
|
||||
{
|
||||
label: "Delete",
|
||||
theme: "red",
|
||||
iconLeft: "trash-2",
|
||||
iconLeft: "lucide-trash-2",
|
||||
variant: "solid",
|
||||
onClick(close: Function) {
|
||||
onClick({ close }: { close: () => void }) {
|
||||
emit("discard");
|
||||
close();
|
||||
},
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:id="`communication-${name}`"
|
||||
v-bind="$attrs"
|
||||
class="grow cursor-pointer bg-surface-base rounded-md text-base leading-6 transition-all duration-300 ease-in-out border border-outline-gray-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2"
|
||||
:class="isMobileView && 'items-start'"
|
||||
>
|
||||
<!-- email design for mobile -->
|
||||
<div v-if="isMobileView" class="flex items-center gap-2 text-sm">
|
||||
<div class="leading-tight">
|
||||
<p>{{ sender.full_name || "Guest" }}</p>
|
||||
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
|
||||
<p class="text-xs md:text-sm text-ink-gray-5">
|
||||
{{ timeAgo(creation) }}
|
||||
</p>
|
||||
</Tooltip>
|
||||
<p class="sm:flex hidden text-sm text-ink-gray-5" v-if="sender.name">
|
||||
{{ "<" + sender.name + ">" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- email design for desktop -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<span>{{ sender.full_name || "Guest" }}</span>
|
||||
<span
|
||||
class="sm:flex hidden text-sm text-ink-gray-5"
|
||||
v-if="sender.name"
|
||||
>{{ "<" + sender.name + ">" }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="gap-0.5 flex items-center">
|
||||
<Badge
|
||||
v-if="status.label && !ticket?.doc?.via_customer_portal"
|
||||
:label="__(status.label)"
|
||||
variant="subtle"
|
||||
:theme="status.color"
|
||||
class="me-1.5"
|
||||
/>
|
||||
<Tooltip
|
||||
:text="dateFormat(creation, dateTooltipFormat)"
|
||||
v-if="!isMobileView"
|
||||
>
|
||||
<p class="text-xs md:text-sm text-ink-gray-5">
|
||||
{{ timeAgo(creation) }}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button :tooltip="__('Reply')" variant="ghost" @click="reply">
|
||||
<template #icon>
|
||||
<ReplyIcon class="text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button :tooltip="__('Reply All')" variant="ghost" @click="replyAll">
|
||||
<template #icon>
|
||||
<ReplyAllIcon class="text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
<Dropdown
|
||||
v-if="showSplitOption"
|
||||
:placement="'right'"
|
||||
:options="[
|
||||
{
|
||||
label: 'Split Ticket',
|
||||
icon: LucideSplit,
|
||||
onClick: () => (showSplitModal = true),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
icon="lucide-more-horizontal"
|
||||
class="!text-ink-gray-7"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="text-sm leading-5 text-ink-gray-5">
|
||||
{{ subject }}
|
||||
</div> -->
|
||||
<div class="text-p-sm text-ink-gray-5">
|
||||
<template
|
||||
v-for="(val, label) in { To: to, cc: cc, bcc: bcc }"
|
||||
:key="label"
|
||||
>
|
||||
<span v-if="val" class="me-1.5">
|
||||
<span class="me-1 text-ink-gray-7">{{ label }}:</span>
|
||||
<span> {{ normalizeAndFilter(val).join(", ") }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="border-0 border-t my-3 border-outline-elevation-2 !-mx-3" />
|
||||
<EmailContent :content="content" />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
:label="a.file_name"
|
||||
:url="a.file_url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TicketSplitModal
|
||||
v-model="showSplitModal"
|
||||
:ticket_id="name"
|
||||
:communication_id="name"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem } from "@/components";
|
||||
import { useScreenSize } from "@/composables/screen";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { TicketSymbol } from "@/types";
|
||||
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
|
||||
import { Dropdown } from "frappe-ui";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, inject, ref } from "vue";
|
||||
import LucideSplit from "~icons/lucide/split";
|
||||
import { ReplyAllIcon, ReplyIcon } from "./icons";
|
||||
import TicketSplitModal from "./ticket/TicketSplitModal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
activity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showSplitOption: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
sender,
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
creation,
|
||||
subject,
|
||||
attachments,
|
||||
content,
|
||||
name,
|
||||
deliveryStatus,
|
||||
} = props.activity;
|
||||
|
||||
const emit = defineEmits(["reply"]);
|
||||
const ticket = inject(TicketSymbol)!;
|
||||
|
||||
const auth = storeToRefs(useAuthStore());
|
||||
|
||||
const { isMobileView } = useScreenSize();
|
||||
|
||||
const showSplitModal = ref(false);
|
||||
|
||||
const status = computed(() => {
|
||||
let _status = deliveryStatus;
|
||||
let indicator_color = "red";
|
||||
if (["Sent", "Clicked"].includes(_status)) {
|
||||
indicator_color = "green";
|
||||
} else if (["Sending", "Scheduled"].includes(_status)) {
|
||||
indicator_color = "orange";
|
||||
} else if (["Opened", "Read"].includes(_status)) {
|
||||
indicator_color = "blue";
|
||||
} else if (_status == "Error") {
|
||||
indicator_color = "red";
|
||||
}
|
||||
return { label: _status, color: indicator_color };
|
||||
});
|
||||
|
||||
const normalizeAndFilter = (
|
||||
field: string | string[],
|
||||
valuesToExclude: string[] = []
|
||||
) => {
|
||||
let arr = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
if (typeof field === "string") {
|
||||
for (let char of field) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += char;
|
||||
} else if (char === "," && !inQuotes) {
|
||||
arr.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) arr.push(current.trim());
|
||||
} else {
|
||||
arr = field || [];
|
||||
}
|
||||
return arr.filter(Boolean).filter((item) => !valuesToExclude.includes(item));
|
||||
};
|
||||
|
||||
const reply = () => {
|
||||
const user = auth.user.value;
|
||||
emit("reply", {
|
||||
content: content,
|
||||
to: user === sender.name ? to : sender.name,
|
||||
});
|
||||
};
|
||||
|
||||
const replyAll = () => {
|
||||
const user = auth.user.value;
|
||||
const exclude = [user, sender.name];
|
||||
const filteredTo = normalizeAndFilter(to, exclude);
|
||||
const filteredCc = normalizeAndFilter(cc, exclude);
|
||||
const filteredBcc = normalizeAndFilter(bcc, exclude);
|
||||
|
||||
let _to, _cc, _bcc;
|
||||
|
||||
if (user === sender.name) {
|
||||
// User is the sender, reply to all original recipients
|
||||
_to = filteredTo.join(", ");
|
||||
_cc = filteredCc;
|
||||
_bcc = filteredBcc;
|
||||
} else {
|
||||
// User is a recipient, reply to sender with all other recipients in cc
|
||||
_to = sender.name;
|
||||
_cc = [...filteredTo, ...filteredCc];
|
||||
_bcc = filteredBcc;
|
||||
}
|
||||
|
||||
emit("reply", {
|
||||
content: content,
|
||||
to: _to,
|
||||
cc: _cc.filter(Boolean),
|
||||
bcc: _bcc.filter(Boolean),
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: Implement reply functionality using this way instead of emit drillup
|
||||
// function reply(email, reply_all = false) {
|
||||
// emailBox.toggleEmailBox();
|
||||
// let editor = emailBox.editor;
|
||||
// let message = email.content;
|
||||
// let recipients = sender.name;
|
||||
// editor.toEmails = [email.sender];
|
||||
// editor.cc = editor.bcc = false;
|
||||
// editor.ccEmails = [];
|
||||
// editor.bccEmails = [];
|
||||
// console.log(recipients);
|
||||
|
||||
// if (!email.subject.startsWith("Re:")) {
|
||||
// editor.subject = `Re: ${email.subject}`;
|
||||
// } else {
|
||||
// editor.subject = email.subject;
|
||||
// }
|
||||
|
||||
// if (reply_all) {
|
||||
// let cc = email.cc?.split(",").map((r) => r.trim());
|
||||
// let bcc = email.bcc?.split(",").map((r) => r.trim());
|
||||
|
||||
// if (cc?.length) {
|
||||
// recipients = recipients.filter((r) => !cc?.includes(r));
|
||||
// cc.push(...recipients);
|
||||
// } else {
|
||||
// cc = recipients;
|
||||
// }
|
||||
|
||||
// editor.cc = cc ? true : false;
|
||||
// editor.bcc = bcc ? true : false;
|
||||
|
||||
// editor.ccEmails = cc;
|
||||
// editor.bccEmails = bcc;
|
||||
// }
|
||||
|
||||
// let repliedMessage = `<blockquote>${message}</blockquote>`;
|
||||
|
||||
// editor.editor
|
||||
// .chain()
|
||||
// .clearContent()
|
||||
// .insertContent("<p>.</p>")
|
||||
// .updateAttributes("paragraph", { class: "reply-to-content" })
|
||||
// .insertContent(repliedMessage)
|
||||
// .focus("all")
|
||||
// .insertContentAt(0, { type: "paragraph" })
|
||||
// .focus("start")
|
||||
// .run();
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.email-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
.email-content > * {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,11 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
const _content = ref(stripEmailColors(props.content));
|
||||
const _content = ref(props.content);
|
||||
|
||||
const themedContent = computed(() =>
|
||||
dataTheme.value === "dark" ? stripEmailColors(_content.value) : _content.value
|
||||
);
|
||||
|
||||
// Get CSS path - in dev Vite serves it directly, in prod we need the built path
|
||||
const cssHref = computed(() => {
|
||||
@@ -44,7 +48,11 @@ const gmailReplyToContent = doc.querySelectorAll("div.gmail_quote");
|
||||
const outlookReplyToContent = doc.querySelectorAll("div#appendonsend");
|
||||
const replyToContent = doc.querySelectorAll("p.reply-to-content");
|
||||
|
||||
if (gmailReplyToContent.length) {
|
||||
if (doc.body.children.length === 0) {
|
||||
// Plain-text emails (e.g. from Thunderbird) arrive as text with newlines and no spacing losing formatting.
|
||||
const plainText = doc.body.innerHTML.replace(/\n\n/g, "\n");
|
||||
_content.value = `<div class="whitespace-pre-wrap">${plainText}</div>`;
|
||||
} else if (gmailReplyToContent.length) {
|
||||
_content.value = parseReplyToContent(doc, "div.gmail_quote", true);
|
||||
} else if (outlookReplyToContent.length) {
|
||||
_content.value = parseReplyToContent(doc, "div#appendonsend");
|
||||
@@ -179,6 +187,7 @@ const htmlContent = computed(
|
||||
.email-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
.email-content :is(:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
|
||||
border-width: 0;
|
||||
@@ -196,7 +205,7 @@ const htmlContent = computed(
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-content prose-f">${_content.value}</div>
|
||||
<div class="email-content prose-f">${themedContent.value}</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
@@ -2,206 +2,216 @@
|
||||
<Editor
|
||||
ref="editorRef"
|
||||
v-model="newEmail"
|
||||
:extensions="extensions"
|
||||
:placeholder="placeholder"
|
||||
:editable="editable"
|
||||
:upload-function="(file:any)=>uploadFunction(file, doctype, ticketId)"
|
||||
:extensions="extensions"
|
||||
:upload-function="
|
||||
(file: any, options: any) =>
|
||||
track(uploadFunction(file, doctype, ticketId, true, options))
|
||||
"
|
||||
>
|
||||
<template #default="{ editor }">
|
||||
<div
|
||||
v-if="hasMultipleSenders"
|
||||
class="mx-6 md:mx-5 flex items-center gap-2 border-t py-2.5 h-12.5"
|
||||
>
|
||||
<span class="text-p-xs text-ink-gray-4">{{ __("From") }}:</span>
|
||||
<FormControl
|
||||
v-model="fromEmail"
|
||||
type="select"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
:placeholder="__('')"
|
||||
:options="from"
|
||||
/>
|
||||
</div>
|
||||
<div class="mx-6 md:mx-5 flex items-center gap-2 border-y py-2.5">
|
||||
<span class="text-p-xs text-ink-gray-4">{{ __("To") }}:</span>
|
||||
<EmailMultiSelect
|
||||
v-model="toEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
<div class="flex gap-1.5">
|
||||
<Button
|
||||
:label="__('Cc')"
|
||||
<template #default>
|
||||
<div @keydown.capture="handleKeydown">
|
||||
<!-- Email headers -->
|
||||
<div
|
||||
v-if="hasMultipleSenders"
|
||||
class="mx-6 md:mx-5 flex items-center gap-2 border-t py-2.5 h-12.5"
|
||||
>
|
||||
<span class="text-p-xs text-ink-gray-4">{{ __("From") }}:</span>
|
||||
<FormControl
|
||||
v-model="fromEmail"
|
||||
type="select"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
cc || showCC
|
||||
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
|
||||
: '!text-ink-gray-4',
|
||||
]"
|
||||
@click="toggleCC()"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Bcc')"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
bcc || showBCC
|
||||
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
|
||||
: '!text-ink-gray-4',
|
||||
]"
|
||||
@click="toggleBCC()"
|
||||
class="w-full"
|
||||
:placeholder="__('')"
|
||||
:options="from"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCC || cc"
|
||||
class="mx-5 flex items-center gap-2 py-2.5"
|
||||
:class="cc || showCC ? 'border-b' : ''"
|
||||
>
|
||||
<span class="text-xs text-ink-gray-4">{{ __("Cc:") }}</span>
|
||||
<EmailMultiSelect
|
||||
ref="ccInput"
|
||||
v-model="ccEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBCC || bcc"
|
||||
class="mx-5 flex items-center gap-2 py-2.5"
|
||||
:class="bcc || showBCC ? 'border-b' : ''"
|
||||
>
|
||||
<span class="text-xs text-ink-gray-4">{{ __("Bcc:") }}</span>
|
||||
<EmailMultiSelect
|
||||
ref="bccInput"
|
||||
v-model="bccEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditorBubbleMenu :editor="editor" :items="articleToolbar" />
|
||||
|
||||
<div
|
||||
class="overflow-y-auto min-h-[7rem] max-h-[30vh] flex flex-col"
|
||||
@keydown.capture="handleKeydown"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<EditorContent
|
||||
:editor="editor"
|
||||
:class="[
|
||||
'prose-sm max-w-full mx-6 md:mx-5 py-3',
|
||||
getFontFamily(newEmail),
|
||||
'[&_p.reply-to-content]:hidden',
|
||||
]"
|
||||
<div class="mx-6 md:mx-5 flex items-center gap-2 border-y py-2.5">
|
||||
<span class="text-p-xs text-ink-gray-4">{{ __("To") }}:</span>
|
||||
<EmailMultiSelect
|
||||
v-model="toEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
<div class="flex gap-1.5">
|
||||
<Button
|
||||
:label="__('Cc')"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
cc || showCC
|
||||
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
|
||||
: '!text-ink-gray-4',
|
||||
]"
|
||||
@click="toggleCC()"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Bcc')"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
bcc || showBCC
|
||||
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
|
||||
: '!text-ink-gray-4',
|
||||
]"
|
||||
@click="toggleBCC()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCC || cc"
|
||||
class="mx-5 flex items-center gap-2 py-2.5"
|
||||
:class="cc || showCC ? 'border-b' : ''"
|
||||
>
|
||||
<span class="text-xs text-ink-gray-4">{{ __("Cc:") }}</span>
|
||||
<EmailMultiSelect
|
||||
ref="ccInput"
|
||||
v-model="ccEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="quotedContent"
|
||||
class="replied-content mx-6 md:mx-5 mb-2 mt-auto"
|
||||
v-if="showBCC || bcc"
|
||||
class="mx-5 flex items-center gap-2 py-2.5"
|
||||
:class="bcc || showBCC ? 'border-b' : ''"
|
||||
>
|
||||
<label class="collapse" for="quoted-toggle">...</label>
|
||||
<input
|
||||
id="quoted-toggle"
|
||||
class="replyCollapser"
|
||||
type="checkbox"
|
||||
:checked="isQuoteExpanded"
|
||||
/>
|
||||
<div
|
||||
ref="quotedContentRef"
|
||||
contenteditable="true"
|
||||
class="prose !max-w-full mx-1 my-2 border-s-4 border-outline-gray-2 ps-4 text-sm focus:outline-none"
|
||||
@input="onQuotedInput"
|
||||
<span class="text-xs text-ink-gray-4">{{ __("Bcc:") }}</span>
|
||||
<EmailMultiSelect
|
||||
ref="bccInput"
|
||||
v-model="bccEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div class="flex flex-wrap gap-2 px-5 my-2">
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
:label="a.file_name"
|
||||
:url="!['MOV', 'MP4'].includes(a.file_type) ? a.file_url : null"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="lucide-x h-3.5"
|
||||
aria-hidden="true"
|
||||
@click.self.stop="removeAttachment(a)"
|
||||
<!-- Editor content + quoted reply -->
|
||||
<div class="overflow-y-auto min-h-[7rem] max-h-[30vh] flex flex-col">
|
||||
<div class="flex-1">
|
||||
<EditorContent
|
||||
:class="[
|
||||
'prose-sm max-w-full mx-6 md:mx-5 py-3',
|
||||
getFontFamily(newEmail),
|
||||
'[&_p.reply-to-content]:hidden',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="quotedContent"
|
||||
class="replied-content mx-6 md:mx-5 mb-2 mt-auto"
|
||||
>
|
||||
<label class="collapse" for="quoted-toggle">...</label>
|
||||
<input
|
||||
id="quoted-toggle"
|
||||
class="replyCollapser"
|
||||
type="checkbox"
|
||||
:checked="isQuoteExpanded"
|
||||
/>
|
||||
<div
|
||||
ref="quotedContentRef"
|
||||
contenteditable="true"
|
||||
class="prose !max-w-full mx-1 my-2 border-s-4 border-outline-gray-2 ps-4 text-sm focus:outline-none"
|
||||
@input="onQuotedInput"
|
||||
/>
|
||||
</template>
|
||||
</AttachmentItem>
|
||||
</div>
|
||||
<!-- Editor Fixed Menu -->
|
||||
<div
|
||||
class="flex justify-between overflow-scroll px-4 py-2.5 items-center border-t"
|
||||
>
|
||||
<div class="flex items-center overflow-x-auto w-[60%]">
|
||||
<div class="inline-flex items-center gap-1.5 p-1">
|
||||
<FileUploader
|
||||
:upload-args="{
|
||||
doctype: doctype,
|
||||
docname: ticketId,
|
||||
private: true,
|
||||
}"
|
||||
@success="
|
||||
(f) => {
|
||||
attachments.push(f);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ void (isUploading = uploading) }}
|
||||
<button
|
||||
class="flex rounded p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
@click="openFileSelector()"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<AttachmentIcon
|
||||
class="h-4 w-4"
|
||||
style="stroke-width: 1.5 !important"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<button
|
||||
class="flex rounded p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
@click="showSavedRepliesSelectorModal = true"
|
||||
>
|
||||
<SavedReplyIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="ml-0.5 mr-1 h-5 w-px bg-surface-gray-3" />
|
||||
</div>
|
||||
<EditorFixedMenu :editor="editor" :items="textEditorMenuItems" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-x-2 sm:mt-0 w-[40%]">
|
||||
<Button label="Discard" @click="handleDiscard" />
|
||||
<Button
|
||||
variant="solid"
|
||||
:disabled="isDisabled"
|
||||
:loading="sendMail.loading"
|
||||
:label="label"
|
||||
@click="
|
||||
() => {
|
||||
submitMail();
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Attachments -->
|
||||
<AttachmentList
|
||||
class="px-5 my-2"
|
||||
:attachments="attachments"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
<!-- Saved reply actions, applied once the reply is sent -->
|
||||
<SavedReplyActions
|
||||
ref="savedReplyActionsRef"
|
||||
class="mx-5 my-2"
|
||||
:ticket-id="ticketId"
|
||||
:doctype="doctype"
|
||||
/>
|
||||
<!-- Fixed Menu -->
|
||||
<div
|
||||
class="flex justify-between gap-2 overflow-scroll px-4 py-2.5 items-center border-t"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1.5 p-1">
|
||||
<FileUploader
|
||||
:doctype="doctype"
|
||||
:docname="ticketId"
|
||||
private
|
||||
@success="
|
||||
(f) => {
|
||||
attachments.push(f);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ void (attachmentUploading = uploading) }}
|
||||
<Tooltip :text="__('Attach file')">
|
||||
<button
|
||||
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
@click="openFileSelector()"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
|
||||
<AttachmentIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
style="stroke-width: 1.5 !important"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<Tooltip :text="__('Saved replies')">
|
||||
<button
|
||||
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
@click="showSavedRepliesSelectorModal = true"
|
||||
>
|
||||
<ZapIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div class="h-4 w-[2px] border-s ml-1" />
|
||||
</div>
|
||||
<EditorFixedMenu :items="fullToolbar" />
|
||||
<EditorTableMenu />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-x-2">
|
||||
<Button label="Discard" @click="handleDiscard" />
|
||||
<!-- A disabled button fires no pointer events, so the span
|
||||
carries the hover for the tooltip -->
|
||||
<Tooltip
|
||||
:text="isUploading ? __('Please wait, media is uploading') : ''"
|
||||
>
|
||||
<span class="inline-flex">
|
||||
<Button
|
||||
variant="solid"
|
||||
:disabled="isDisabled"
|
||||
:loading="sendMail.loading"
|
||||
:label="label"
|
||||
@click="
|
||||
() => {
|
||||
submitMail();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -215,18 +225,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem, SavedRepliesSelectorModal } from "@/components";
|
||||
import { AttachmentList, SavedRepliesSelectorModal } from "@/components";
|
||||
import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
|
||||
import EmailMultiSelect from "@/components/EmailMultiSelect.vue";
|
||||
import { createDialog } from "@/components/dialogs";
|
||||
import { AttachmentIcon } from "@/components/icons";
|
||||
import SavedReplyActions from "@/components/SavedReplyActions/SavedReplyActions.vue";
|
||||
import { useTyping } from "@/composables/realtime";
|
||||
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
|
||||
import { textEditorMenuItems } from "@/editor-menu";
|
||||
import { useUploadTracker } from "@/composables/useUploadTracker";
|
||||
import { replyComposer } from "@/pages/ticket/modalStates";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
CleanStyles,
|
||||
ComponentUtils,
|
||||
HandleExcelPaste,
|
||||
} from "@/tiptap-extensions";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { __ } from "@/translation";
|
||||
import { RenderedSavedReply } from "@/types";
|
||||
import {
|
||||
getFontFamily,
|
||||
htmlToText,
|
||||
@@ -236,16 +248,22 @@ import {
|
||||
validateEmailWithZod,
|
||||
} from "@/utils";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { FileUploader, createResource, toast } from "frappe-ui";
|
||||
import {
|
||||
FileUploader,
|
||||
LoadingIndicator,
|
||||
Tooltip,
|
||||
createResource,
|
||||
dayjs,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import {
|
||||
Editor,
|
||||
EditorBubbleMenu,
|
||||
EditorContent,
|
||||
EditorFixedMenu,
|
||||
RichTextKit,
|
||||
articleToolbar,
|
||||
EditorTableMenu,
|
||||
} from "frappe-ui/editor";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { addPendingActivity } from "@framework/ui/ActivityTimeline";
|
||||
import { useOnboarding } from "@framework/ui";
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -254,7 +272,7 @@ import {
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import SavedReplyIcon from "./icons/SavedReplyIcon.vue";
|
||||
import ZapIcon from "~icons/lucide/zap";
|
||||
|
||||
// ─── Props & Emits ────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
@@ -292,22 +310,19 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["submit", "discard"]);
|
||||
const emit = defineEmits(["submit", "discard", "sending", "restore"]);
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { isManager } = useAuthStore();
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
|
||||
const authStore = useAuthStore();
|
||||
const { isManager } = authStore;
|
||||
const { getUser } = useUserStore();
|
||||
const { onUserType, cleanup } = useTyping(props.ticketId);
|
||||
|
||||
const extensions = buildEditorExtensions();
|
||||
|
||||
const editorRef = ref(null);
|
||||
const editor = computed(() => editorRef.value?.editor);
|
||||
|
||||
const extensions = [
|
||||
RichTextKit.configure({ heading: { levels: [2, 3, 4, 5, 6] } }),
|
||||
ComponentUtils,
|
||||
HandleExcelPaste,
|
||||
CleanStyles,
|
||||
];
|
||||
|
||||
function focusEditorAtStart() {
|
||||
setTimeout(() => {
|
||||
editorRef.value?.editor?.commands?.focus("start");
|
||||
@@ -330,13 +345,6 @@ function isOnlySignature(content: string | null) {
|
||||
|
||||
const userResource = getUserEmailInfo();
|
||||
|
||||
watch(newEmail, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue && newValue) {
|
||||
onUserType();
|
||||
}
|
||||
cachedEmail.value = isOnlySignature(newValue) ? null : newValue;
|
||||
});
|
||||
|
||||
const quotedContent = useStorage<null | string>(
|
||||
"quotedEmailBoxContent" + props.ticketId,
|
||||
null
|
||||
@@ -350,42 +358,6 @@ function onQuotedInput() {
|
||||
quotedContent.value = el.innerHTML || null;
|
||||
}
|
||||
|
||||
watch(quotedContent, (newVal, oldVal) => {
|
||||
if (!oldVal && newVal) {
|
||||
nextTick(() => {
|
||||
if (quotedContentRef.value) {
|
||||
quotedContentRef.value.innerHTML = newVal;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => userResource.data,
|
||||
(data: { email_signature?: string } | null) => {
|
||||
if (!data?.email_signature) return;
|
||||
emailSignature.value = `<br>${data.email_signature}`;
|
||||
if (isOnlySignature(cachedEmail.value)) {
|
||||
cachedEmail.value = null;
|
||||
}
|
||||
if (isContentEmpty(newEmail.value) && !quotedContent.value) {
|
||||
newEmail.value = emailSignature.value;
|
||||
focusEditorAtStart();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (quotedContent.value) {
|
||||
nextTick(() => {
|
||||
if (quotedContentRef.value) {
|
||||
quotedContentRef.value.innerHTML = quotedContent.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const toEmailsClone = ref([...props.toEmails]);
|
||||
const ccEmailsClone = ref([...props.ccEmails]);
|
||||
const bccEmailsClone = ref([...props.bccEmails]);
|
||||
@@ -438,18 +410,14 @@ const from = computed(() => {
|
||||
|
||||
const hasMultipleSenders = computed(() => (from?.value.length ?? 0) > 1);
|
||||
|
||||
watch(
|
||||
from,
|
||||
(fromOptions) => {
|
||||
if (!fromOptions.find((f) => f.value === fromEmail.value)) {
|
||||
fromEmail.value = fromOptions.length ? fromOptions[0].value : "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const attachments = ref([]);
|
||||
const isUploading = ref(false);
|
||||
const attachmentUploading = ref(false);
|
||||
const { isUploading: editorUploading, track, dropUnused } = useUploadTracker();
|
||||
|
||||
// The paperclip and the editor's own media buttons upload by different routes
|
||||
const isUploading = computed(
|
||||
() => attachmentUploading.value || editorUploading.value
|
||||
);
|
||||
|
||||
async function removeAttachment(attachment) {
|
||||
attachments.value = attachments.value.filter((a) => a !== attachment);
|
||||
@@ -457,40 +425,90 @@ async function removeAttachment(attachment) {
|
||||
}
|
||||
|
||||
const showSavedRepliesSelectorModal = ref(false);
|
||||
const savedReplyActionsRef = ref<InstanceType<typeof SavedReplyActions>>();
|
||||
|
||||
function applySavedReplies(template: string) {
|
||||
/** A reply is only replaced when another one is already applied. */
|
||||
function applySavedReplies(reply: RenderedSavedReply) {
|
||||
const staged = savedReplyActionsRef.value?.stagedSummary();
|
||||
if (!staged) {
|
||||
insertSavedReply(reply);
|
||||
return;
|
||||
}
|
||||
createDialog({
|
||||
title: __("Replace saved reply"),
|
||||
message: __(
|
||||
'Applying "{0}" discards the reply you have now, along with its {1} action(s).',
|
||||
[reply.title, staged.count]
|
||||
),
|
||||
actions: [
|
||||
{ label: __("Cancel") },
|
||||
{
|
||||
label: __("Replace"),
|
||||
variant: "solid",
|
||||
onClick: ({ close }: { close: () => void }) => {
|
||||
replaceSavedReply(reply);
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** First reply of a draft: added to whatever the agent has already written. */
|
||||
function insertSavedReply(reply: RenderedSavedReply) {
|
||||
const textEditor = editorRef.value?.editor;
|
||||
if (!textEditor) return;
|
||||
textEditor.chain().focus("start").insertContent(template).run();
|
||||
textEditor.chain().focus("start").insertContent(reply.message).run();
|
||||
savedReplyActionsRef.value?.add(reply);
|
||||
}
|
||||
|
||||
/** Confirmed replace: the new reply's body and actions stand alone. */
|
||||
function replaceSavedReply(reply: RenderedSavedReply) {
|
||||
newEmail.value = reply.message + (emailSignature.value ?? "");
|
||||
savedReplyActionsRef.value?.add(reply);
|
||||
focusEditorAtStart();
|
||||
}
|
||||
|
||||
/** Set by submitMail, so the composer can be cleared before the request goes out. */
|
||||
let pendingRow: ReturnType<typeof addPendingActivity> | null = null;
|
||||
let sentDraft: {
|
||||
content: string | null;
|
||||
attachments: any[];
|
||||
quoted: string | null;
|
||||
quoteExpanded: boolean;
|
||||
} | null = null;
|
||||
|
||||
const sendMail = createResource({
|
||||
url: "run_doc_method",
|
||||
makeParams: () => ({
|
||||
dt: props.doctype,
|
||||
dn: props.ticketId,
|
||||
method: "reply_via_agent",
|
||||
args: {
|
||||
attachments: attachments.value.map((x) => x.name),
|
||||
from_email: selectedFromEmail.value,
|
||||
to: toEmailsClone.value.join(","),
|
||||
cc: ccEmailsClone.value?.join(","),
|
||||
bcc: bccEmailsClone.value?.join(","),
|
||||
message:
|
||||
newEmail.value +
|
||||
(quotedContentRef.value
|
||||
? `<p class="reply-to-content"></p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
|
||||
: ""),
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
resetState();
|
||||
makeParams: (params) => params,
|
||||
onSuccess: (res: { message?: string } | string) => {
|
||||
// run_doc_method answers with the whole body, since it always carries `docs`
|
||||
const name = typeof res === "string" ? res : res?.message;
|
||||
// the real row replaces the pending one the moment it arrives; unkeyed,
|
||||
// it would outlive it
|
||||
name ? pendingRow?.resolve(`email:${name}`) : pendingRow?.drop();
|
||||
pendingRow = null;
|
||||
sentDraft = null;
|
||||
savedReplyActionsRef.value?.submit();
|
||||
emit("submit");
|
||||
|
||||
if (isManager) {
|
||||
updateOnboardingStep("reply_on_ticket");
|
||||
updateOnboardingStep?.("reply_on_ticket");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__("Could not send the reply"));
|
||||
pendingRow?.drop();
|
||||
pendingRow = null;
|
||||
if (sentDraft) {
|
||||
newEmail.value = sentDraft.content;
|
||||
attachments.value = sentDraft.attachments;
|
||||
quotedContent.value = sentDraft.quoted;
|
||||
isQuoteExpanded.value = sentDraft.quoteExpanded;
|
||||
sentDraft = null;
|
||||
}
|
||||
emit("restore");
|
||||
},
|
||||
debounce: 300,
|
||||
});
|
||||
|
||||
@@ -507,6 +525,8 @@ function submitMail() {
|
||||
if (isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) {
|
||||
return false;
|
||||
}
|
||||
// The keyboard shortcut reaches here without passing the disabled button
|
||||
if (isUploading.value || sendMail.loading) return false;
|
||||
if (
|
||||
!toEmailsClone.value.length &&
|
||||
!ccEmailsClone.value.length &&
|
||||
@@ -518,7 +538,64 @@ function submitMail() {
|
||||
return false;
|
||||
}
|
||||
|
||||
sendMail.submit();
|
||||
const message =
|
||||
newEmail.value +
|
||||
(quotedContentRef.value
|
||||
? `<p class="reply-to-content"></p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
|
||||
: "");
|
||||
const sender = selectedFromEmail.value?.email_id ?? authStore.userId;
|
||||
const ownEmail = getUser(authStore.userId).email;
|
||||
|
||||
pendingRow?.drop();
|
||||
pendingRow = addPendingActivity(props.doctype, props.ticketId, {
|
||||
type: "email",
|
||||
timestamp: dayjs().format("YYYY-MM-DD HH:mm:ss"),
|
||||
author: {
|
||||
email: sender,
|
||||
// the users list may still be a stub with a guessed name and no avatar
|
||||
fullname: authStore.userName,
|
||||
image: sender === ownEmail ? authStore.userImage : undefined,
|
||||
},
|
||||
data: {
|
||||
name: "",
|
||||
sender,
|
||||
to: toEmailsClone.value.join(", "),
|
||||
cc: ccEmailsClone.value?.join(", "),
|
||||
bcc: bccEmailsClone.value?.join(", "),
|
||||
content: message,
|
||||
attachments: attachments.value.map((a) => ({
|
||||
file_url: a.file_url,
|
||||
file_name: a.file_name,
|
||||
is_private: a.is_private,
|
||||
})),
|
||||
},
|
||||
});
|
||||
sentDraft = {
|
||||
content: newEmail.value,
|
||||
attachments: attachments.value,
|
||||
quoted: quotedContent.value,
|
||||
quoteExpanded: isQuoteExpanded.value,
|
||||
};
|
||||
|
||||
const params = {
|
||||
dt: props.doctype,
|
||||
dn: props.ticketId,
|
||||
method: "reply_via_agent",
|
||||
args: {
|
||||
attachments: attachments.value.map((x) => x.name),
|
||||
from_email: selectedFromEmail.value,
|
||||
to: toEmailsClone.value.join(","),
|
||||
cc: ccEmailsClone.value?.join(","),
|
||||
bcc: bccEmailsClone.value?.join(","),
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
// drop before resetState clears, or an unmount mid-send deletes what it carries
|
||||
dropUnused(message);
|
||||
resetState();
|
||||
emit("sending");
|
||||
sendMail.submit(params);
|
||||
}
|
||||
|
||||
function getInitialContent() {
|
||||
@@ -535,6 +612,13 @@ function addToReply(
|
||||
ccEmailsClone.value = ccEmails;
|
||||
bccEmailsClone.value = bccEmails;
|
||||
|
||||
// Plain-text emails (e.g. Thunderbird) have no HTML tags, so their newlines/spacing
|
||||
// thus lose formatting when added to replied content
|
||||
const doc = new DOMParser().parseFromString(body, "text/html");
|
||||
if (doc.body.children.length === 0) {
|
||||
body = `<div style="white-space: pre-wrap; line-height: 1.5">${doc.body.innerHTML}</div>`;
|
||||
}
|
||||
|
||||
if (body !== quotedContent.value) {
|
||||
//trigger change for watch when replied to body data is different from current quoted content
|
||||
quotedContent.value = null;
|
||||
@@ -550,6 +634,7 @@ function addToReply(
|
||||
focusEditorAtStart();
|
||||
}
|
||||
|
||||
// Staged actions are left to `submit()`, which unstages them either way
|
||||
function resetState() {
|
||||
newEmail.value = emailSignature.value ? emailSignature.value : null;
|
||||
attachments.value = [];
|
||||
@@ -560,6 +645,7 @@ function resetState() {
|
||||
|
||||
function handleDiscard() {
|
||||
attachments.value = [];
|
||||
savedReplyActionsRef.value?.clear();
|
||||
newEmail.value = getInitialContent();
|
||||
quotedContent.value = null;
|
||||
ccEmailsClone.value = [];
|
||||
@@ -568,6 +654,7 @@ function handleDiscard() {
|
||||
showBCC.value = false;
|
||||
isQuoteExpanded.value = false;
|
||||
|
||||
dropUnused(null);
|
||||
focusEditorAtStart();
|
||||
emit("discard");
|
||||
}
|
||||
@@ -582,6 +669,9 @@ function handleSelectAll(e: KeyboardEvent) {
|
||||
if (!editorDom.contains(active) && !(quotedEl && quotedEl.contains(active))) {
|
||||
return;
|
||||
}
|
||||
// after the focus check: select-all from a recipient field must not unfold
|
||||
// the quoted reply
|
||||
isQuoteExpanded.value = true;
|
||||
e.preventDefault();
|
||||
editorContext?.commands.selectAll();
|
||||
sel.removeAllRanges();
|
||||
@@ -623,7 +713,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && key === "a") {
|
||||
isQuoteExpanded.value = true;
|
||||
handleSelectAll(e);
|
||||
return;
|
||||
}
|
||||
@@ -634,13 +723,80 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(newEmail, (newValue, oldValue) => {
|
||||
// The signature drops into an empty editor on load and on every reply. That
|
||||
// is not the agent typing, and broadcasting it would show a typing indicator
|
||||
// to everyone else on the ticket.
|
||||
if (
|
||||
newValue !== oldValue &&
|
||||
!isContentEmpty(newValue) &&
|
||||
!isOnlySignature(newValue)
|
||||
) {
|
||||
onUserType();
|
||||
}
|
||||
cachedEmail.value = isOnlySignature(newValue) ? null : newValue;
|
||||
});
|
||||
|
||||
watch(quotedContent, (newVal, oldVal) => {
|
||||
if (!oldVal && newVal) {
|
||||
nextTick(() => {
|
||||
if (quotedContentRef.value) {
|
||||
quotedContentRef.value.innerHTML = newVal;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => userResource.data,
|
||||
(data: { email_signature?: string } | null) => {
|
||||
if (!data?.email_signature) return;
|
||||
emailSignature.value = `<br>${data.email_signature}`;
|
||||
if (isOnlySignature(cachedEmail.value)) {
|
||||
cachedEmail.value = null;
|
||||
}
|
||||
if (isContentEmpty(newEmail.value) && !quotedContent.value) {
|
||||
newEmail.value = emailSignature.value;
|
||||
focusEditorAtStart();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
from,
|
||||
(fromOptions) => {
|
||||
if (!fromOptions.find((f) => f.value === fromEmail.value)) {
|
||||
fromEmail.value = fromOptions.length ? fromOptions[0].value : "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Published for the command palette, which cannot reach `editorRef` from
|
||||
// module scope. See modalStates.ts.
|
||||
replyComposer.value = applySavedReplies;
|
||||
if (quotedContent.value) {
|
||||
nextTick(() => {
|
||||
if (quotedContentRef.value) {
|
||||
quotedContentRef.value.innerHTML = quotedContent.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
replyComposer.value = null;
|
||||
cleanup();
|
||||
// the saved draft still references what it shows, so only what it dropped goes
|
||||
dropUnused(`${newEmail.value ?? ""}${quotedContent.value ?? ""}`);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
addToReply,
|
||||
editor,
|
||||
isUploading,
|
||||
submitMail,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div ref="pills" class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
@@ -11,7 +10,7 @@
|
||||
:tooltip="copyOnClick ? __('Click to copy') : undefined"
|
||||
:class="[
|
||||
{
|
||||
'rounded border !bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
|
||||
'rounded-4 !bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
|
||||
variant === 'subtle',
|
||||
},
|
||||
copyOnClick
|
||||
@@ -22,11 +21,7 @@
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="lucide-x h-3.5"
|
||||
aria-hidden="true"
|
||||
@click.stop="removeValue(value)"
|
||||
/>
|
||||
<LucideX class="size-3.5" @click.stop="removeValue(value)" />
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1 min-w-32">
|
||||
@@ -39,7 +34,7 @@
|
||||
highlight-on-hover
|
||||
>
|
||||
<ComboboxAnchor
|
||||
class="flex h-7 max-w-full w-auto items-center gap-2 rounded px-2 py-1 border border-transparent"
|
||||
class="flex h-7 max-w-full w-auto items-center gap-2 rounded-4 px-2 py-1 border border-transparent"
|
||||
:class="[
|
||||
variant == 'ghost'
|
||||
? 'bg-surface-base hover:bg-surface-base'
|
||||
@@ -62,7 +57,7 @@
|
||||
</ComboboxAnchor>
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
class="z-10 mt-1 min-w-48 w-auto max-w-96 bg-surface-elevation-2 overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
class="z-[100] mt-1 min-w-48 w-auto max-w-96 bg-surface-elevation-2 overflow-hidden rounded-6 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
position="popper"
|
||||
:align="'start'"
|
||||
@openAutoFocus.prevent
|
||||
@@ -70,28 +65,28 @@
|
||||
>
|
||||
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
|
||||
<ComboboxEmpty
|
||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||
class="flex gap-2 rounded-4 px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
<span class="lucide-search h-4" aria-hidden="true" />
|
||||
<LucideSearch class="size-4" />
|
||||
{{ __(emptyPlaceholder) }}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
|
||||
class="text-base leading-none text-ink-gray-7 rounded-4 flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
|
||||
@mousedown.prevent="onSelect(option.value)"
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-0.5"
|
||||
class="mr-0.5 shrink-0"
|
||||
:name="getUsernameLabel(option.label)"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base-medium">
|
||||
<div class="flex min-w-0 flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="truncate text-base-medium">
|
||||
{{ getUsernameLabel(option.label) }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
<div class="truncate text-sm text-ink-gray-5">
|
||||
{{ option.isCustom ? customEmailLabel : option.value }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +108,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LucideSearch from "~icons/lucide/search";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { copy } from "@/utils";
|
||||
@@ -151,7 +148,7 @@ const errorMessage = (value) => __("{0} is an Invalid Email Address", [value]);
|
||||
const values = defineModel();
|
||||
|
||||
// Common state
|
||||
const emails = ref([]);
|
||||
const pills = ref(null);
|
||||
const search = ref(null);
|
||||
const error = ref(null);
|
||||
const info = ref(null);
|
||||
@@ -171,6 +168,7 @@ const filterContacts = createResource({
|
||||
label: full_name || email_id || name,
|
||||
value: email_id || name,
|
||||
email: email_id,
|
||||
name,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -205,19 +203,12 @@ const options = computed(() => {
|
||||
if (props.existingUsers?.length) {
|
||||
list = list.filter((o) => !props.existingUsers.includes(o.value));
|
||||
}
|
||||
if (query.value) {
|
||||
const q = query.value.toLowerCase();
|
||||
list = list.filter(
|
||||
(o) =>
|
||||
o.label?.toLowerCase().includes(q) ||
|
||||
o.value?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
return list.filter(matchesQuery);
|
||||
}
|
||||
|
||||
// contacts mode
|
||||
let list = filterContacts.data || [];
|
||||
// contacts mode; the search is debounced, so narrow the last results to the
|
||||
// typed text, or a quick Enter picks a contact that no longer matches
|
||||
let list = (filterContacts.data || []).filter(matchesQuery);
|
||||
if (values.value?.length) {
|
||||
list = list.filter((o) => !values.value.includes(o.value));
|
||||
}
|
||||
@@ -236,6 +227,13 @@ const options = computed(() => {
|
||||
return list;
|
||||
});
|
||||
|
||||
function matchesQuery(option) {
|
||||
const q = query.value.toLowerCase();
|
||||
return [option.label, option.value, option.name].some((field) =>
|
||||
field?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function isExistingUser(email) {
|
||||
if (!email) return false;
|
||||
return props.existingUsers?.some(
|
||||
@@ -310,24 +308,32 @@ function removeValue(value) {
|
||||
values.value = values.value.filter((v) => v !== value);
|
||||
}
|
||||
|
||||
// Read the pills from the DOM: a Button with a tooltip renders extra root
|
||||
// nodes, so its `$el` is not the <button>.
|
||||
function lastPill() {
|
||||
const buttons = pills.value?.querySelectorAll(":scope > button");
|
||||
return buttons?.[buttons.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function removeLastValue() {
|
||||
if (query.value) return;
|
||||
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
|
||||
if (document.activeElement === emailRef) {
|
||||
const pill = lastPill();
|
||||
if (document.activeElement === pill) {
|
||||
values.value.pop();
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].rootRef;
|
||||
emailRef?.focus();
|
||||
lastPill()?.focus();
|
||||
} else {
|
||||
setFocus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
emailRef?.focus();
|
||||
pill?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: use single contact component. This refocus also fires when focus has
|
||||
// already moved on, so a click elsewhere within 200ms is pulled back here.
|
||||
function setFocus() {
|
||||
setTimeout(() => {
|
||||
search.value?.$el?.focus();
|
||||
|
||||
@@ -52,9 +52,10 @@
|
||||
<span
|
||||
v-if="descriptionText"
|
||||
:class="{
|
||||
'text-center text-xs text-ink-gray-6 mt-1': text === 'sm',
|
||||
'text-center text-sm text-ink-gray-6 mt-1': text === 'md' || !text,
|
||||
'text-center text-base text-ink-gray-6 mt-1': text === 'lg',
|
||||
'text-center text-p-xs text-ink-gray-6 mt-1': text === 'sm',
|
||||
'text-center text-p-sm text-ink-gray-6 mt-1':
|
||||
text === 'md' || !text,
|
||||
'text-center text-p-base text-ink-gray-6 mt-1': text === 'lg',
|
||||
}"
|
||||
>
|
||||
{{ __(descriptionText) }}
|
||||
|
||||
18
desk/src/components/FieldLabel.vue
Normal file
18
desk/src/components/FieldLabel.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="w-[106px] shrink-0 truncate text-base text-ink-gray-5">
|
||||
<Tooltip :text="__(label)">
|
||||
<span>{{ __(label) }}</span>
|
||||
</Tooltip>
|
||||
<span v-if="required" class="text-ink-red-6"> * </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from "frappe-ui";
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
// Frappe fields carry 1 / 0, not true / false
|
||||
required?: boolean | number;
|
||||
}>();
|
||||
</script>
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="mt-0.5 flex justify-between text-base items-start md:items-center"
|
||||
>
|
||||
<div
|
||||
v-if="relatedActivities.length > 1"
|
||||
class="inline-flex flex-wrap gap-1.5 text-ink-gray-8 font-medium w-4/5"
|
||||
>
|
||||
<span>{{ `${show_others ? __("Hide ") : __("Show ")}` }}</span>
|
||||
<span>+{{ relatedActivities.length }} </span>
|
||||
<span>{{ __("changes from") }} </span>
|
||||
<span>{{ user }}</span>
|
||||
|
||||
<Button
|
||||
class="!size-4"
|
||||
variant="ghost"
|
||||
@click="show_others = !show_others"
|
||||
>
|
||||
<template #icon>
|
||||
<SelectIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-ink-gray-5 w-4/6 text-p-base" v-else>
|
||||
<span class="font-medium text-ink-gray-8">
|
||||
{{ user }}
|
||||
</span>
|
||||
<span> {{ content }}</span>
|
||||
</div>
|
||||
|
||||
<div class="text-ink-gray-5 text-sm w-2/6 flex justify-end">
|
||||
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
|
||||
<span>{{ timeAgo(creation) }}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="show_others" class="space-y-2.5 mt-3.5">
|
||||
<div
|
||||
v-for="relatedActivity in relatedActivities"
|
||||
:key="relatedActivity.creation"
|
||||
class="flex justify-between text-base"
|
||||
>
|
||||
<div class="text-ink-gray-5 w-4/6">
|
||||
<span class="font-medium text-ink-gray-8">
|
||||
{{ relatedActivity.user }}
|
||||
</span>
|
||||
<span> {{ relatedActivity.content }}</span>
|
||||
</div>
|
||||
<Tooltip
|
||||
:text="dateFormat(relatedActivity.creation, dateTooltipFormat)"
|
||||
>
|
||||
<div class="text-ink-gray-5 text-sm flex justify-end">
|
||||
{{ timeAgo(relatedActivity.creation) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SelectIcon } from "@/components/icons";
|
||||
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
|
||||
import { computed, ref } from "vue";
|
||||
const props = defineProps({
|
||||
activity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { user, content, creation } = props.activity;
|
||||
const relatedActivities = computed(() => props.activity.relatedActivities);
|
||||
|
||||
let show_others = ref(false);
|
||||
</script>
|
||||
@@ -2,20 +2,18 @@
|
||||
<div v-if="isEmoji(icon)" v-bind="$attrs">
|
||||
{{ icon }}
|
||||
</div>
|
||||
<span
|
||||
v-else-if="typeof icon == 'string' && icon.startsWith('lucide-')"
|
||||
:class="icon"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<span
|
||||
<SpriteIcon
|
||||
v-else-if="typeof icon == 'string'"
|
||||
:class="`lucide-${icon}`"
|
||||
:name="icon.replace('lucide-', '')"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<component v-else :is="icon" v-bind="$attrs" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { isEmoji } from "@/utils";
|
||||
// Sprite, not a `lucide-*` class: these names come from stored view icons, and
|
||||
// Tailwind only emits CSS for class names it can see in source.
|
||||
import { Icon as SpriteIcon } from "frappe-ui/experimental";
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ togglePopover, isOpen }">
|
||||
<slot v-bind="{ isOpen, togglePopover }">
|
||||
<span class="text-base"> {{ modelValue || "" }} </span>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
v-if="reaction"
|
||||
class="px-2 py-1 flex items-center justify-center gap-2 rounded-full bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div
|
||||
class="size-5 cursor-pointer rounded-full bg-surface-transparent text-xl"
|
||||
v-for="r in reactionEmojis"
|
||||
:key="r"
|
||||
@click="() => (emoji = r) && togglePopover()"
|
||||
>
|
||||
<button>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
class="rounded-full"
|
||||
icon="lucide-plus"
|
||||
@click.stop="() => (reaction = false)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="my-3 max-w-max transform bg-surface-base px-4 sm:px-0">
|
||||
<div
|
||||
class="relative max-h-96 pb-3 overflow-y-auto min-w-40 rounded-lg bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div class="flex gap-2 px-3 pb-1 pt-3">
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="Search by keyword"
|
||||
v-model="search"
|
||||
:debounce="300"
|
||||
/>
|
||||
</div>
|
||||
<Button @click="setRandom">Random</Button>
|
||||
</div>
|
||||
<div class="w-96"></div>
|
||||
<div class="px-3" v-for="(emojis, group) in emojiGroups" :key="group">
|
||||
<div
|
||||
class="sticky top-0 bg-surface-elevation-2 pb-2 pt-3 text-sm text-ink-gray-7"
|
||||
>
|
||||
{{ group }}
|
||||
</div>
|
||||
<div class="grid w-96 grid-cols-12 place-items-center">
|
||||
<button
|
||||
class="h-8 w-8 rounded-md p-1 text-2xl hover:bg-surface-gray-2 focus:outline-none focus:ring focus:ring-outline-blue-2"
|
||||
v-for="_emoji in emojis"
|
||||
:key="_emoji.description"
|
||||
@click="() => (emoji = _emoji.emoji) && togglePopover()"
|
||||
:title="_emoji.description"
|
||||
>
|
||||
{{ _emoji.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { Popover } from "frappe-ui";
|
||||
import { gemoji } from "gemoji";
|
||||
|
||||
const search = ref("");
|
||||
const emoji = defineModel();
|
||||
const reaction = defineModel("reaction");
|
||||
|
||||
const reactionEmojis = ref(["👍", "❤️", "😂", "😮", "😢", "🙏"]);
|
||||
|
||||
const emojiGroups = computed(() => {
|
||||
let groups = {};
|
||||
for (let _emoji of gemoji) {
|
||||
if (search.value) {
|
||||
let keywords = [_emoji.description, ..._emoji.names, ..._emoji.tags]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!keywords.includes(search.value.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let group = groups[_emoji.category];
|
||||
if (!group) {
|
||||
groups[_emoji.category] = [];
|
||||
group = groups[_emoji.category];
|
||||
}
|
||||
group.push(_emoji);
|
||||
}
|
||||
if (!Object.keys(groups).length) {
|
||||
groups["No results"] = [];
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
function setRandom() {
|
||||
let total = gemoji.length;
|
||||
let index = randomInt(0, total - 1);
|
||||
emoji.value = gemoji[index].emoji;
|
||||
}
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
defineExpose({ setRandom });
|
||||
</script>
|
||||
@@ -12,7 +12,7 @@
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/40 text-ink-base opacity-0 outline-none transition focus:outline-none focus-visible:outline-none group-hover:opacity-100"
|
||||
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/40 text-white opacity-0 outline-none transition focus:outline-none focus-visible:outline-none group-hover:opacity-100"
|
||||
:class="roundedClass"
|
||||
:aria-label="__('Replace image')"
|
||||
@click.prevent="openFileSelector()"
|
||||
@@ -21,7 +21,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute -right-2.5 -top-2.5 flex size-4.5 cursor-pointer items-center justify-center rounded-full bg-surface-base text-ink-gray-4 opacity-0 outline outline-black-overlay-50 duration-300 ease-in-out focus:outline-none focus-visible:outline-none hover:bg-surface-gray-2 group-hover:opacity-100"
|
||||
class="absolute -right-2.5 -top-2.5 flex size-4.5 cursor-pointer items-center justify-center rounded-full bg-surface-base text-ink-gray-4 opacity-0 outline outline-black-overlay-50 duration-300 ease-in-out focus:outline-none focus-visible:outline-none hover:bg-surface-gray-2 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
:aria-label="__('Remove image')"
|
||||
@click.prevent="image = ''"
|
||||
>
|
||||
|
||||
@@ -71,7 +71,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const currentDuration = ref(__("Last month"));
|
||||
// the untranslated key: it is lowercased straight into the API period
|
||||
const currentDuration = ref("Last month");
|
||||
|
||||
const chartData = computed(() => {
|
||||
const isDataFetched = resource.fetched;
|
||||
|
||||
@@ -57,14 +57,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Dropdown } from "frappe-ui";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
ListGroupHeader,
|
||||
ListGroupRows,
|
||||
ListRow,
|
||||
ListRows,
|
||||
} from "frappe-ui";
|
||||
} from "frappe-ui/experimental";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import IconMoreHorizontal from "~icons/lucide/more-horizontal";
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
v-if="list.loading && !list.data?.data?.length"
|
||||
class="flex items-center justify-center h-full w-full absolute top-0 z-100"
|
||||
>
|
||||
<LoadingIndicator :scale="8" />
|
||||
<LoadingIndicator class="size-6 text-ink-gray-4" />
|
||||
</div>
|
||||
<!-- List View -->
|
||||
<ListView
|
||||
@@ -86,9 +86,19 @@
|
||||
</ListRows>
|
||||
<ListSelectBanner v-if="options.showSelectBanner">
|
||||
<template #actions="{ selections, unselectAll }">
|
||||
<Dropdown :options="selectBannerOptions(selections, unselectAll)">
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-for="action in selectBannerOptions(selections, unselectAll, true)"
|
||||
:key="action.label"
|
||||
:label="action.label"
|
||||
:icon-left="action.icon"
|
||||
variant="ghost"
|
||||
@click="action.onClick"
|
||||
/>
|
||||
<Dropdown :options="selectBannerOptions(selections, unselectAll)">
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
@@ -111,7 +121,17 @@
|
||||
handlePageLength(count);
|
||||
}
|
||||
"
|
||||
/>
|
||||
>
|
||||
<!-- frappe-ui's ListFooter still feeds TabButtons the removed `buttons`
|
||||
prop, so its own page-length picker renders nothing. -->
|
||||
<template #left>
|
||||
<TabButtons
|
||||
:model-value="defaultParams.page_length_count"
|
||||
:options="[20, 50, 100].map((o) => ({ label: String(o), value: o }))"
|
||||
@update:model-value="(count) => handlePageLength(count)"
|
||||
/>
|
||||
</template>
|
||||
</ListFooter>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
@@ -147,22 +167,27 @@ import { getIcon } from "@/utils";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import {
|
||||
createResource,
|
||||
dayjsLocal,
|
||||
Dropdown,
|
||||
frappeRequest,
|
||||
LoadingIndicator,
|
||||
TabButtons,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import {
|
||||
Icon as SpriteIcon,
|
||||
ListFooter,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
ListView,
|
||||
LoadingIndicator,
|
||||
dayjs,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
} from "frappe-ui/experimental";
|
||||
import {
|
||||
computed,
|
||||
h,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
reactive,
|
||||
ref,
|
||||
@@ -172,6 +197,7 @@ import {
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import EmptyState from "./EmptyState.vue";
|
||||
import { listFilters } from "./listViewFilters";
|
||||
import ListRows from "./ListRows.vue";
|
||||
|
||||
interface P {
|
||||
@@ -242,7 +268,7 @@ const defaultOptions = reactive({
|
||||
label: __("Delete"),
|
||||
variant: "solid",
|
||||
theme: "red",
|
||||
iconLeft: "trash-2",
|
||||
iconLeft: "lucide-trash-2",
|
||||
onClick({ close }) {
|
||||
handleBulkDelete(close, selections);
|
||||
},
|
||||
@@ -391,7 +417,12 @@ const exposeFunctions = {
|
||||
unselectAll: () => {},
|
||||
};
|
||||
|
||||
function selectBannerOptions(selections: Set<string>, unselectAll = () => {}) {
|
||||
/** Banner actions. `inline` picks the ones shown as buttons; the rest fill the "..." menu. */
|
||||
function selectBannerOptions(
|
||||
selections: Set<string>,
|
||||
unselectAll = () => {},
|
||||
inline = false
|
||||
) {
|
||||
exposeFunctions["unselectAll"] = unselectAll;
|
||||
|
||||
// Get the user-provided actions
|
||||
@@ -414,8 +445,10 @@ function selectBannerOptions(selections: Set<string>, unselectAll = () => {}) {
|
||||
onClick: () => action.onClick?.(selections),
|
||||
}));
|
||||
|
||||
// Return combined actions
|
||||
return [...userActions, ...defaultActions];
|
||||
return [...userActions, ...defaultActions].filter(
|
||||
(action) =>
|
||||
Boolean(action.inline) === inline && (action.condition?.() ?? true)
|
||||
);
|
||||
}
|
||||
|
||||
const rows = computed(() => {
|
||||
@@ -445,9 +478,9 @@ function getGroupedByRows(listRows, groupByField) {
|
||||
group: option || " ",
|
||||
collapsed: false,
|
||||
rows: filteredRows,
|
||||
icon: h("span", {
|
||||
class: ["lucide-folder", "h-4 w-4 flex-shrink-0 text-ink-gray-6"],
|
||||
"aria-hidden": "true",
|
||||
icon: h(SpriteIcon, {
|
||||
name: "folder",
|
||||
class: "h-4 w-4 flex-shrink-0 text-ink-gray-6",
|
||||
}),
|
||||
};
|
||||
groupedRows.push(groupDetail);
|
||||
@@ -529,7 +562,7 @@ function listCell(column: any, row: any, item: any, idx: number) {
|
||||
if (column.type === "Datetime") {
|
||||
return h("span", {
|
||||
class: "text-base",
|
||||
textContent: dayjs(item).fromNow(),
|
||||
textContent: dayjsLocal(item).fromNow(),
|
||||
});
|
||||
}
|
||||
if (column.type === "MultipleAvatar") {
|
||||
@@ -568,16 +601,11 @@ function handleFieldClick(e: MouseEvent, column, row, item) {
|
||||
}
|
||||
|
||||
if (column.type === "MultipleAvatar") {
|
||||
if (item.length > 1) {
|
||||
let target = e.target as HTMLElement;
|
||||
target = target.closest(".user-avatar");
|
||||
if (target) {
|
||||
item = target.getAttribute("data-name");
|
||||
}
|
||||
} else {
|
||||
item = item[0].name;
|
||||
}
|
||||
applyColumnFilter(column.key, "LIKE", `%${item}%`);
|
||||
const avatar = (e.target as HTMLElement).closest(".user-avatar");
|
||||
// the gap between the faces, and the "+n" chip, name nobody to filter by
|
||||
const name = avatar?.getAttribute("data-name");
|
||||
if (!name) return;
|
||||
applyColumnFilter(column.key, "LIKE", `%${name}%`);
|
||||
return;
|
||||
}
|
||||
applyColumnFilter(column.key, "=", item);
|
||||
@@ -616,6 +644,14 @@ provide("listViewActions", {
|
||||
reload,
|
||||
});
|
||||
|
||||
// Also published module-scope, for the command palette: it renders in the
|
||||
// sidebar, outside this provide chain.
|
||||
listFilters.value = {
|
||||
current: () => normalizeFilters(list?.params?.filters),
|
||||
apply: applyFilters,
|
||||
};
|
||||
onUnmounted(() => (listFilters.value = null));
|
||||
|
||||
function applyFilters(filters) {
|
||||
isViewUpdated.value = true;
|
||||
defaultParams.filters = normalizeFilters(filters);
|
||||
@@ -743,38 +779,66 @@ function handleReload() {
|
||||
}
|
||||
|
||||
function handleViewChanges() {
|
||||
let currentView: View = findCurrentView();
|
||||
if (!currentView) {
|
||||
router.push({ name: route.name });
|
||||
reload(true);
|
||||
if (!switchToView(route.query.view as string)) return;
|
||||
applyUrlFilters();
|
||||
list.submit({ ...defaultParams });
|
||||
}
|
||||
|
||||
/** Base for URL filters, so repeated pushes layer on the view, not each other. */
|
||||
let viewFilters = [];
|
||||
|
||||
/** Owns sort, columns and rows. False means a redirect is in flight. */
|
||||
function switchToView(view: string): boolean {
|
||||
defaultParams.view.name = view;
|
||||
const currentView: View = findCurrentView();
|
||||
if (currentView) {
|
||||
// normalize so legacy dict-format saved views become list conditions
|
||||
viewFilters = normalizeFilters(currentView.filters);
|
||||
defaultParams.order_by = currentView.order_by || "modified desc";
|
||||
defaultParams.columns = currentView.columns;
|
||||
defaultParams.rows = currentView.rows;
|
||||
return true;
|
||||
}
|
||||
if (view) {
|
||||
// Stale ?view: drop it but keep the filters, unlike a push to the bare route.
|
||||
router.replace({
|
||||
name: route.name,
|
||||
query: { ...route.query, view: undefined },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
viewFilters = normalizeFilters(options.value.defaultFilters);
|
||||
defaultParams.order_by = "modified desc";
|
||||
defaultParams.columns = [];
|
||||
defaultParams.rows = [];
|
||||
defaultParams.is_default = true;
|
||||
headerView.value.label = __("List");
|
||||
headerView.value.icon = LucideAlignJustify;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Touches `filters` only, so filtering never resets the user's sort. */
|
||||
function applyUrlFilters() {
|
||||
const urlFilters = parseUrlFilters();
|
||||
if (!urlFilters) {
|
||||
defaultParams.filters = viewFilters;
|
||||
return;
|
||||
}
|
||||
// normalize so legacy dict-format saved views become list conditions
|
||||
defaultParams.filters = normalizeFilters(currentView.filters);
|
||||
defaultParams.order_by = currentView.order_by || "modified desc";
|
||||
defaultParams.columns = currentView.columns;
|
||||
defaultParams.rows = currentView.rows;
|
||||
const overriddenFields = new Set(urlFilters.map((c) => c[0]));
|
||||
defaultParams.filters = urlFilters.length
|
||||
? [...viewFilters.filter((c) => !overriddenFields.has(c[0])), ...urlFilters]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (route.query.filters) {
|
||||
try {
|
||||
const parsedFilters = normalizeFilters(
|
||||
JSON.parse(route.query.filters as string)
|
||||
);
|
||||
if (parsedFilters.length > 0) {
|
||||
const overriddenFields = new Set(parsedFilters.map((c) => c[0]));
|
||||
defaultParams.filters = [
|
||||
...normalizeFilters(defaultParams.filters).filter(
|
||||
(c) => !overriddenFields.has(c[0])
|
||||
),
|
||||
...parsedFilters,
|
||||
];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse filters from URL", e);
|
||||
}
|
||||
/** null when the URL carries no filters; [] means "clear them". */
|
||||
function parseUrlFilters() {
|
||||
if (!route.query.filters) return null;
|
||||
try {
|
||||
return normalizeFilters(JSON.parse(route.query.filters as string));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse filters from URL", e);
|
||||
return null;
|
||||
}
|
||||
|
||||
list.submit({ ...defaultParams });
|
||||
}
|
||||
|
||||
function findCurrentView() {
|
||||
@@ -789,15 +853,14 @@ function findCurrentView() {
|
||||
return currentView;
|
||||
}
|
||||
|
||||
// The view is re-read only when it changes; re-reading it on every filter
|
||||
// change is what let a filter silently reset the sort.
|
||||
watch(
|
||||
() => route.query.view,
|
||||
(val: string) => {
|
||||
defaultParams.view.name = val;
|
||||
handleViewChanges();
|
||||
if (!val) {
|
||||
headerView.value.label = __("List");
|
||||
headerView.value.icon = LucideAlignJustify;
|
||||
}
|
||||
[() => route.query.view as string, () => route.query.filters as string],
|
||||
([view], [previousView]) => {
|
||||
if (view !== previousView && !switchToView(view)) return;
|
||||
applyUrlFilters();
|
||||
list.submit({ ...defaultParams });
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 rounded-lg bg-surface-gray-2 p-2">
|
||||
<div class="flex flex-wrap gap-2 rounded-6 bg-surface-gray-2 p-2">
|
||||
<Pill
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<template>
|
||||
<div v-if="_avatars?.length" class="me-1.5 flex cursor-pointer items-center">
|
||||
<div
|
||||
v-if="_avatars?.length"
|
||||
class="isolate me-1.5 flex min-w-0 cursor-pointer items-center"
|
||||
>
|
||||
<div
|
||||
v-if="_avatars?.length == 1"
|
||||
class="flex items-center gap-2 text-base line-clamp-1"
|
||||
class="user-avatar flex min-w-0 items-center gap-2 text-base line-clamp-1"
|
||||
:data-name="_avatars[0].name"
|
||||
>
|
||||
<Tooltip :text="_avatars[0].name">
|
||||
<Avatar
|
||||
@@ -11,24 +15,34 @@
|
||||
:label="_avatars[0].label"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="truncate" v-if="!hideName">{{ _avatars[0].label }}</div>
|
||||
<div class="min-w-0 truncate" v-if="!hideName">
|
||||
{{ _avatars[0].label }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-for="avatar in _avatars"
|
||||
v-else
|
||||
:key="avatar.name"
|
||||
:text="avatar.name"
|
||||
>
|
||||
<Avatar
|
||||
class="user-avatar -mr-1.5 ring-2 ring-[var(--surface-base)] transition hover:z-10 hover:scale-110"
|
||||
shape="circle"
|
||||
:image="avatar.image"
|
||||
:label="avatar.label"
|
||||
:size="size"
|
||||
:data-name="avatar.name"
|
||||
/>
|
||||
</Tooltip>
|
||||
<template v-else>
|
||||
<Tooltip
|
||||
v-for="avatar in visibleAvatars"
|
||||
:key="avatar.name"
|
||||
:text="avatar.name"
|
||||
>
|
||||
<Avatar
|
||||
class="user-avatar -mr-1.5 ring-2 ring-[var(--surface-base)] transition hover:z-20 hover:scale-110"
|
||||
shape="circle"
|
||||
:image="avatar.image"
|
||||
:label="avatar.label"
|
||||
:size="size"
|
||||
:data-name="avatar.name"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="overflowCount" :text="overflowNames">
|
||||
<div
|
||||
class="user-avatar relative z-10 -mr-1.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-surface-gray-3 text-xs text-ink-gray-7 ring-2 ring-[var(--surface-base)]"
|
||||
>
|
||||
+{{ overflowCount }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -37,8 +51,10 @@ import { Avatar, Tooltip } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
// Frappe hands `_assign` over as a JSON string; callers with the list
|
||||
// already parsed pass the array itself.
|
||||
avatars: {
|
||||
type: String,
|
||||
type: [String, Array],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
@@ -48,6 +64,12 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Cap the number of avatars shown; the rest collapse into a "+n" chip.
|
||||
// 0 (default) shows all, so existing callers are unaffected.
|
||||
max: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const { getUser } = useUserStore();
|
||||
const _avatars = computed(() => {
|
||||
@@ -71,4 +93,25 @@ const _avatars = computed(() => {
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
const capped = computed(
|
||||
() => props.max > 0 && (_avatars.value?.length || 0) > props.max
|
||||
);
|
||||
|
||||
const visibleAvatars = computed(() =>
|
||||
capped.value ? _avatars.value.slice(0, props.max) : _avatars.value
|
||||
);
|
||||
|
||||
const overflowCount = computed(() =>
|
||||
capped.value ? _avatars.value.length - props.max : 0
|
||||
);
|
||||
|
||||
const overflowNames = computed(() =>
|
||||
capped.value
|
||||
? _avatars.value
|
||||
.slice(props.max)
|
||||
.map((a: { name: string }) => a.name)
|
||||
.join(", ")
|
||||
: ""
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<template>
|
||||
<Popover v-slot="{ open }">
|
||||
<PopoverButton
|
||||
ref="reference"
|
||||
v-slot="{ open: o }"
|
||||
as="div"
|
||||
@click="updatePosition"
|
||||
@focusin="updatePosition"
|
||||
@keydown="updatePosition"
|
||||
>
|
||||
<slot name="target" :open="o" />
|
||||
</PopoverButton>
|
||||
<div v-show="open">
|
||||
<PopoverPanel
|
||||
v-slot="{ open: o, close }"
|
||||
ref="popover"
|
||||
static
|
||||
class="z-[100]"
|
||||
>
|
||||
<slot name="body" :open="o" :close="close" />
|
||||
</PopoverPanel>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, onBeforeUnmount } from "vue";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
|
||||
const props = defineProps({
|
||||
placement: {
|
||||
type: String,
|
||||
default: "bottom-start",
|
||||
},
|
||||
});
|
||||
|
||||
const reference = ref(null);
|
||||
const popover = ref(null);
|
||||
|
||||
let popper = ref(null);
|
||||
|
||||
function setupPopper() {
|
||||
if (!popper.value) {
|
||||
popper.value = createPopper(reference.value.el, popover.value.el, {
|
||||
placement: props.placement,
|
||||
});
|
||||
} else {
|
||||
popper.value.update();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
nextTick(() => setupPopper());
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
popper.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
@@ -19,7 +19,7 @@
|
||||
{{ avatar.label }}
|
||||
</p>
|
||||
<Tooltip v-if="badge" :text="badge.tooltip ?? ''">
|
||||
<Badge :label="badge.label" :theme="badge.theme ?? 'orange'" />
|
||||
<Badge :label="badge.label" :theme="badge.theme ?? 'amber'" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-if="!isMobileView" class="flex items-center gap-x-1.5">
|
||||
@@ -69,7 +69,7 @@ interface DocInfoItem {
|
||||
|
||||
interface BadgeInfo {
|
||||
label: string;
|
||||
theme?: "gray" | "blue" | "green" | "orange" | "red";
|
||||
theme?: "gray" | "blue" | "green" | "amber" | "red";
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<header class="border-b px-5 py-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<slot name="title">
|
||||
<div v-if="title" class="text-lg-medium text-ink-gray-9">
|
||||
<div v-if="title" class="text-md-medium text-ink-gray-9">
|
||||
{{ title }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<Tooltip>
|
||||
<template #body>
|
||||
<Tooltip bare>
|
||||
<template #content>
|
||||
<div
|
||||
class="rounded bg-surface-gray-10 py-1.5 px-2 text-xs text-ink-base shadow-xl"
|
||||
class="rounded-4 bg-surface-gray-10 py-1.5 px-2 text-xs text-ink-base shadow-xl"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{{ show ? "Hide Password" : "Show Password" }}
|
||||
|
||||
@@ -38,6 +38,6 @@ const handleClick = (label) => {
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
rootRef: computed(() => buttonRef.value?.rootRef),
|
||||
rootRef: computed(() => buttonRef.value?.$el),
|
||||
});
|
||||
</script>
|
||||
|
||||
35
desk/src/components/Questionnaire.types.ts
Normal file
35
desk/src/components/Questionnaire.types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// A dropdown rendered beneath a text question (e.g. org name + company size).
|
||||
export interface DropdownConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
export interface TextQuestion {
|
||||
key: string;
|
||||
title: string;
|
||||
type: "text";
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
required?: boolean;
|
||||
dropdown?: DropdownConfig;
|
||||
}
|
||||
|
||||
export interface ChoiceQuestion {
|
||||
key: string;
|
||||
title: string;
|
||||
type: "choice";
|
||||
options: Option[];
|
||||
multiple?: boolean;
|
||||
// Placeholder for the free-text box the "Other" option reveals.
|
||||
otherPlaceholder?: string;
|
||||
}
|
||||
|
||||
export type Question = TextQuestion | ChoiceQuestion;
|
||||
279
desk/src/components/Questionnaire.vue
Normal file
279
desk/src/components/Questionnaire.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="relative mx-auto w-full max-w-md flex flex-col gap-5">
|
||||
<HDLogo class="size-8" />
|
||||
|
||||
<!-- Fixed height keeps the panel from resizing as questions vary in length. -->
|
||||
<div class="relative">
|
||||
<Button
|
||||
v-if="current > 0"
|
||||
variant="ghost"
|
||||
icon="lucide-chevron-left"
|
||||
class="!size-7 shrink-0 !rounded-5 absolute -left-11 mt-4"
|
||||
@click="back"
|
||||
/>
|
||||
<div
|
||||
:key="question.key"
|
||||
class="mx-0 min-w-0 border-0 p-0 flex gap-5 flex-col"
|
||||
>
|
||||
<div>
|
||||
<p class="p-0 text-p-xl font-bold text-ink-gray-9">
|
||||
{{ question.title
|
||||
}}<span v-if="titleRequired" class="ms-0.5 text-ink-red-6">*</span>
|
||||
</p>
|
||||
|
||||
<p class="text-base pt-1.5">
|
||||
{{ __("This helps us personalize your Helpdesk experience.") }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="question.type === 'text'">
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Cap textarea resize: vertical only, between one row block and ~2x default. -->
|
||||
<div
|
||||
class="[&_textarea]:resize-y [&_textarea]:min-h-16 [&_textarea]:max-h-40"
|
||||
>
|
||||
<span
|
||||
v-if="question.label"
|
||||
class="mb-1.5 block text-p-sm text-ink-gray-5"
|
||||
>
|
||||
{{ question.label
|
||||
}}<span v-if="question.required" class="ms-0.5 text-ink-red-6"
|
||||
>*</span
|
||||
>
|
||||
</span>
|
||||
<Textarea
|
||||
v-if="question.multiline"
|
||||
v-model="answers[question.key]"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:rows="3"
|
||||
:placeholder="question.placeholder"
|
||||
/>
|
||||
<TextInput
|
||||
v-else
|
||||
v-model="answers[question.key]"
|
||||
size="sm"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:placeholder="question.placeholder"
|
||||
@keyup.enter="proceed"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="dropdown">
|
||||
<span class="mb-1.5 block text-p-sm text-ink-gray-5">
|
||||
{{ dropdown.label }}
|
||||
</span>
|
||||
<Select
|
||||
v-model="answers[dropdown.key]"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:placeholder="dropdown.placeholder"
|
||||
:options="dropdown.options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap gap-3 !text-ink-gray-7 text-p-base">
|
||||
<Button
|
||||
v-for="option in choiceOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:variant="isSelected(option) ? 'subtle' : 'outline'"
|
||||
size="sm"
|
||||
class="!rounded-5 border border-solid"
|
||||
:class="
|
||||
isSelected(option)
|
||||
? '!bg-surface-gray-3 hover:!bg-surface-gray-4 !border-outline-gray-3'
|
||||
: 'hover:!bg-surface-alpha-gray-2'
|
||||
"
|
||||
@click="select(option)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="-mt-5 grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
:class="
|
||||
isOtherSelected
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div
|
||||
class="transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] [&_textarea]:resize-y [&_textarea]:min-h-16 [&_textarea]:max-h-40"
|
||||
:class="isOtherSelected ? 'translate-y-0' : '-translate-y-full'"
|
||||
>
|
||||
<Textarea
|
||||
ref="otherInput"
|
||||
v-model="answers[otherKey]"
|
||||
class="mt-5"
|
||||
variant="outline"
|
||||
:rows="3"
|
||||
:placeholder="otherPlaceholder"
|
||||
:tabindex="isOtherSelected ? 0 : -1"
|
||||
:error="otherError"
|
||||
@update:model-value="otherError = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:label="isLast ? __('Finish') : __('Next')"
|
||||
:disabled="!canProceed"
|
||||
@click="proceed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="mt-auto !bg-transparent !text-ink-gray-5 hover:!text-ink-gray-8"
|
||||
variant="ghost"
|
||||
:label="__('Skip for now')"
|
||||
@click="finish"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HDLogo from "@/assets/logos/HDLogo.vue";
|
||||
import { __ } from "@/translation";
|
||||
import { Button, Select, Textarea, TextInput } from "frappe-ui";
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import type { Option, Question } from "./Questionnaire.types";
|
||||
|
||||
type Answers = Record<string, string | string[]>;
|
||||
|
||||
const props = defineProps<{ questions: Question[] }>();
|
||||
const emit = defineEmits<{ submit: [answers: Answers] }>();
|
||||
|
||||
const current = ref(0);
|
||||
const completed = ref(false);
|
||||
const answers = reactive<Answers>({});
|
||||
const otherInput = ref<InstanceType<typeof Textarea>>();
|
||||
|
||||
const total = computed(() => props.questions.length);
|
||||
const question = computed(() => props.questions[current.value] as Question);
|
||||
const isLast = computed(() => current.value === total.value - 1);
|
||||
|
||||
// Next stays disabled until the current question has an answer.
|
||||
const canProceed = computed(() => {
|
||||
const q = question.value;
|
||||
const value = answers[q.key];
|
||||
if (q.type === "text")
|
||||
return !q.required || (typeof value === "string" && value.trim() !== "");
|
||||
if (q.multiple) return Array.isArray(value) && value.length > 0;
|
||||
return typeof value === "string" && value !== "";
|
||||
});
|
||||
|
||||
// Narrow the discriminated union so the template stays declarative: choice
|
||||
// questions render pills, text questions may carry a dropdown beneath them.
|
||||
const choiceOptions = computed(() =>
|
||||
question.value.type === "choice" ? question.value.options : []
|
||||
);
|
||||
const dropdown = computed(() =>
|
||||
question.value.type === "text" ? question.value.dropdown : undefined
|
||||
);
|
||||
|
||||
// "Other" free-text is stored under a `<key>_other` sibling answer key.
|
||||
const OTHER_SUFFIX = "_other";
|
||||
const otherKey = computed(() => `${question.value.key}${OTHER_SUFFIX}`);
|
||||
const isOtherSelected = computed(() => {
|
||||
const otherOption = choiceOptions.value.find((o) => o.value === "other");
|
||||
return otherOption ? isSelected(otherOption) : false;
|
||||
});
|
||||
|
||||
const otherPlaceholder = computed(
|
||||
() =>
|
||||
(question.value.type === "choice" && question.value.otherPlaceholder) ||
|
||||
__("Tell us more")
|
||||
);
|
||||
|
||||
// The asterisk normally rides on the field label; questions whose title is
|
||||
// itself the prompt have no label to hang it on.
|
||||
const titleRequired = computed(
|
||||
() =>
|
||||
question.value.type === "text" &&
|
||||
!!question.value.required &&
|
||||
!question.value.label
|
||||
);
|
||||
|
||||
const isOtherFilled = computed(() => {
|
||||
const other = answers[otherKey.value];
|
||||
return typeof other === "string" && other.trim() !== "";
|
||||
});
|
||||
|
||||
// Cleared on navigation and on toggling "Other" off, so re-opening the box
|
||||
// never shows a stale error the user hasn't earned yet.
|
||||
const otherError = ref("");
|
||||
watch([current, isOtherSelected], () => (otherError.value = ""));
|
||||
|
||||
function isSelected(option: Option) {
|
||||
const q = question.value;
|
||||
if (q.type !== "choice") return false;
|
||||
const value = answers[q.key];
|
||||
if (q.multiple) {
|
||||
return Array.isArray(value) && value.includes(option.value);
|
||||
}
|
||||
return value === option.value;
|
||||
}
|
||||
|
||||
function select(option: Option) {
|
||||
if (completed.value) return;
|
||||
const q = question.value;
|
||||
if (q.type !== "choice") return;
|
||||
const key = q.key;
|
||||
if (q.multiple) {
|
||||
const value = Array.isArray(answers[key])
|
||||
? [...(answers[key] as string[])]
|
||||
: [];
|
||||
const index = value.indexOf(option.value);
|
||||
if (index === -1) {
|
||||
value.push(option.value);
|
||||
} else {
|
||||
value.splice(index, 1);
|
||||
}
|
||||
answers[key] = value;
|
||||
} else {
|
||||
// A click only sets the selection; advancing is always via the Next button.
|
||||
answers[key] = option.value;
|
||||
}
|
||||
|
||||
if (!isOtherSelected.value) {
|
||||
delete answers[otherKey.value];
|
||||
} else if (option.value === "other") {
|
||||
nextTick(() => otherInput.value?.focus({ preventScroll: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function advance() {
|
||||
if (current.value < total.value - 1) current.value += 1;
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (current.value > 0) current.value -= 1;
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
if (!canProceed.value) return;
|
||||
// "Other" stays selectable with Next enabled; the ask happens on click.
|
||||
if (isOtherSelected.value && !isOtherFilled.value) {
|
||||
otherError.value = __("Add a few words so we can continue");
|
||||
otherInput.value?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (isLast.value) finish();
|
||||
else advance();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (completed.value) return;
|
||||
completed.value = true;
|
||||
setTimeout(() => emit("submit", { ...answers }), 700);
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,10 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:open="show"
|
||||
size="4xl"
|
||||
bare
|
||||
@vue:unmounted="resetFilter"
|
||||
@after-leave="onAfterLeave"
|
||||
>
|
||||
<Dialog v-model:open="show" size="4xl" bare @after-leave="onAfterLeave">
|
||||
<template #default>
|
||||
<div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }">
|
||||
<div class="flex items-center justify-between w-full p-4 pb-2">
|
||||
<div class="text-2xl-semibold">{{ __("Saved Replies") }}</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
icon-left="lucide-plus"
|
||||
:label="__('New')"
|
||||
@click="onNewSavedReplyClick"
|
||||
@@ -42,20 +35,9 @@
|
||||
class="absolute end-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="filters" placement="right">
|
||||
<Button
|
||||
:label="activeFilter"
|
||||
icon-left="lucide-filter"
|
||||
class="p-4"
|
||||
>
|
||||
<template #suffix>
|
||||
<p
|
||||
class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-base pt-px text-xs-medium text-ink-gray-8 shadow-sm"
|
||||
v-if="savedReplyListResource?.data?.length"
|
||||
>
|
||||
{{ savedReplyListResource?.data?.length }}
|
||||
</p>
|
||||
</template>
|
||||
<Dropdown :options="filters" align="end">
|
||||
<Button :label="activeFilterLabel">
|
||||
<template #prefix><FilterIcon class="h-4" /></template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@@ -72,40 +54,67 @@
|
||||
!savedReplyListResource?.list?.loading &&
|
||||
savedReplyListResource?.data?.length
|
||||
"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-2 pb-36"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-3 pb-36"
|
||||
>
|
||||
<div
|
||||
v-for="template in savedReplyListResource?.data"
|
||||
:key="template.name"
|
||||
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-surface-gray-2 relative"
|
||||
class="flex h-56 cursor-pointer flex-col gap-2 rounded-6 border border-outline-gray-1 bg-surface-base p-3 hover:border-outline-gray-3 relative"
|
||||
@click="onTemplateSelect(template)"
|
||||
>
|
||||
<div class="text-base-semibold truncate border-b pb-2">
|
||||
{{ template.title }}
|
||||
</div>
|
||||
<Editor
|
||||
v-if="template.message"
|
||||
:model-value="template.message"
|
||||
:editable="false"
|
||||
:extensions="extensions"
|
||||
>
|
||||
<template #default="{ editor }">
|
||||
<EditorContent
|
||||
:editor="editor"
|
||||
class="flex-1 overflow-hidden pointer-events-none !prose-sm max-w-none !text-sm text-ink-gray-5 focus:outline-none"
|
||||
<div class="flex items-center gap-2 border-b pb-2">
|
||||
<div class="text-base-semibold truncate max-w-[75%]">
|
||||
{{ template.title }}
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="actionCounts[template.name]"
|
||||
:text="actionTooltip(actionCounts[template.name])"
|
||||
>
|
||||
<Badge
|
||||
class="ms-auto shrink-0"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
:label="actionLabel(actionCounts[template.name])"
|
||||
/>
|
||||
</template>
|
||||
</Editor>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-if="template.message"
|
||||
class="flex-1 overflow-hidden pointer-events-none [mask-image:linear-gradient(to_bottom,black_80%,transparent)]"
|
||||
>
|
||||
<Editor
|
||||
:model-value="template.message"
|
||||
:extensions="extensions"
|
||||
:editable="false"
|
||||
>
|
||||
<template #default>
|
||||
<EditorContent
|
||||
class="max-w-none text-p-sm text-ink-gray-5 focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
selectedTemplate.name === template.name &&
|
||||
selectedTemplate.isLoading
|
||||
"
|
||||
class="flex items-center justify-center absolute top-0 start-0 w-full h-full bg-surface-gray-10/20 rounded-lg"
|
||||
class="flex items-center justify-center absolute top-0 start-0 w-full h-full rounded-6"
|
||||
>
|
||||
<LoadingIndicator class="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full flex justify-center">
|
||||
<Button
|
||||
v-if="
|
||||
!savedReplyListResource.list.loading &&
|
||||
savedReplyListResource.hasNextPage
|
||||
"
|
||||
:label="__('Load More')"
|
||||
icon-left="lucide-refresh-cw"
|
||||
@click="savedReplyListResource.next()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
@@ -127,12 +136,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { recordSavedReplyUse } from "@/components/command-palette/savedReplyCommands";
|
||||
import { buildEditorExtensions } from "@/components/editor/config";
|
||||
import FilterIcon from "@/components/icons/FilterIcon.vue";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { capture } from "@/telemetry";
|
||||
import { __ } from "@/translation";
|
||||
import { SavedReply } from "@/types";
|
||||
import { RenderedSavedReply, SavedReply } from "@/types";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
@@ -140,14 +153,16 @@ import {
|
||||
Dropdown,
|
||||
LoadingIndicator,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "frappe-ui";
|
||||
import { Editor, EditorContent, RichTextKit } from "frappe-ui/editor";
|
||||
import { Editor, EditorContent } from "frappe-ui/editor";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { computed, h, nextTick, ref, watch } from "vue";
|
||||
import {
|
||||
setActiveSettingsTab,
|
||||
showSettingsModal,
|
||||
} from "./Settings/settingsModal";
|
||||
const extensions = buildEditorExtensions();
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@@ -160,42 +175,48 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = [RichTextKit];
|
||||
|
||||
const show = defineModel();
|
||||
const activeFilter = useStorage("saved-replies-filter", "Personal");
|
||||
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
|
||||
storeToRefs(useConfigStore());
|
||||
|
||||
const filters = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
label: __("All"),
|
||||
value: "All",
|
||||
onClick: () => (activeFilter.value = "All"),
|
||||
},
|
||||
{
|
||||
label: __("Personal"),
|
||||
value: "Personal",
|
||||
onClick: () => (activeFilter.value = "Personal"),
|
||||
},
|
||||
{
|
||||
label: __("My Team"),
|
||||
value: "Team",
|
||||
onClick: () => (activeFilter.value = "My Team"),
|
||||
},
|
||||
{
|
||||
label: __("Global"),
|
||||
value: "Global",
|
||||
onClick: () => (activeFilter.value = "Global"),
|
||||
},
|
||||
const scopes = [
|
||||
{ label: __("All"), value: "All" },
|
||||
{ label: __("Personal"), value: "Personal" },
|
||||
{ label: __("My Team(s)"), value: "Team" },
|
||||
{ label: __("Global"), value: "Global" },
|
||||
];
|
||||
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
|
||||
options.pop();
|
||||
scopes.pop();
|
||||
}
|
||||
return options;
|
||||
return scopes.map((scope) => ({
|
||||
...scope,
|
||||
selected: activeFilter.value === scope.value,
|
||||
onClick: () => (activeFilter.value = scope.value),
|
||||
slots: {
|
||||
suffix: () =>
|
||||
h(
|
||||
"span",
|
||||
{ class: "text-sm text-ink-gray-5" },
|
||||
String(scopeCounts.data?.[scope.value] ?? 0)
|
||||
),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const activeFilterLabel = computed(() => {
|
||||
return (
|
||||
filters.value.find((filter) => filter.value === activeFilter.value)
|
||||
?.label ?? activeFilter.value
|
||||
);
|
||||
});
|
||||
|
||||
// Older sessions stored the label instead of the value
|
||||
if (activeFilter.value == "My Team") {
|
||||
activeFilter.value = "Team";
|
||||
}
|
||||
|
||||
// Set default filter to Personal if Global is disabled
|
||||
if (
|
||||
teamRestrictionApplied.value &&
|
||||
@@ -213,7 +234,7 @@ const selectedTemplate = ref({
|
||||
name: "",
|
||||
isLoading: false,
|
||||
});
|
||||
const pendingTemplate = ref<string | null>(null);
|
||||
const pendingTemplate = ref<RenderedSavedReply | null>(null);
|
||||
|
||||
function onAfterLeave() {
|
||||
if (pendingTemplate.value !== null) {
|
||||
@@ -228,7 +249,7 @@ const scope = computed(() => {
|
||||
|
||||
const savedReplyListResource = createListResource({
|
||||
doctype: "HD Saved Reply",
|
||||
fields: ["name", "title", "owner", "scope", "message"],
|
||||
fields: ["name", "title", "owner", "scope", "message", "actions"],
|
||||
filters: {
|
||||
scope: scope.value == "All" ? undefined : ["=", scope.value],
|
||||
},
|
||||
@@ -236,9 +257,46 @@ const savedReplyListResource = createListResource({
|
||||
auto: true,
|
||||
orderBy: "modified desc",
|
||||
start: 0,
|
||||
pageLength: 999,
|
||||
pageLength: 20,
|
||||
});
|
||||
|
||||
// One grouped query answers every scope at once, so the menu can say where the
|
||||
// replies live before an agent switches to find out
|
||||
const scopeCounts = createResource({
|
||||
url: "frappe.client.get_list",
|
||||
params: {
|
||||
doctype: "HD Saved Reply",
|
||||
fields: ["scope", { COUNT: "name" }],
|
||||
group_by: "scope",
|
||||
limit_page_length: 0,
|
||||
},
|
||||
transform: (rows: { scope: string }[]) => {
|
||||
const counts: Record<string, number> = { All: 0 };
|
||||
for (const row of rows) {
|
||||
const count = row["COUNT(`name`)"] ?? 0;
|
||||
counts[row.scope] = count;
|
||||
counts.All += count;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
});
|
||||
|
||||
const actionCounts = computed<Record<string, number>>(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of savedReplyListResource.data || []) {
|
||||
counts[row.name] = JSON.parse(row.actions || "[]").length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
const actionLabel = (count: number) =>
|
||||
count === 1 ? __("1 action") : __("{0} actions", count);
|
||||
|
||||
const actionTooltip = (count: number) =>
|
||||
count === 1
|
||||
? __("Applies 1 action when sent")
|
||||
: __("Applies {0} actions when sent", count);
|
||||
|
||||
const onTemplateSelect = (template: SavedReply) => {
|
||||
if (selectedTemplate.value.isLoading) return;
|
||||
selectedTemplate.value = {
|
||||
@@ -251,7 +309,7 @@ const onTemplateSelect = (template: SavedReply) => {
|
||||
saved_reply_id: template.name,
|
||||
ticket_id: props.ticketId,
|
||||
},
|
||||
onSuccess: (data: string) => {
|
||||
onSuccess: (data: RenderedSavedReply) => {
|
||||
selectedTemplate.value = {
|
||||
name: "",
|
||||
isLoading: false,
|
||||
@@ -260,7 +318,9 @@ const onTemplateSelect = (template: SavedReply) => {
|
||||
if (!show.value) return;
|
||||
pendingTemplate.value = data;
|
||||
show.value = false;
|
||||
capture("saved_reply_applied");
|
||||
// Shared with the palette: modal picks count toward its most-used rows.
|
||||
recordSavedReplyUse(template.name, template.title);
|
||||
capture("saved_reply_applied", { data: { source: "composer" } });
|
||||
},
|
||||
});
|
||||
renderResponse.submit().catch(() => {
|
||||
@@ -277,18 +337,12 @@ const onNewSavedReplyClick = () => {
|
||||
setActiveSettingsTab("Saved Replies");
|
||||
};
|
||||
|
||||
const resetFilter = () => {
|
||||
savedReplyListResource.filters = {
|
||||
...savedReplyListResource.filters,
|
||||
title: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
watch(search, (newValue) => {
|
||||
savedReplyListResource.filters = {
|
||||
...savedReplyListResource.filters,
|
||||
title: ["like", `%${newValue}%`],
|
||||
};
|
||||
// A reply is as findable by its wording as by its name, now that only a page loads
|
||||
watch(search, (query) => {
|
||||
savedReplyListResource.orFilters = query
|
||||
? { title: ["like", `%${query}%`], message: ["like", `%${query}%`] }
|
||||
: undefined;
|
||||
savedReplyListResource.start = 0;
|
||||
savedReplyListResource.list.reload();
|
||||
});
|
||||
|
||||
@@ -297,17 +351,22 @@ watch(activeFilter, () => {
|
||||
...savedReplyListResource?.filters,
|
||||
scope: scope.value == "All" ? undefined : ["=", scope.value],
|
||||
};
|
||||
savedReplyListResource.start = 0;
|
||||
savedReplyListResource.list.reload();
|
||||
});
|
||||
|
||||
watch(
|
||||
show,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.el?.focus();
|
||||
});
|
||||
if (!newValue) return;
|
||||
if (search.value) {
|
||||
search.value = "";
|
||||
} else {
|
||||
savedReplyListResource.list.reload();
|
||||
}
|
||||
// Counts don't move with the selected scope, so this is the only refetch
|
||||
scopeCounts.reload();
|
||||
nextTick(() => searchInput.value?.focus());
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
132
desk/src/components/SavedReplyActions/SavedReplyActionChip.vue
Normal file
132
desk/src/components/SavedReplyActions/SavedReplyActionChip.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<component
|
||||
:is="config.doctype ? Link : Combobox"
|
||||
v-bind="controlProps"
|
||||
:disabled="!isEditable"
|
||||
:model-value="action.value"
|
||||
:placeholder="__('Select {0}', label.toLowerCase())"
|
||||
@update:model-value="pick(($event ?? '') as string)"
|
||||
>
|
||||
<template #trigger="{ setOpen }">
|
||||
<!-- The anchor forwards its click to this element, so it has to be a plain
|
||||
one: Tooltip and Combobox both drop inherited attrs -->
|
||||
<div
|
||||
class="inline-flex"
|
||||
:role="isEditable ? 'button' : undefined"
|
||||
:tabindex="isEditable ? 0 : undefined"
|
||||
@keydown.enter.self.prevent="setOpen(true)"
|
||||
>
|
||||
<Tooltip :text="value ? `${label}: ${value}` : label">
|
||||
<Badge
|
||||
class="bg-surface-base transition-colors"
|
||||
:class="isEditable ? 'cursor-pointer hover:bg-surface-gray-2' : ''"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
<template #prefix>
|
||||
<component
|
||||
:is="config.icon"
|
||||
class="size-3 shrink-0 text-ink-gray-6"
|
||||
/>
|
||||
</template>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span v-if="value" class="shrink-0 text-ink-gray-6">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span class="max-w-40 truncate text-ink-gray-8">
|
||||
{{ value || label }}
|
||||
</span>
|
||||
</span>
|
||||
<template #suffix>
|
||||
<!-- Removing is not picking: the click stops here -->
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="__('Remove {0}', label)"
|
||||
class="flex items-center rounded-1 text-ink-gray-6 transition-colors hover:text-ink-gray-9 focus-visible:ring-2 focus-visible:ring-outline-gray-3 mt-[1px]"
|
||||
@click.stop="emit('remove')"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<LucideX class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ACTION_TYPES } from "@/components/Settings/SavedReplies/components/actionTypes";
|
||||
import { useSavedReplyActionOptions } from "@/composables/useSavedReplyActionOptions";
|
||||
import { Link } from "@framework/ui";
|
||||
import { __ } from "@/translation";
|
||||
import { SavedReplyAction } from "@/types";
|
||||
import { Badge, Combobox, Tooltip } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
|
||||
const props = defineProps<{
|
||||
action: SavedReplyAction;
|
||||
/** Values the other chips hold, so a repoint can't collide with them. */
|
||||
taken?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [action: SavedReplyAction];
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
const { valueOptions, linkFilters } = useSavedReplyActionOptions();
|
||||
|
||||
const config = computed(() => ACTION_TYPES[props.action.action_type]);
|
||||
const label = computed(() => config.value.chipLabel);
|
||||
// Empty for no-value actions like "Assign to Me"; the noun then stands alone
|
||||
const value = computed(() => props.action.label || props.action.value);
|
||||
|
||||
// A tag chip holds one tag, so it picks like the rest. Excluded rather than
|
||||
// listed, so a new control type is editable by default instead of silently
|
||||
// read-only: no-value actions have nothing to pick, and a comment is too long
|
||||
// for a chip.
|
||||
const isEditable = computed(
|
||||
() => !["none", "editor"].includes(config.value.control)
|
||||
);
|
||||
|
||||
// Combobox takes string values; the shared options allow numbers too
|
||||
const pickerOptions = computed(() =>
|
||||
valueOptions(props.action.action_type)
|
||||
.filter((option) => !props.taken?.includes(String(option.value)))
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: String(option.value),
|
||||
}))
|
||||
);
|
||||
|
||||
// A #trigger slot puts Combobox in button mode, and Link forwards that slot,
|
||||
// so the badge below serves both controls.
|
||||
const controlProps = computed(() =>
|
||||
config.value.doctype
|
||||
? {
|
||||
doctype: config.value.doctype,
|
||||
filters: linkFilters(props.action.action_type),
|
||||
title: value.value,
|
||||
}
|
||||
: { options: pickerOptions.value }
|
||||
);
|
||||
|
||||
/** `label` carries the display name (an agent's, not their id), so both move. */
|
||||
function pick(picked: string) {
|
||||
// A link picker offers no option list to read a label from; for these
|
||||
// doctypes the record name is the label.
|
||||
if (config.value.doctype) {
|
||||
emit("update", { ...props.action, value: picked, label: picked });
|
||||
return;
|
||||
}
|
||||
const option = pickerOptions.value.find(
|
||||
(candidate) => candidate.value === picked
|
||||
);
|
||||
if (!option) return;
|
||||
emit("update", { ...props.action, value: option.value, label: option.label });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<!-- Full-bleed white section, so the block reads as one box with a gray top
|
||||
and a white bottom rather than a card floating inside another card -->
|
||||
<div class="border-t border-outline-gray-1 bg-surface-base">
|
||||
<div class="flex h-8 items-center gap-1.5 ps-3.5 pe-1.5 text-sm">
|
||||
<LucideMessageSquare class="size-3 shrink-0 text-ink-gray-6" />
|
||||
<span class="shrink-0 text-ink-gray-6">{{ __("Comment") }}</span>
|
||||
<Button
|
||||
class="ms-auto shrink-0"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="lucide-x"
|
||||
:label="__('Remove comment')"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
<Editor
|
||||
:model-value="action.value"
|
||||
:extensions="extensions"
|
||||
:placeholder="__('Comment')"
|
||||
@update:model-value="emit('update:value', $event ?? '')"
|
||||
>
|
||||
<template #default>
|
||||
<EditorContent
|
||||
class="prose prose-v3 [--prose-font-size:13px] max-w-none max-h-[140px] min-h-16 overflow-y-auto px-3.5 py-2"
|
||||
/>
|
||||
</template>
|
||||
</Editor>
|
||||
<!-- Fixed height so the reset button appearing doesn't grow the footer -->
|
||||
<div
|
||||
class="flex h-9 items-center gap-2 ps-3.5 pe-1.5 pb-2 text-xs text-ink-gray-5"
|
||||
>
|
||||
<span class="cursor-default">
|
||||
{{ __("Visible to your team only, never sent to the customer.") }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="isEdited"
|
||||
class="ms-auto"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:label="__('Reset to default')"
|
||||
@click="emit('update:value', action.original_value ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { buildEditorExtensions } from "@/components/editor/config";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { __ } from "@/translation";
|
||||
import { SavedReplyAction } from "@/types";
|
||||
import { Button } from "frappe-ui";
|
||||
import { Editor, EditorContent } from "frappe-ui/editor";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import LucideMessageSquare from "~icons/lucide/message-square";
|
||||
|
||||
const props = defineProps<{
|
||||
action: SavedReplyAction;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:value": [value: string];
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
/** Only actions staged with the original text can report an edit. */
|
||||
const isEdited = computed(
|
||||
() =>
|
||||
props.action.original_value !== undefined &&
|
||||
props.action.original_value !== props.action.value
|
||||
);
|
||||
|
||||
// Same editor the Comment tab uses: rich text with @-mentions
|
||||
const { dropdown: agentDropdown } = storeToRefs(useAgentStore());
|
||||
const extensions = buildEditorExtensions({
|
||||
mentions: () => agentDropdown.value ?? [],
|
||||
});
|
||||
</script>
|
||||
264
desk/src/components/SavedReplyActions/SavedReplyActions.vue
Normal file
264
desk/src/components/SavedReplyActions/SavedReplyActions.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="pendingActions.length"
|
||||
ref="container"
|
||||
class="overflow-hidden rounded-6 border border-outline-gray-1 bg-surface-gray-1"
|
||||
>
|
||||
<div class="flex h-8 items-center gap-1.5 ps-3.5 pe-1.5">
|
||||
<LucideInfo class="size-3 shrink-0 text-ink-gray-6" />
|
||||
<template v-if="sourceLabel">
|
||||
<Tooltip :text="sourceLabel">
|
||||
<span class="min-w-0 truncate text-sm-medium text-ink-gray-7">
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span class="shrink-0 text-ink-gray-3">·</span>
|
||||
</template>
|
||||
<span class="shrink-0 whitespace-nowrap text-sm text-ink-gray-5">
|
||||
{{ countSentence }}
|
||||
</span>
|
||||
<div class="ms-auto flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:aria-expanded="expanded"
|
||||
:icon="expanded ? 'lucide-chevron-down' : 'lucide-chevron-right'"
|
||||
:label="expanded ? __('Hide actions') : __('Show actions')"
|
||||
@click="toggleExpanded"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="lucide-x"
|
||||
:label="__('Clear all actions')"
|
||||
@click="clear"
|
||||
:tooltip="__('Clear all actions')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expanded">
|
||||
<div
|
||||
v-if="chipActions.length"
|
||||
class="flex flex-wrap items-center gap-1.5 px-1.5 pb-2"
|
||||
>
|
||||
<SavedReplyActionChip
|
||||
v-for="action in visibleChips"
|
||||
:key="actionKey(action)"
|
||||
:action="action"
|
||||
:taken="takenTags(action)"
|
||||
@update="updateAction(action, $event)"
|
||||
@remove="removeAction(action)"
|
||||
/>
|
||||
<button v-if="overflowCount" type="button" @click="showAllChips = true">
|
||||
<Badge theme="gray" variant="outline" size="lg">
|
||||
{{ __("+{0} more", overflowCount) }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
<SavedReplyActionComment
|
||||
v-if="noteAction"
|
||||
:action="noteAction"
|
||||
@update:value="setNoteValue"
|
||||
@remove="removeAction(noteAction)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isTagAction } from "@/components/Settings/SavedReplies/components/actionTypes";
|
||||
import { reloadTicket, reloadTicketFeed } from "@/composables/useTicket";
|
||||
import { userStorage } from "@/composables/userStorage";
|
||||
import { __ } from "@/translation";
|
||||
import { RenderedSavedReply, SavedReplyAction } from "@/types";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import { Badge, Button, Tooltip, createResource, toast } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import LucideInfo from "~icons/lucide/info";
|
||||
import SavedReplyActionChip from "./SavedReplyActionChip.vue";
|
||||
import SavedReplyActionComment from "./SavedReplyActionComment.vue";
|
||||
|
||||
/** Chips beyond this stay hidden until the agent asks for the rest. */
|
||||
const MAX_VISIBLE_CHIPS = 4;
|
||||
/** Failures hold long enough to read: one asks for a decision, the other for work. */
|
||||
const FAILURE_TOAST_DURATION = 15000;
|
||||
/** Below this the long sentence squeezes the reply name to nothing. */
|
||||
const NARROW_WIDTH = 470;
|
||||
|
||||
const props = defineProps<{
|
||||
ticketId: string;
|
||||
doctype: string;
|
||||
}>();
|
||||
|
||||
// Persisted like the draft body, so a refresh keeps text and actions in sync.
|
||||
// Scoped to the agent: a staged comment must not survive a shift handover.
|
||||
const pendingActions = userStorage<SavedReplyAction[]>(
|
||||
"pendingSavedReplyActions" + props.ticketId,
|
||||
[]
|
||||
);
|
||||
const expanded = userStorage(
|
||||
"pendingSavedReplyActionsExpanded" + props.ticketId,
|
||||
false
|
||||
);
|
||||
|
||||
const showAllChips = ref(false);
|
||||
// Not reactive: it only gates the next failure's retry
|
||||
let isRetry = false;
|
||||
const container = ref<HTMLElement>();
|
||||
const { width } = useElementSize(container);
|
||||
|
||||
const noteAction = computed(() =>
|
||||
pendingActions.value.find((action) => action.action_type === "Add Comment")
|
||||
);
|
||||
|
||||
const chipActions = computed(() =>
|
||||
pendingActions.value.filter((action) => action.action_type !== "Add Comment")
|
||||
);
|
||||
|
||||
const visibleChips = computed(() =>
|
||||
showAllChips.value || chipActions.value.length <= MAX_VISIBLE_CHIPS + 1
|
||||
? chipActions.value
|
||||
: chipActions.value.slice(0, MAX_VISIBLE_CHIPS)
|
||||
);
|
||||
|
||||
const overflowCount = computed(
|
||||
() => chipActions.value.length - visibleChips.value.length
|
||||
);
|
||||
|
||||
/** Collapsing puts the chip list back to its capped state. */
|
||||
function toggleExpanded() {
|
||||
expanded.value = !expanded.value;
|
||||
if (!expanded.value) showAllChips.value = false;
|
||||
}
|
||||
|
||||
// Only one reply is ever staged, so its title is the whole label
|
||||
const sourceLabel = computed(() => pendingActions.value[0]?.source ?? "");
|
||||
|
||||
const countSentence = computed(() => {
|
||||
const count = pendingActions.value.length;
|
||||
if (width.value && width.value < NARROW_WIDTH) {
|
||||
return __("{0} actions after sending", count);
|
||||
}
|
||||
return count === 1
|
||||
? __("1 action will be applied after sending the email")
|
||||
: __("{0} actions will be applied after sending the email", count);
|
||||
});
|
||||
|
||||
/** Stage the actions of an applied saved reply, replacing whatever was staged. */
|
||||
function add(reply: RenderedSavedReply) {
|
||||
pendingActions.value = (reply.actions ?? []).map((action) => ({
|
||||
...action,
|
||||
source: reply.title,
|
||||
// Kept so the staged comment can report edits and restore
|
||||
...(action.action_type === "Add Comment"
|
||||
? { original_value: action.value }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** What is staged right now, for the replace confirmation. Null when nothing is. */
|
||||
function stagedSummary(): { count: number; source: string } | null {
|
||||
if (!pendingActions.value.length) return null;
|
||||
return { count: pendingActions.value.length, source: sourceLabel.value };
|
||||
}
|
||||
|
||||
// Tags share an action type, so they key on their value too
|
||||
function actionKey(action: SavedReplyAction): string {
|
||||
return isTagAction(action.action_type)
|
||||
? `${action.action_type}:${action.value}`
|
||||
: action.action_type;
|
||||
}
|
||||
|
||||
// Dont re-use duplicate tags
|
||||
function takenTags(action: SavedReplyAction): string[] {
|
||||
if (!isTagAction(action.action_type)) return [];
|
||||
return pendingActions.value
|
||||
.filter((other) => other !== action && isTagAction(other.action_type))
|
||||
.map((other) => other.value);
|
||||
}
|
||||
|
||||
/** Repoint an action, e.g. an escalation routed to a different team. */
|
||||
function updateAction(action: SavedReplyAction, updated: SavedReplyAction) {
|
||||
const index = pendingActions.value.indexOf(action);
|
||||
if (index !== -1) pendingActions.value[index] = updated;
|
||||
}
|
||||
|
||||
function removeAction(action: SavedReplyAction) {
|
||||
pendingActions.value = pendingActions.value.filter(
|
||||
(pending) => actionKey(pending) !== actionKey(action)
|
||||
);
|
||||
}
|
||||
|
||||
// Whatever is in the editor at send time is what gets applied
|
||||
function setNoteValue(value: string) {
|
||||
if (noteAction.value) noteAction.value.value = value;
|
||||
}
|
||||
|
||||
function reloadFeed() {
|
||||
reloadTicket(props.ticketId);
|
||||
reloadTicketFeed(props.ticketId);
|
||||
}
|
||||
|
||||
const applyActions = createResource({
|
||||
url: "helpdesk.api.saved_replies.apply_saved_reply_actions",
|
||||
onSuccess: (result: {
|
||||
applied: SavedReplyAction[];
|
||||
skipped: SavedReplyAction[];
|
||||
}) => {
|
||||
if (result.applied?.length) {
|
||||
toast.success(
|
||||
__("Applied {0} saved reply action(s)", result.applied.length)
|
||||
);
|
||||
}
|
||||
if (result.skipped?.length) {
|
||||
toast.warning(
|
||||
__(
|
||||
"Skipped {0} saved reply action(s) that are no longer valid",
|
||||
result.skipped.length
|
||||
)
|
||||
);
|
||||
}
|
||||
pendingActions.value = [];
|
||||
reloadFeed();
|
||||
},
|
||||
onError: (error: { status?: number }) => {
|
||||
const failed = [...pendingActions.value];
|
||||
// Only a response proves it rolled back, and one retry is enough
|
||||
const canRetry = Boolean(error?.status) && !isRetry;
|
||||
// Unstaged always: a kept batch would ride the next, unrelated send
|
||||
pendingActions.value = [];
|
||||
toast.error(__("Could not apply actions from the saved reply."), {
|
||||
duration: FAILURE_TOAST_DURATION,
|
||||
...(canRetry
|
||||
? { action: { label: __("Retry"), onClick: () => retry(failed) } }
|
||||
: { description: __("Please apply the actions manually") }),
|
||||
});
|
||||
reloadFeed();
|
||||
},
|
||||
});
|
||||
|
||||
/** Re-send a batch the agent kept from the failure toast. */
|
||||
function retry(actions: SavedReplyAction[]) {
|
||||
isRetry = true;
|
||||
applyActions.submit({ ticket_id: props.ticketId, actions });
|
||||
}
|
||||
|
||||
/** Apply the staged actions to the ticket; call after the email is sent. */
|
||||
function submit() {
|
||||
if (!pendingActions.value.length || props.doctype !== "HD Ticket") return;
|
||||
// Actions stay staged during the request, so guard the second call
|
||||
if (applyActions.loading) return;
|
||||
isRetry = false;
|
||||
applyActions.submit({
|
||||
ticket_id: props.ticketId,
|
||||
actions: [...pendingActions.value],
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop the staged actions without applying them. */
|
||||
function clear() {
|
||||
pendingActions.value = [];
|
||||
}
|
||||
|
||||
defineExpose({ add, stagedSummary, submit, clear });
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="Boolean(articles.data?.length) && query.length > 2"
|
||||
class="rounded border p-4 text-base"
|
||||
class="rounded-4 border p-4 text-base"
|
||||
>
|
||||
<div class="mb-2 font-medium ps-2" v-if="!hideViewAll">
|
||||
These articles may already cover what you are looking for
|
||||
@@ -22,7 +22,7 @@
|
||||
<div
|
||||
v-for="a in articles.data"
|
||||
:key="a.id"
|
||||
class="rounded-md border-2 p-2 border-hidden hover:bg-surface-gray-2"
|
||||
class="rounded-5 border-2 p-2 border-hidden hover:bg-surface-gray-2"
|
||||
>
|
||||
<RouterLink
|
||||
class="group cursor-pointer hover:text-ink-gray-9 flex flex-col gap-1"
|
||||
@@ -50,7 +50,7 @@
|
||||
v-else-if="
|
||||
!articles.loading && articles.data?.length === 0 && query.length > 2
|
||||
"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded-4 border"
|
||||
>
|
||||
<LucideSearch class="size-8 text-ink-gray-3" />
|
||||
<div class="flex items-center flex-col justify-center">
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="articles.loading"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded-4 border"
|
||||
>
|
||||
<LucideSearch class="size-8 text-ink-gray-3" />
|
||||
<div class="flex items-center flex-col justify-center">
|
||||
@@ -99,7 +99,9 @@ watch(
|
||||
},
|
||||
});
|
||||
articles.reload();
|
||||
}
|
||||
},
|
||||
// Mounted after the third character is typed.
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function handleSearchArticleClick(article) {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<Autocomplete
|
||||
placeholder="Select an option"
|
||||
:options="options"
|
||||
:value="selection"
|
||||
@update:query="(q) => onUpdateQuery(q)"
|
||||
@change="
|
||||
(v) => {
|
||||
if (!resetInput) {
|
||||
selection = v;
|
||||
}
|
||||
emit('change', v);
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Autocomplete } from "@/components";
|
||||
import { createListResource } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
searchField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "name",
|
||||
},
|
||||
labelField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "name",
|
||||
},
|
||||
valueField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "name",
|
||||
},
|
||||
pageLength: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 100,
|
||||
},
|
||||
resetInput: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
customFilters: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: {},
|
||||
},
|
||||
});
|
||||
|
||||
const r = createListResource({
|
||||
doctype: props.doctype,
|
||||
pageLength: props.pageLength,
|
||||
auto: true,
|
||||
fields: [props.labelField, props.searchField, props.valueField],
|
||||
filters: {
|
||||
[props.searchField]: ["like", `%${props.value}%`],
|
||||
...props.customFilters,
|
||||
},
|
||||
onSuccess: () => {
|
||||
selection.value = props.value
|
||||
? options.value.find((o) => o.value === props.value)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const options = computed(
|
||||
() =>
|
||||
r.data?.map((result) => ({
|
||||
label: result[props.labelField],
|
||||
value: result[props.valueField],
|
||||
})) || []
|
||||
);
|
||||
const selection = ref(null);
|
||||
|
||||
function onUpdateQuery(query: string) {
|
||||
r.update({
|
||||
filters: {
|
||||
[props.searchField]: ["like", `%${query}%`],
|
||||
...props.customFilters,
|
||||
},
|
||||
});
|
||||
|
||||
r.reload();
|
||||
}
|
||||
</script>
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-50 mt-2 w-64 divide-y divide-outline-elevation-2 rounded-lg bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none start-0 origin-top-left"
|
||||
class="absolute z-50 mt-2 w-64 divide-y divide-outline-elevation-2 rounded-6 bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none start-0 origin-top-left"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="py-1.5 px-1.5">
|
||||
@@ -85,13 +85,13 @@
|
||||
<button
|
||||
v-for="option in filteredIndividualOptions"
|
||||
:key="`individual-${option.value}`"
|
||||
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
class="group flex h-7 w-full items-center rounded-4 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<Checkbox
|
||||
:modelValue="props.modelValue.includes(option.value)"
|
||||
class="me-2 flex-shrink-0"
|
||||
class="me-4 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<component v-if="option.icon" :is="renderIcon(option.icon)" />
|
||||
@@ -130,13 +130,13 @@
|
||||
<button
|
||||
v-for="option in group.items"
|
||||
:key="`grouped-${option.value}`"
|
||||
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
class="group flex h-7 w-full items-center rounded-4 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<Checkbox
|
||||
:modelValue="props.modelValue.includes(option.value)"
|
||||
class="me-2 flex-shrink-0"
|
||||
class="me-4 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<component v-if="option.icon" :is="renderIcon(option.icon)" />
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
<template>
|
||||
<Popover
|
||||
:popover-class="popoverClass.length > 0 ? popoverClass : ''"
|
||||
class="flex w-full"
|
||||
>
|
||||
<template #target="{ open, close }">
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<slot name="label"></slot>
|
||||
<FormControl
|
||||
type="text"
|
||||
class="w-full focus:outline-none outline-none border-inherit shadow-none"
|
||||
v-bind="$attrs"
|
||||
v-model="query"
|
||||
@update:model-value="
|
||||
(e: string) => {
|
||||
if (e.length >= 3) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
<Popover>
|
||||
<template #trigger="{ close, setOpen }">
|
||||
<!--
|
||||
Typing opens this, not clicking. reka turns the trigger's root into the
|
||||
toggle, so the field stops its own click before it reaches that root.
|
||||
-->
|
||||
<div>
|
||||
<div class="flex flex-col gap-1 w-full" @click.stop>
|
||||
<slot name="label"></slot>
|
||||
<FormControl
|
||||
type="text"
|
||||
class="w-full focus:outline-none outline-none border-inherit shadow-none"
|
||||
v-bind="$attrs"
|
||||
v-model="query"
|
||||
@update:model-value="
|
||||
(e: string) => {
|
||||
if (e.length >= 3) {
|
||||
setOpen(true);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="size-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</FormControl>
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="size-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<template #default>
|
||||
<!-- Searched Articles -->
|
||||
<div class="max-h-[320px] md:max-h-[420px] overflow-scroll flex flex-col">
|
||||
<div
|
||||
class="max-h-[320px] md:max-h-[420px] overflow-scroll flex flex-col"
|
||||
:class="popoverClass"
|
||||
>
|
||||
<SearchArticles
|
||||
:query="query"
|
||||
:hideViewAll="true"
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- px-4 lives once here so the header and content share a single horizontal
|
||||
padding instead of each declaring their own. -->
|
||||
<div class="px-4">
|
||||
<slot name="header" v-bind="{ opened, hide, open, close, toggle }">
|
||||
<div
|
||||
v-if="!hide"
|
||||
class="section-header flex items-center justify-between"
|
||||
class="section-header sticky top-0 z-10 flex cursor-pointer select-none items-center gap-1 bg-surface-base py-3.5"
|
||||
:class="headerClass"
|
||||
@click="collapsible && toggle()"
|
||||
>
|
||||
<div
|
||||
class="flex text-ink-gray-9 max-w-fit cursor-pointer items-center gap-2 text-base"
|
||||
:class="labelClass"
|
||||
@click="collapsible && toggle()"
|
||||
>
|
||||
<!-- Figma: text/base/medium, ink-gray-8 -->
|
||||
<Tooltip v-if="tooltip" :text="__(tooltip)">
|
||||
<span
|
||||
v-if="collapsible && collapseIconPosition === 'left'"
|
||||
class="lucide-chevron-right h-4 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
<span>
|
||||
{{ label || "Untitled" }}
|
||||
class="text-base font-medium text-ink-gray-8"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ __(label) || "Untitled" }}
|
||||
</span>
|
||||
<span
|
||||
v-if="collapsible && collapseIconPosition === 'right'"
|
||||
class="lucide-chevron-right h-4 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<span
|
||||
v-else
|
||||
class="text-base font-medium text-ink-gray-8"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ __(label) || "Untitled" }}
|
||||
</span>
|
||||
<LucideChevronRight
|
||||
v-if="collapsible"
|
||||
class="size-3.5 text-ink-gray-5 transition-transform"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</slot>
|
||||
<transition
|
||||
enter-active-class="duration-300 ease-in"
|
||||
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
|
||||
enter-active-class="transition-all duration-300 ease-in"
|
||||
leave-active-class="transition-all duration-300 ease-[cubic-bezier(0,1,0.5,1)]"
|
||||
enter-to-class="max-h-[2000px] overflow-hidden"
|
||||
leave-from-class="max-h-[2000px] overflow-hidden"
|
||||
enter-from-class="max-h-0 opacity-0"
|
||||
@@ -43,13 +48,20 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { __ } from "@/translation";
|
||||
import { Tooltip } from "frappe-ui";
|
||||
import { ref, watch } from "vue";
|
||||
import LucideChevronRight from "~icons/lucide/chevron-right";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
hideLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -62,10 +74,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
collapseIconPosition: {
|
||||
type: String,
|
||||
default: "left",
|
||||
},
|
||||
labelClass: {
|
||||
type: [String, Object, Array],
|
||||
default: "",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<template>
|
||||
<Popover :placement="props.placement">
|
||||
<template #target="{ togglePopover }">
|
||||
<Popover
|
||||
bare
|
||||
:side="splitPlacement(props.placement).side"
|
||||
:align="splitPlacement(props.placement).align"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button
|
||||
class="flex items-center justify-between min-w-36"
|
||||
@click="togglePopover()"
|
||||
:class="targetClass"
|
||||
icon-right="lucide-chevron-down"
|
||||
>
|
||||
@@ -14,30 +17,27 @@
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<template #default="{ close: closePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-6 top-1 absolute w-[--reka-popper-anchor-width] bg-surface-base shadow-2xl rounded"
|
||||
class="mt-1 p-1 text-ink-gray-6 w-[--reka-popper-anchor-width] bg-surface-base shadow-2xl rounded-4"
|
||||
:class="bodyClass"
|
||||
>
|
||||
<div class="max-h-52 overflow-y-auto">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-base flex items-center justify-between rounded"
|
||||
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-base flex items-center justify-between rounded-4"
|
||||
@click="
|
||||
() => {
|
||||
onChange(option.value);
|
||||
togglePopover();
|
||||
closePopover();
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="w-full truncate">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<span
|
||||
v-if="model == option.value"
|
||||
class="lucide-check size-4 ms-2"
|
||||
/>
|
||||
<LucideCheck v-if="model == option.value" class="size-4 ms-2" />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-1" />
|
||||
@@ -46,7 +46,7 @@
|
||||
:label="__('Reset')"
|
||||
icon-left="lucide-refresh-ccw"
|
||||
class="w-full focus-visible:ring-0"
|
||||
@click="onReset(togglePopover)"
|
||||
@click="onReset(closePopover)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -54,6 +54,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { splitPlacement } from "@/utils";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import { Button, Popover } from "frappe-ui";
|
||||
|
||||
const model = defineModel();
|
||||
@@ -71,9 +73,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
|
||||
const onReset = (togglePopover: () => void) => {
|
||||
const onReset = (closePopover: () => void) => {
|
||||
model.value = props.defaultValue !== undefined ? props.defaultValue : null;
|
||||
togglePopover();
|
||||
closePopover();
|
||||
};
|
||||
|
||||
const onChange = (value: string) => {
|
||||
|
||||
@@ -38,36 +38,29 @@
|
||||
class="absolute end-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="dropdownOptions" placement="right">
|
||||
<Dropdown :options="dropdownOptions" align="end">
|
||||
<template #default="{ open }">
|
||||
<Button
|
||||
:label="activeFilter"
|
||||
class="flex items-center justify-between w-fit p-4"
|
||||
>
|
||||
<Button :label="activeFilter">
|
||||
<template #suffix>
|
||||
<span
|
||||
:class="[
|
||||
'h-4',
|
||||
open ? 'lucide-chevron-up' : 'lucide-chevron-down',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
<component
|
||||
:is="open ? LucideChevronUp : LucideChevronDown"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #item-label="{ item }">
|
||||
<button
|
||||
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded text-base"
|
||||
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded-4 text-base"
|
||||
@click="item.onClick"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<span class="whitespace-nowrap">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
<LucideCheck
|
||||
v-if="activeFilter === item.label"
|
||||
class="lucide-check size-4 text-ink-gray-7"
|
||||
aria-hidden="true"
|
||||
class="size-4 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
@@ -86,7 +79,7 @@
|
||||
:loading="agents.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
@@ -113,7 +106,7 @@
|
||||
</div>
|
||||
<hr class="mt-2" />
|
||||
<div v-for="(agent, index) in agents.data" :key="agent.agent_name">
|
||||
<div class="flex items-center justify-between h-14 group rounded">
|
||||
<div class="flex items-center justify-between h-14 group rounded-4">
|
||||
<div class="flex items-center gap-x-3 grow">
|
||||
<Avatar
|
||||
:image="agent.user_image"
|
||||
@@ -153,14 +146,9 @@
|
||||
? 'lucide-briefcase'
|
||||
: null,
|
||||
}"
|
||||
placement="right"
|
||||
align="end"
|
||||
/>
|
||||
<Dropdown
|
||||
:options="getOptions(agent)"
|
||||
:key="agent"
|
||||
class="ms-2"
|
||||
placement="right"
|
||||
>
|
||||
<Dropdown :options="getOptions(agent)" :key="agent" align="end">
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
@@ -185,17 +173,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideChevronUp from "~icons/lucide/chevron-up";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { __ } from "@/translation";
|
||||
import { Avatar, Button, call, Dropdown, toast } from "frappe-ui";
|
||||
import { h, onUnmounted } from "vue";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import { activeFilter, useAgents } from "./agents";
|
||||
import AgentIcon from "../icons/AgentIcon.vue";
|
||||
import { activeFilter, useAgents } from "./agents";
|
||||
import { setActiveSettingsTab } from "./settingsModal";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { __ } from "@/translation";
|
||||
import { renderOptionIcon } from "@/utils";
|
||||
|
||||
const { getUserRole, updateUserRoleCache } = useUserStore();
|
||||
const { isManager } = useAuthStore();
|
||||
@@ -206,65 +196,20 @@ const agents = agentStore.agents;
|
||||
|
||||
function getRoles(agent: string) {
|
||||
const agentRole = getUserRole(agent);
|
||||
const roles = [
|
||||
{
|
||||
label: "Agent",
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: "Agent",
|
||||
active: props.active,
|
||||
selected: agentRole === "Agent",
|
||||
icon: "lucide-user",
|
||||
onClick: () => {
|
||||
updateRole(agent, "Agent");
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
const role = (label: string, icon: string) => ({
|
||||
label,
|
||||
icon,
|
||||
selected: agentRole === label,
|
||||
onClick: () => updateRole(agent, label),
|
||||
});
|
||||
const roles = [role("Agent", "lucide-user")];
|
||||
if (isManager) {
|
||||
roles.unshift({
|
||||
label: "Manager",
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: "Manager",
|
||||
active: props.active,
|
||||
selected: agentRole === "Manager",
|
||||
icon: "lucide-briefcase",
|
||||
onClick: () => {
|
||||
updateRole(agent, "Manager");
|
||||
},
|
||||
}),
|
||||
});
|
||||
roles.unshift(role("Manager", "lucide-briefcase"));
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
function RoleOption({ active, role, onClick, selected, icon = null }) {
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
class: [
|
||||
active ? "bg-surface-gray-2" : "text-ink-gray-7",
|
||||
|
||||
"group flex w-full text-ink-gray-8 justify-between items-center rounded-md px-2 py-2 text-sm hover:bg-surface-gray-3",
|
||||
],
|
||||
onClick: !selected ? onClick : null,
|
||||
},
|
||||
[
|
||||
h("div", { class: "flex gap-2" }, [
|
||||
renderOptionIcon(icon),
|
||||
h("span", { class: "whitespace-nowrap" }, role),
|
||||
]),
|
||||
selected
|
||||
? h(LucideCheck, {
|
||||
class: ["h-4 w-4 shrink-0 text-ink-gray-7"],
|
||||
"aria-hidden": true,
|
||||
})
|
||||
: null,
|
||||
]
|
||||
);
|
||||
}
|
||||
function updateRole(agent: string, newRole: string) {
|
||||
const currentRole = getUserRole(agent);
|
||||
if (currentRole === newRole) {
|
||||
|
||||
@@ -24,11 +24,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<Popover bare side="bottom" align="end">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 ps-2 pe-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-44"
|
||||
@click="togglePopover()"
|
||||
class="flex items-center justify-between text-base rounded-4 h-7 py-1.5 ps-2 pe-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-44"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
@@ -37,30 +36,30 @@
|
||||
)?.label
|
||||
}}
|
||||
</div>
|
||||
<span class="lucide-chevron-down size-4" />
|
||||
<LucideChevronDown class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<template #default="{ close: closePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-7 mt-1 bg-surface-base shadow-xl rounded w-[--reka-popper-anchor-width]"
|
||||
class="p-1 text-ink-gray-7 mt-1 bg-surface-base shadow-xl rounded-4 w-[--reka-popper-anchor-width]"
|
||||
>
|
||||
<div
|
||||
v-for="option in ticketRoutingOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-sm flex items-center justify-between rounded"
|
||||
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-sm flex items-center justify-between rounded-4"
|
||||
@click="
|
||||
() => {
|
||||
assignmentRuleData.rule = option.value;
|
||||
togglePopover();
|
||||
closePopover();
|
||||
}
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<span
|
||||
<LucideCheck
|
||||
v-if="assignmentRuleData.rule == option.value"
|
||||
class="lucide-check size-4"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,48 +79,56 @@
|
||||
<AssigneeSearch @addAssignee="validateAssignmentRule('users')" />
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<div
|
||||
<Button
|
||||
v-for="user in users"
|
||||
:key="user.name"
|
||||
class="flex items-center gap-2 text-sm bg-surface-gray-2 rounded-md p-1 w-max px-2 select-none"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:label="user.full_name"
|
||||
>
|
||||
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
|
||||
<div class="text-ink-gray-7">
|
||||
{{ user.full_name }}
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="user.email == assignmentRuleData.lastUser"
|
||||
:text="__('Last user assigned by this rule')"
|
||||
:hover-delay="0.35"
|
||||
:placement="'top'"
|
||||
>
|
||||
<div
|
||||
class="text-xs rounded-full select-none bg-surface-blue-3 text-ink-base p-0.5 px-2"
|
||||
<template #prefix>
|
||||
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<Tooltip
|
||||
v-if="user.email == assignmentRuleData.lastUser"
|
||||
:text="__('Last user assigned by this rule')"
|
||||
:hover-delay="350"
|
||||
side="top"
|
||||
>
|
||||
{{ __("Last") }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="lucide-x"
|
||||
@click="removeAssignedUser(user)"
|
||||
/>
|
||||
</div>
|
||||
<Badge theme="blue" variant="solid" :label="__('Last')" />
|
||||
</Tooltip>
|
||||
<LucideX
|
||||
class="size-4 text-ink-gray-4 transition-colors hover:text-ink-gray-7"
|
||||
@click.stop="removeAssignedUser(user)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<ErrorMessage :message="assignmentRulesErrors.users" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Avatar, Button, ErrorMessage, Popover, Tooltip } from "frappe-ui";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import {
|
||||
assignmentRuleData,
|
||||
assignmentRulesErrors,
|
||||
validateAssignmentRule,
|
||||
} from "@/stores/assignmentRules";
|
||||
import AssigneeSearch from "./AssigneeSearch.vue";
|
||||
import { computed } from "vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ErrorMessage,
|
||||
Popover,
|
||||
Tooltip,
|
||||
} from "frappe-ui";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
import { computed } from "vue";
|
||||
import AssigneeSearch from "./AssigneeSearch.vue";
|
||||
|
||||
const { getUser } = useUserStore();
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<Combobox :multiple="true">
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<Popover bare side="bottom" align="end">
|
||||
<template #trigger>
|
||||
<Button
|
||||
variant="subtle"
|
||||
icon-left="lucide-plus"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Assignee')"
|
||||
class="rtl:flex-row-reverse"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<template #default>
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-base py-1 text-base shadow-2xl w-60"
|
||||
class="mt-1 rounded-6 bg-surface-base py-1 text-base shadow-2xl w-60"
|
||||
>
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
@@ -32,7 +31,7 @@
|
||||
class="absolute end-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="query = ''"
|
||||
>
|
||||
<span class="lucide-x w-4" />
|
||||
<LucideX class="w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions class="my-2 max-h-64 overflow-y-auto px-1.5" static>
|
||||
@@ -51,7 +50,7 @@
|
||||
"
|
||||
>
|
||||
<li
|
||||
class="flex items-center rounded p-1.5 w-full text-base"
|
||||
class="flex items-center rounded-4 p-1.5 w-full text-base"
|
||||
:class="{ 'bg-surface-gray-2': active }"
|
||||
>
|
||||
<div class="flex gap-2 items-center w-full select-none">
|
||||
@@ -74,7 +73,7 @@
|
||||
</ComboboxOption>
|
||||
<li
|
||||
v-if="users.length == 0"
|
||||
class="mt-1.5 rounded-md p-1.5 text-base text-ink-gray-5"
|
||||
class="mt-1.5 rounded-5 p-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
{{ __("No results found") }}
|
||||
</li>
|
||||
@@ -101,6 +100,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideX from "~icons/lucide/x";
|
||||
import { assignmentRuleData } from "@/stores/assignmentRules";
|
||||
import {
|
||||
Combobox,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded"
|
||||
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4"
|
||||
>
|
||||
<div
|
||||
@click="assignmentRulesActiveScreen = { screen: 'view', data: data }"
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<select
|
||||
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md p-0 ps-2 pe-5 bg-transparent -ms-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
|
||||
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-5 p-0 ps-2 pe-5 bg-transparent -ms-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
|
||||
v-model="data.priority"
|
||||
@update:modelValue="onPriorityChange"
|
||||
@change="onPriorityChange"
|
||||
@@ -39,12 +39,12 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown placement="right" :options="dropdownOptions">
|
||||
<Button
|
||||
icon="lucide-more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
/>
|
||||
<Dropdown
|
||||
align="end"
|
||||
:options="dropdownOptions"
|
||||
@update:open="(open) => open && (isConfirmingDelete = false)"
|
||||
>
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,11 +57,10 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel :label="__('Priority')" />
|
||||
<Popover>
|
||||
<template #target="{ togglePopover }">
|
||||
<Popover bare>
|
||||
<template #trigger>
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 ps-2 pe-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
|
||||
@click="togglePopover()"
|
||||
class="flex items-center justify-between text-base rounded-4 h-7 py-1.5 ps-2 pe-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
@@ -70,26 +69,26 @@
|
||||
)?.label
|
||||
}}
|
||||
</div>
|
||||
<span class="lucide-chevron-down size-4" />
|
||||
<LucideChevronDown class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<template #default="{ close: closePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-6 top-1 absolute bg-surface-base shadow-2xl rounded w-[--reka-popper-anchor-width]"
|
||||
class="p-1 text-ink-gray-6 top-1 absolute bg-surface-base shadow-2xl rounded-4 w-[--reka-popper-anchor-width]"
|
||||
>
|
||||
<div
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-surface-sidebar text-base flex items-center justify-between rounded"
|
||||
class="p-2 cursor-pointer hover:bg-surface-sidebar text-base flex items-center justify-between rounded-4"
|
||||
@click="
|
||||
assignmentRuleData.priority = option.value;
|
||||
togglePopover();
|
||||
closePopover();
|
||||
"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span
|
||||
<LucideCheck
|
||||
v-if="assignmentRuleData.priority == option.value"
|
||||
class="lucide-check size-4"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,29 +136,29 @@
|
||||
<div
|
||||
v-if="isAssignConditionOld && assignmentRulesActiveScreen.data"
|
||||
>
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<HoverCard :hoverDelay="250" side="top" align="end">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span>{{ __("Old Condition") }}</span>
|
||||
<span class="lucide-info size-4" />
|
||||
<LucideInfo class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<template #default>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.assignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-5 p-3 py-4"
|
||||
v-if="
|
||||
!useNewUIForAssignCondition &&
|
||||
assignmentRuleData.assignCondition
|
||||
@@ -218,29 +217,29 @@
|
||||
assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<HoverCard :hoverDelay="250" side="top" align="end">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span> {{ __("Old Condition") }} </span>
|
||||
<span class="lucide-info size-4" />
|
||||
<LucideInfo class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<template #default>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.unassignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-md p-3 py-4"
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-5 p-3 py-4"
|
||||
v-if="
|
||||
!useNewUIForUnassignCondition &&
|
||||
assignmentRuleData.unassignCondition
|
||||
@@ -310,6 +309,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import LucideInfo from "~icons/lucide/info";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import {
|
||||
Badge,
|
||||
@@ -319,6 +321,7 @@ import {
|
||||
ErrorMessage,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HoverCard,
|
||||
LoadingIndicator,
|
||||
Popover,
|
||||
Switch,
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
/>
|
||||
<div
|
||||
v-if="props.conditions.length == 0"
|
||||
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-outline-gray-2 text-ink-gray-5 rounded-md"
|
||||
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-outline-gray-2 text-ink-gray-5 rounded-5"
|
||||
@click="
|
||||
props.conditions.push(['', '', '']);
|
||||
validateAssignmentRule(props.name);
|
||||
"
|
||||
>
|
||||
<span class="lucide-plus h-4" />
|
||||
<LucidePlus class="size-4" />
|
||||
{{ __("Add a condition") }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
@@ -38,6 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucidePlus from "~icons/lucide/plus";
|
||||
import CFConditions from "@/components/conditions-filter/CFConditions.vue";
|
||||
import { validateConditions } from "@/utils";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
|
||||
<div class="rounded-5 border px-2 border-outline-gray-2 text-sm">
|
||||
<div
|
||||
class="grid p-2 px-4 items-center"
|
||||
style="grid-template-columns: 3fr 1fr"
|
||||
|
||||
@@ -5,7 +5,15 @@
|
||||
>
|
||||
<div class="text-ink-gray-7 font-medium">{{ data.day }}</div>
|
||||
<div class="flex justify-start">
|
||||
<Switch v-model="data.active" @update:model-value="toggleDay" />
|
||||
<Switch
|
||||
:model-value="Boolean(data.active)"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
data.active = value;
|
||||
toggleDay(value);
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="!isLast" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center border-outline-elevation-2 p-2 cursor-pointer hover:bg-surface-sidebar rounded h-14"
|
||||
class="flex justify-between items-center border-outline-elevation-2 p-2 cursor-pointer hover:bg-surface-sidebar rounded-4 h-14"
|
||||
>
|
||||
<!-- avatar and name -->
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
|
||||
@@ -38,14 +38,15 @@
|
||||
<hr
|
||||
class="mx-2"
|
||||
v-if="
|
||||
emailAccount !== emailAccounts.data[emailAccounts.data.length - 1]
|
||||
emailAccount !==
|
||||
emailAccounts?.data[emailAccounts?.data?.length - 1]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- fallback if no email accounts -->
|
||||
<EmptyState
|
||||
v-else
|
||||
v-else-if="!emailAccounts.loading"
|
||||
variant="badge"
|
||||
:icon="EmailIcon"
|
||||
:title="__('No email account found')"
|
||||
@@ -56,6 +57,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { EmailAccount } from "@/types";
|
||||
import { createListResource } from "frappe-ui";
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- email service provider info -->
|
||||
<div>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-outline-elevation-2"
|
||||
class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
|
||||
>
|
||||
<CircleAlert
|
||||
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
|
||||
@@ -167,7 +167,7 @@ import { capture } from "@/telemetry";
|
||||
import { __ } from "@/translation";
|
||||
import { EmailAccount, EmailService, EmailStep } from "@/types";
|
||||
import { call, createResource, toast } from "frappe-ui";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { useOnboarding } from "@framework/ui";
|
||||
import { computed, reactive, Ref, ref, watch } from "vue";
|
||||
import CircleAlert from "~icons/lucide/circle-alert";
|
||||
import {
|
||||
@@ -222,7 +222,7 @@ interface E {
|
||||
|
||||
const emit = defineEmits<E>();
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
|
||||
|
||||
const state = reactive<EmailAccountProviderAuthState>({
|
||||
service: "",
|
||||
@@ -308,7 +308,7 @@ const addEmailRes = createResource({
|
||||
onSuccess: () => {
|
||||
toast.success(__("Email account created"));
|
||||
emit("update:step", "email-list");
|
||||
updateOnboardingStep("setup_email_account");
|
||||
updateOnboardingStep?.("setup_email_account");
|
||||
capture("email_account_created", { data: { service: state.service } });
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-outline-elevation-2"
|
||||
class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
|
||||
>
|
||||
<CircleAlert
|
||||
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
|
||||
@@ -139,6 +139,7 @@
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="hasChanges"
|
||||
:label="__('Update Account')"
|
||||
variant="solid"
|
||||
@click="updateAccount"
|
||||
@@ -229,7 +230,7 @@ const props = withDefaults(defineProps<P>(), {
|
||||
|
||||
const emit = defineEmits<E>();
|
||||
|
||||
const state = reactive<EmailAccountProviderAuthState>({
|
||||
const getInitialState = (): EmailAccountProviderAuthState => ({
|
||||
email_account_name: props.accountData.email_account_name || "",
|
||||
service: props.accountData.service || "",
|
||||
email_id: props.accountData.email_id || "",
|
||||
@@ -243,6 +244,8 @@ const state = reactive<EmailAccountProviderAuthState>({
|
||||
default_incoming: props.accountData.default_incoming || false,
|
||||
});
|
||||
|
||||
const state = reactive<EmailAccountProviderAuthState>(getInitialState());
|
||||
|
||||
const getInitialCustomState = (): CustomEmailAccountState => ({
|
||||
domain: props.accountData?.domain || "",
|
||||
email_server: props.accountData?.email_server || "",
|
||||
@@ -328,26 +331,8 @@ async function updateAccount() {
|
||||
: currentServiceName.value;
|
||||
error.value = validateInputs(validationState, validationService, true);
|
||||
if (error.value) return;
|
||||
const old = { ...props.accountData };
|
||||
const updatedEmailAccount = { ...state };
|
||||
|
||||
const nameChanged =
|
||||
old.email_account_name !== updatedEmailAccount.email_account_name;
|
||||
delete old.email_account_name;
|
||||
delete updatedEmailAccount.email_account_name;
|
||||
|
||||
const otherFieldsChanged = isDirty.value;
|
||||
const values = buildUpdatePayload();
|
||||
|
||||
if (!nameChanged && !otherFieldsChanged) {
|
||||
toast.create({
|
||||
message: __("No changes made"),
|
||||
icon: h(CircleAlert, { class: "text-ink-blue-5" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (nameChanged) {
|
||||
if (nameChanged.value) {
|
||||
try {
|
||||
loading.value = true;
|
||||
await callRenameDoc();
|
||||
@@ -356,10 +341,10 @@ async function updateAccount() {
|
||||
errorHandler();
|
||||
}
|
||||
}
|
||||
if (otherFieldsChanged) {
|
||||
if (isDirty.value) {
|
||||
try {
|
||||
loading.value = true;
|
||||
await callSetValue(values);
|
||||
await callSetValue(buildUpdatePayload());
|
||||
succesHandler();
|
||||
} catch (err) {
|
||||
errorHandler();
|
||||
@@ -403,9 +388,8 @@ function buildUpdatePayload() {
|
||||
function pullEmails() {
|
||||
loadingPull.value = true;
|
||||
|
||||
toast.create({
|
||||
message: __("Pulling emails, this may take a few minutes."),
|
||||
icon: h(CircleAlert, { class: "text-ink-blue-5" }),
|
||||
toast(__("Pulling emails, this may take a few minutes."), {
|
||||
icon: () => h(CircleAlert, { class: "text-ink-blue-5" }),
|
||||
});
|
||||
|
||||
call("frappe.email.doctype.email_account.email_account.pull_emails", {
|
||||
@@ -423,32 +407,32 @@ function pullEmails() {
|
||||
});
|
||||
}
|
||||
|
||||
// Compare against the initial values rather than the account itself. The form
|
||||
// normalises what the server sends (a 0 becomes false, a null becomes ""), so
|
||||
// comparing straight to the account reported every custom account as changed.
|
||||
// Renaming goes through a separate API, so it is tracked by nameChanged.
|
||||
const isDirty = computed(() => {
|
||||
const customDirty = isCustomProvider.value
|
||||
? customState.domain !== props.accountData.domain ||
|
||||
customState.email_server !== props.accountData.email_server ||
|
||||
customState.smtp_server !== props.accountData.smtp_server ||
|
||||
customState.incoming_port !== props.accountData.incoming_port ||
|
||||
customState.smtp_port !== props.accountData.smtp_port ||
|
||||
customState.use_ssl !== props.accountData.use_ssl ||
|
||||
customState.use_starttls !== props.accountData.use_starttls
|
||||
: false;
|
||||
|
||||
return (
|
||||
customDirty ||
|
||||
state.service !== props.accountData.service ||
|
||||
state.email_id !== props.accountData.email_id ||
|
||||
state.api_key !== props.accountData.api_key ||
|
||||
state.api_secret !== props.accountData.api_secret ||
|
||||
state.password !== props.accountData.password ||
|
||||
state.enable_incoming !== props.accountData.enable_incoming ||
|
||||
state.enable_outgoing !== props.accountData.enable_outgoing ||
|
||||
state.default_outgoing !== props.accountData.default_outgoing ||
|
||||
state.default_incoming !== props.accountData.default_incoming ||
|
||||
state.frappe_mail_site !== props.accountData.frappe_mail_site
|
||||
const initialState = getInitialState();
|
||||
const baseChanged = (
|
||||
Object.keys(initialState) as (keyof EmailAccountProviderAuthState)[]
|
||||
).some(
|
||||
(field) =>
|
||||
field !== "email_account_name" && state[field] !== initialState[field]
|
||||
);
|
||||
if (baseChanged || !isCustomProvider.value) return baseChanged;
|
||||
|
||||
const initialCustomState = getInitialCustomState();
|
||||
return (
|
||||
Object.keys(initialCustomState) as (keyof CustomEmailAccountState)[]
|
||||
).some((field) => customState[field] !== initialCustomState[field]);
|
||||
});
|
||||
|
||||
const nameChanged = computed(
|
||||
() => state.email_account_name !== getInitialState().email_account_name
|
||||
);
|
||||
|
||||
const hasChanges = computed(() => nameChanged.value || isDirty.value);
|
||||
|
||||
async function callRenameDoc() {
|
||||
return call("frappe.client.rename_doc", {
|
||||
doctype: "Email Account",
|
||||
|
||||
@@ -62,7 +62,9 @@
|
||||
:required="true"
|
||||
:rows="10"
|
||||
v-model="content"
|
||||
:oninput="() => setUnsavedChanges()"
|
||||
@update:model-value="
|
||||
(value) => setUnsavedChanges(undefined, value)
|
||||
"
|
||||
/>
|
||||
<div class="flex gap-x-1 items-start justify-between">
|
||||
<p class="text-sm text-ink-gray-7 leading-5">
|
||||
@@ -151,6 +153,7 @@ const content = defineModel<string>("content", { required: true });
|
||||
const enabled = defineModel<boolean>("enabled", { required: true });
|
||||
|
||||
const unsavedChanges = ref(false);
|
||||
const slotFieldChanged = ref(false);
|
||||
const showUnsavedConfirm = ref(false);
|
||||
const showContentChangeConfirm = ref(false);
|
||||
|
||||
@@ -169,15 +172,25 @@ function setUnsavedChanges(
|
||||
newContent = content.value
|
||||
) {
|
||||
unsavedChanges.value =
|
||||
slotFieldChanged.value ||
|
||||
newEnabled !== notificationDataResource.data.enabled ||
|
||||
newContent !== notificationDataResource.data.content;
|
||||
}
|
||||
|
||||
/** For fields a notification adds through `#formFields`, which this component
|
||||
* cannot diff itself. */
|
||||
function markUnsavedChanges() {
|
||||
slotFieldChanged.value = true;
|
||||
unsavedChanges.value = true;
|
||||
}
|
||||
|
||||
function resetUnsavedChanges() {
|
||||
notificationDataResource.data = {
|
||||
...notificationDataResource.data,
|
||||
enabled: enabled.value,
|
||||
content: content.value,
|
||||
};
|
||||
slotFieldChanged.value = false;
|
||||
unsavedChanges.value = false;
|
||||
}
|
||||
function resetContent() {
|
||||
@@ -209,6 +222,7 @@ watch(
|
||||
|
||||
defineExpose({
|
||||
setUnsavedChanges,
|
||||
markUnsavedChanges,
|
||||
resetUnsavedChanges,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
v-for="(notification, index) in notifications"
|
||||
:key="notification.name"
|
||||
>
|
||||
<li class="flex items-center justify-between p-3 rounded relative">
|
||||
<li class="flex items-center justify-between p-3 rounded-4 relative">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2
|
||||
class="text-base-medium text-ink-gray-7 relative z-10 pointer-events-none"
|
||||
@@ -26,9 +26,8 @@
|
||||
{{ __(notification.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="lucide-chevron-right text-ink-gray-7 size-4 relative z-10 pointer-events-none rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
<LucideChevronRight
|
||||
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none rtl:rotate-180"
|
||||
/>
|
||||
<div
|
||||
class="w-full h-full absolute top-0 start-0 hover:bg-surface-sidebar rounded-[inherit]"
|
||||
@@ -51,6 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideChevronRight from "~icons/lucide/chevron-right";
|
||||
import { __ } from "@/translation";
|
||||
import type { AtLeastOneNotifcation, Notification } from "./types";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
@@ -15,16 +15,17 @@
|
||||
>
|
||||
<template #formFields>
|
||||
<FormControl
|
||||
type="autocomplete"
|
||||
type="combobox"
|
||||
size="sm"
|
||||
:label="__('On ticket status')"
|
||||
:options="statusOptions"
|
||||
:required="true"
|
||||
:model-value="ticketStatus"
|
||||
:model-value="ticketStatus?.value ?? null"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
ticketStatus = val;
|
||||
compRef.setUnsavedChanges();
|
||||
(value) => {
|
||||
ticketStatus =
|
||||
statusOptions.find((option) => option.value === value) ?? null;
|
||||
compRef.markUnsavedChanges();
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
<div class="flex gap-4">
|
||||
<!-- Switch -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<Switch v-model="state.enabled" class="!w-fit" />
|
||||
<Switch
|
||||
class="!w-fit"
|
||||
:model-value="Boolean(state.enabled)"
|
||||
@update:model-value="(value) => (state.enabled = value)"
|
||||
/>
|
||||
<span class="text-p-base text-ink-gray-6">
|
||||
{{ __("Enabled") }}
|
||||
</span>
|
||||
|
||||
@@ -20,15 +20,14 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
:disabled="!fieldCriteriaState.display.enabled"
|
||||
<MultiSelect
|
||||
class="min-w-[120px] max-w-[120px]"
|
||||
side="top"
|
||||
:disabled="!fieldCriteriaState.display.enabled"
|
||||
:options="fieldCriteriaOptions"
|
||||
:model-value="fieldCriteriaState.display.value"
|
||||
:model-value="selectedValues('display')"
|
||||
:placeholder="__('Select Child Field values')"
|
||||
@update:model-value="handleCriteriaSelection($event, 'display')"
|
||||
:multiple="true"
|
||||
placeholder="Select Child Field values"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
<!-- Mandatory Criteria -->
|
||||
@@ -46,15 +45,14 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
:disabled="!fieldCriteriaState.mandatory.enabled"
|
||||
<MultiSelect
|
||||
class="min-w-[120px] max-w-[120px]"
|
||||
side="top"
|
||||
:disabled="!fieldCriteriaState.mandatory.enabled"
|
||||
:options="fieldCriteriaOptions"
|
||||
:model-value="fieldCriteriaState.mandatory.value"
|
||||
:model-value="selectedValues('mandatory')"
|
||||
:placeholder="__('Select Child Field values')"
|
||||
@update:model-value="handleCriteriaSelection($event, 'mandatory')"
|
||||
:multiple="true"
|
||||
placeholder="Select Child Field values"
|
||||
placement="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,9 +61,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import DocumentationButton from "@/components/DocumentationButton.vue";
|
||||
import MultiSelectCombobox from "@/components/frappe-ui/MultiSelectCombobox.vue";
|
||||
import { getMeta } from "@/stores/meta";
|
||||
import { Switch } from "frappe-ui";
|
||||
import { __ } from "@/translation";
|
||||
import { MultiSelect, Switch } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -98,21 +96,25 @@ const fieldCriteriaOptions = computed(() => {
|
||||
return _options;
|
||||
});
|
||||
|
||||
function selectedValues(stateKey: "display" | "mandatory") {
|
||||
return fieldCriteriaState.value[stateKey].value.map((v) => v.value);
|
||||
}
|
||||
|
||||
function handleCriteriaSelection(
|
||||
values: { label: string; value: string }[],
|
||||
values: string[],
|
||||
stateKey: "display" | "mandatory"
|
||||
) {
|
||||
const _values = values.map((v) => v.value);
|
||||
fieldCriteriaState.value[stateKey].value = values;
|
||||
if (_values.length === 0) {
|
||||
fieldCriteriaState.value[stateKey].value = [{ label: "Any", value: "Any" }];
|
||||
} else if (_values[0] === "Any" && _values.length > 1) {
|
||||
fieldCriteriaState.value[stateKey].value = _values
|
||||
.filter((value) => value !== "Any")
|
||||
.map((value) => ({ label: value, value }));
|
||||
} else if (_values.at(-1) === "Any" && _values.length > 1) {
|
||||
fieldCriteriaState.value[stateKey].value = [{ label: "Any", value: "Any" }];
|
||||
}
|
||||
// "Any" is exclusive: picking it drops the rest, picking anything else drops it
|
||||
const wasAnySelected = selectedValues(stateKey).includes("Any");
|
||||
let selected =
|
||||
values.includes("Any") && !wasAnySelected
|
||||
? ["Any"]
|
||||
: values.filter((value) => value !== "Any");
|
||||
if (!selected.length) selected = ["Any"];
|
||||
fieldCriteriaState.value[stateKey].value = selected.map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
v-if="fieldDependenciesList.loading"
|
||||
class="flex items-center justify-center py-4"
|
||||
>
|
||||
<LoadingIndicator :scale="5" />
|
||||
<LoadingIndicator class="size-5" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
@@ -75,7 +75,7 @@
|
||||
:key="row.name"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded h-12.5"
|
||||
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4 h-12.5"
|
||||
>
|
||||
<div
|
||||
@click.stop="$emit('update:step', 'fd', row.name)"
|
||||
@@ -100,7 +100,13 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown placement="right" :options="getOptions(row.name)">
|
||||
<Dropdown
|
||||
align="end"
|
||||
:options="getOptions(row.name)"
|
||||
@update:open="
|
||||
(open) => open && (isConfirmingDelete = false)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click.stop="
|
||||
@@ -138,6 +144,7 @@ import {
|
||||
Switch,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import { getFieldDependencyLabel, ConfirmDelete } from "@/utils";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { fieldDependenciesList } from "./fieldDependency";
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
<span class="block text-xs text-ink-gray-5">
|
||||
Select parent field value
|
||||
</span>
|
||||
<div class="border flex-1 border-e-0 rounded-s p-2 flex flex-col gap-2">
|
||||
<div
|
||||
class="border border-outline-elevation-2 flex-1 border-e-0 rounded-s-4 p-2 flex flex-col gap-2"
|
||||
>
|
||||
<template v-if="state.selectedParentField">
|
||||
<FormControl
|
||||
v-model="state.parentSearch"
|
||||
@@ -24,7 +26,7 @@
|
||||
<li
|
||||
v-for="value in filteredParentFieldValues"
|
||||
:key="value"
|
||||
class="py-2 mb-1 px-2.5 cursor-pointer rounded flex justify-between items-center hover:bg-surface-gray-1 overflow-hidden max-w-full"
|
||||
class="py-2 mb-1 px-2.5 cursor-pointer rounded-4 flex justify-between items-center hover:bg-surface-gray-1 overflow-hidden max-w-full"
|
||||
:class="{
|
||||
'bg-surface-gray-2 hover:bg-surface-gray-3':
|
||||
state.currentParentSelection === value,
|
||||
@@ -66,7 +68,9 @@
|
||||
<span class="block text-xs text-ink-gray-5 ps-1.5">
|
||||
Select child field value
|
||||
</span>
|
||||
<div class="border flex-1 rounded-e p-2 flex flex-col gap-2">
|
||||
<div
|
||||
class="border border-outline-elevation-2 flex-1 rounded-e-4 p-2 flex flex-col gap-2"
|
||||
>
|
||||
<template
|
||||
v-if="state.selectedChildField && state.currentParentSelection"
|
||||
>
|
||||
@@ -83,13 +87,13 @@
|
||||
<div class="flex-1 overflow-y-auto hide-scrollbar basis-0">
|
||||
<!-- Master Check box -->
|
||||
<li
|
||||
class="py-2 mb-1 px-2.5 cursor-pointer rounded flex items-center bg-surface-gray-1 hover:bg-surface-gray-2"
|
||||
class="py-2 mb-1 px-2.5 cursor-pointer rounded-4 flex items-center bg-surface-gray-1 hover:bg-surface-gray-2"
|
||||
@click="handleSelectAllChildValues(!toggleAllChildValues)"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:model-value="toggleAllChildValues"
|
||||
class="me-2"
|
||||
class="me-4"
|
||||
/>
|
||||
<span class="text-base-medium text-ink-gray-8">
|
||||
{{ toggleCheckboxLabel }}
|
||||
@@ -99,13 +103,13 @@
|
||||
<li
|
||||
v-for="value in filteredChildFieldValues"
|
||||
:key="value"
|
||||
class="py-2 mb-1 px-2.5 cursor-pointer rounded flex items-center hover:bg-surface-gray-1 max-w-full truncate"
|
||||
class="py-2 mb-1 px-2.5 cursor-pointer rounded-4 flex items-center hover:bg-surface-gray-1 max-w-full truncate"
|
||||
@click="handleChildValueClick(value)"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:model-value="isChildValueSelected(value)"
|
||||
class="me-2"
|
||||
class="me-4"
|
||||
/>
|
||||
<span class="text-base text-ink-gray-6">{{ value }}</span>
|
||||
</li>
|
||||
|
||||
@@ -254,14 +254,61 @@ const saveSettings = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFields = [
|
||||
"isFeedbackMandatory",
|
||||
"enableCommentReactions",
|
||||
"disableSavedRepliesGlobalScope",
|
||||
"allowAnyoneToCreateTickets",
|
||||
"preferKnowledgeBase",
|
||||
"skipEmailWorkflow",
|
||||
] as const;
|
||||
const toggleFieldnames = {
|
||||
isFeedbackMandatory: "is_feedback_mandatory",
|
||||
enableCommentReactions: "enable_comment_reactions",
|
||||
disableSavedRepliesGlobalScope: "disable_saved_replies_global_scope",
|
||||
allowAnyoneToCreateTickets: "allow_anyone_to_create_tickets",
|
||||
preferKnowledgeBase: "prefer_knowledge_base",
|
||||
skipEmailWorkflow: "skip_email_workflow",
|
||||
} as const;
|
||||
const toggleFields = Object.keys(toggleFieldnames) as Array<
|
||||
keyof typeof toggleFieldnames
|
||||
>;
|
||||
|
||||
// Toggles save on their own, so they never carry unsaved text fields along.
|
||||
const saveTogglesResource = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
makeParams: (values: Record<string, unknown>) => ({
|
||||
doctype: "HD Settings",
|
||||
name: "HD Settings",
|
||||
fieldname: Object.fromEntries(
|
||||
toggleFields.map((f) => [toggleFieldnames[f], values[f]])
|
||||
),
|
||||
}),
|
||||
});
|
||||
let pendingToggleSave = Promise.resolve();
|
||||
|
||||
function currentToggles() {
|
||||
return Object.fromEntries(
|
||||
toggleFields.map((f) => [f, settingsData.value[f]])
|
||||
);
|
||||
}
|
||||
|
||||
async function saveToggles(sent: Record<string, unknown>) {
|
||||
try {
|
||||
await saveTogglesResource.submit(sent);
|
||||
markTogglesSaved(sent);
|
||||
toast.success(__("Settings updated"));
|
||||
} catch {
|
||||
revertToggles(sent);
|
||||
}
|
||||
}
|
||||
|
||||
function markTogglesSaved(sent: Record<string, unknown>) {
|
||||
const initial = JSON.parse(initialData.value!);
|
||||
toggleFields.forEach((f) => (initial[f] = sent[f]));
|
||||
initialData.value = JSON.stringify(initial);
|
||||
configStore.configResource.reload();
|
||||
}
|
||||
|
||||
// Undo only what the failed request sent; a toggle changed since keeps its queued save.
|
||||
function revertToggles(sent: Record<string, unknown>) {
|
||||
const initial = JSON.parse(initialData.value!);
|
||||
toggleFields
|
||||
.filter((f) => settingsData.value[f] === sent[f])
|
||||
.forEach((f) => (settingsData.value[f] = initial[f]));
|
||||
}
|
||||
|
||||
// Track dirty state for non-toggle fields only
|
||||
watch(
|
||||
@@ -271,7 +318,7 @@ watch(
|
||||
const initial = JSON.parse(initialData.value);
|
||||
isDirty.value = Object.keys(data).some(
|
||||
(key) =>
|
||||
!(toggleFields as readonly string[]).includes(key) &&
|
||||
!(toggleFields as string[]).includes(key) &&
|
||||
JSON.stringify(data[key as keyof typeof data]) !==
|
||||
JSON.stringify(initial[key])
|
||||
);
|
||||
@@ -280,16 +327,16 @@ watch(
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// auto save when any toggle field changes
|
||||
// Queue toggle saves: parallel writes to the single doc fail with a 417.
|
||||
watch(
|
||||
() => toggleFields.map((f) => settingsData.value[f]),
|
||||
async (newVals) => {
|
||||
(newVals) => {
|
||||
if (!initialData.value) return;
|
||||
const initial = JSON.parse(initialData.value);
|
||||
if (newVals.some((v, i) => v !== initial[toggleFields[i]])) {
|
||||
await saveSettingsResource.submit();
|
||||
toast.success(__("Settings updated"));
|
||||
}
|
||||
if (!newVals.some((v, i) => v !== initial[toggleFields[i]])) return;
|
||||
pendingToggleSave = pendingToggleSave.then(() =>
|
||||
saveToggles(currentToggles())
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-3.5">
|
||||
<div
|
||||
class="flex items-center justify-center min-w-16 min-h-16 rounded-lg overflow-hidden border border-outline-gray-1"
|
||||
class="flex items-center justify-center min-w-16 min-h-16 rounded-6 overflow-hidden border border-outline-gray-1"
|
||||
>
|
||||
<Avatar
|
||||
v-if="props.image"
|
||||
@@ -12,11 +12,7 @@
|
||||
:image="props.image"
|
||||
:label="props.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="lucide-image size-6 text-ink-gray-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<LucideImage v-else class="size-6 text-ink-gray-4" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 max-w-sm items-start">
|
||||
<span class="text-base-medium text-ink-gray-8">{{ title }}</span>
|
||||
@@ -61,6 +57,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideImage from "~icons/lucide/image";
|
||||
import { Avatar, Button, FileUploader } from "frappe-ui";
|
||||
import ImageUpIcon from "~icons/lucide/image-up";
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
:options="ticketTypeList.data"
|
||||
:options="ticketTypeList.data || []"
|
||||
v-model="settingsData.defaultTicketType"
|
||||
target-class="max-w-40"
|
||||
placement="bottom-start"
|
||||
@@ -294,7 +294,8 @@ function resetBannerContent() {
|
||||
}
|
||||
|
||||
function handleShowBannerToggle(value: boolean) {
|
||||
if (!bannerMsg.data.current) {
|
||||
// the switch is live before the message resource lands
|
||||
if (!bannerMsg.data?.current) {
|
||||
if (value) {
|
||||
settingsData.value.outsideWorkingHoursBannerMessage =
|
||||
bannerMsg.data?.default;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="text-base-medium mb-2 text-ink-gray-8 ms-2.5">
|
||||
{{ formattedMonth }}
|
||||
</div>
|
||||
<div class="rounded-md text-sm">
|
||||
<div class="rounded-5 text-sm">
|
||||
<div class="flex items-center text-xs uppercase">
|
||||
<div
|
||||
class="flex size-7.5 items-center justify-center text-center text-ink-gray-5"
|
||||
@@ -16,37 +16,51 @@
|
||||
<div class="flex items-center" v-for="(week, i) in datesAsWeeks" :key="i">
|
||||
<div v-for="date in week" :key="getFormattedDate(date)">
|
||||
<Popover v-if="isHoliday(date)">
|
||||
<template #target="{ open, close }">
|
||||
<div
|
||||
class="flex size-7 cursor-pointer text-ink-orange-6 bg-surface-yellow-2 items-center justify-center rounded hover:bg-surface-yellow-2 select-none m-[1px]"
|
||||
:class="{
|
||||
'!text-ink-gray-4 !bg-surface-gray-2': isWeekOff(date),
|
||||
}"
|
||||
@mouseover="handleMouseEnter(getFormattedDate(date), open)"
|
||||
@mouseleave="handleMouseLeave(getFormattedDate(date), close)"
|
||||
@click="
|
||||
() => {
|
||||
if (isWeekOff(date)) return;
|
||||
close();
|
||||
editHoliday(date);
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ date.getDate() }}
|
||||
<template #trigger="{ close, setOpen }">
|
||||
<!--
|
||||
Hover opens this one. reka turns the whole trigger subtree into
|
||||
the toggle, so the cell stops its click before it gets there.
|
||||
-->
|
||||
<div>
|
||||
<div
|
||||
class="flex size-7 cursor-pointer text-ink-orange-6 bg-surface-yellow-2 items-center justify-center rounded-4 hover:bg-surface-yellow-2 select-none m-[1px]"
|
||||
:class="{
|
||||
'!text-ink-gray-4 !bg-surface-gray-2': isWeekOff(date),
|
||||
}"
|
||||
@mouseover="
|
||||
handleMouseEnter(getFormattedDate(date), () =>
|
||||
setOpen(true)
|
||||
)
|
||||
"
|
||||
@mouseleave="handleMouseLeave(getFormattedDate(date), close)"
|
||||
@click.stop="
|
||||
() => {
|
||||
if (isWeekOff(date)) return;
|
||||
close();
|
||||
editHoliday(date);
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ date.getDate() }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main="{ close: closePopover, open: openPopover }">
|
||||
<template
|
||||
#default="{ close: closePopover, setOpen: setPopoverOpen }"
|
||||
>
|
||||
<div
|
||||
class="p-3 flex gap-2.5 text-ink-gray-9 w-80 border border-outline-gray-1 rounded-md"
|
||||
class="p-3 flex gap-2.5 text-ink-gray-9 w-80 border border-outline-gray-1 rounded-5"
|
||||
@mouseover="
|
||||
handleMouseEnter(getFormattedDate(date), openPopover)
|
||||
handleMouseEnter(getFormattedDate(date), () =>
|
||||
setPopoverOpen(true)
|
||||
)
|
||||
"
|
||||
@mouseleave="
|
||||
handleMouseLeave(getFormattedDate(date), closePopover)
|
||||
"
|
||||
>
|
||||
<div class="w-[5%]">
|
||||
<div class="size-3.5 bg-surface-orange-5 rounded-sm mt-1" />
|
||||
<div class="size-3.5 bg-surface-orange-5 rounded-1 mt-1" />
|
||||
</div>
|
||||
<div class="grow">
|
||||
<div class="text-sm-semibold">
|
||||
@@ -60,11 +74,10 @@
|
||||
v-if="!isWeekOff(date)"
|
||||
@close="isConfirmingDelete = false"
|
||||
>
|
||||
<template #target="{ open, close }">
|
||||
<template #trigger="{ close }">
|
||||
<Button
|
||||
icon="lucide-more-horizontal"
|
||||
variant="ghost"
|
||||
@click="open"
|
||||
@mouseleave="
|
||||
handleMouseLeave(
|
||||
getFormattedDate(date) + 'dropdown',
|
||||
@@ -74,15 +87,20 @@
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
#body-main="{ close: closeDropdown, open: openDropdown }"
|
||||
#default="{
|
||||
close: closeDropdown,
|
||||
setOpen: setDropdownOpen,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="p-2 flex flex-col gap-1 w-40 text-ink-gray-9 border border-outline-gray-1 rounded-md"
|
||||
class="p-2 flex flex-col gap-1 w-40 text-ink-gray-9 border border-outline-gray-1 rounded-5"
|
||||
@mouseover="
|
||||
handleMouseEnter(getFormattedDate(date), openPopover);
|
||||
handleMouseEnter(getFormattedDate(date), () =>
|
||||
setPopoverOpen(true)
|
||||
);
|
||||
handleMouseEnter(
|
||||
getFormattedDate(date) + 'dropdown',
|
||||
openDropdown
|
||||
() => setDropdownOpen(true)
|
||||
);
|
||||
"
|
||||
@mouseleave="
|
||||
@@ -131,7 +149,7 @@
|
||||
</Popover>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-7 items-center justify-center rounded m-[1px] select-none"
|
||||
class="flex size-7 items-center justify-center rounded-4 m-[1px] select-none"
|
||||
:class="{
|
||||
'cursor-pointer hover:bg-surface-gray-2': isDateInRange(date),
|
||||
'text-ink-gray-3':
|
||||
@@ -155,7 +173,8 @@
|
||||
<script setup lang="ts">
|
||||
import { holidayData } from "@/stores/holidayList";
|
||||
import { getFormattedDate, htmlToText } from "@/utils";
|
||||
import { dayjs, Popover, useDatePicker } from "frappe-ui";
|
||||
import { dayjs, Popover } from "frappe-ui";
|
||||
import { useDatePicker } from "@/composables/useDatePicker";
|
||||
import { ref, watch } from "vue";
|
||||
import AddHolidayModal from "./Modals/AddHolidayModal.vue";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center cursor-pointer hover:bg-surface-sidebar rounded"
|
||||
class="flex items-center cursor-pointer hover:bg-surface-sidebar rounded-4"
|
||||
>
|
||||
<div
|
||||
class="w-full ps-2 flex flex-col justify-center h-14"
|
||||
@@ -16,12 +16,12 @@
|
||||
</div>
|
||||
<div class="flex justify-between items-center pe-2">
|
||||
<div>
|
||||
<Dropdown placement="right" :options="dropdownOptions">
|
||||
<Button
|
||||
icon="lucide-more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
/>
|
||||
<Dropdown
|
||||
align="end"
|
||||
:options="dropdownOptions"
|
||||
@update:open="(open) => open && (isConfirmingDelete = false)"
|
||||
>
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,13 +140,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<TabButtons
|
||||
:buttons="[
|
||||
:options="[
|
||||
{
|
||||
value: 'calendar',
|
||||
label: __('Calendar'),
|
||||
icon: 'lucide-calendar',
|
||||
},
|
||||
{
|
||||
value: 'list',
|
||||
label: __('List'),
|
||||
icon: 'lucide-list',
|
||||
},
|
||||
]"
|
||||
@@ -167,13 +169,13 @@
|
||||
<!-- Indicators -->
|
||||
<div class="flex gap-4" v-if="holidayListView === 'calendar'">
|
||||
<div class="gap-1 flex items-center">
|
||||
<span class="bg-surface-yellow-2 size-4 rounded-sm" />
|
||||
<span class="bg-surface-yellow-2 size-4 rounded-1" />
|
||||
<span class="text-sm text-ink-gray-6">{{
|
||||
__("Holidays")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="gap-1 flex items-center">
|
||||
<span class="bg-surface-gray-2 size-4 rounded-sm" />
|
||||
<span class="bg-surface-gray-2 size-4 rounded-1" />
|
||||
<span class="text-sm text-ink-gray-6">{{
|
||||
__("Recurring holidays")
|
||||
}}</span>
|
||||
|
||||
@@ -74,10 +74,8 @@ watch(holidaySearchRef, (newValue) => {
|
||||
holidayList.filters = {
|
||||
name: ["like", `%${newValue}%`],
|
||||
};
|
||||
if (!newValue) {
|
||||
holidayList.start = 0;
|
||||
holidayList.pageLength = 10;
|
||||
}
|
||||
// no pager on this list, so a shrunken pageLength would hide the rest for good
|
||||
if (!newValue) holidayList.start = 0;
|
||||
holidayList.reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<div class="p-6.5 px-5 rounded-xl border border-outline-gray-2">
|
||||
<div class="p-6.5 px-5 rounded-7 border border-outline-gray-2">
|
||||
<div class="mb-6.5 flex justify-between items-center">
|
||||
<div class="ms-1">
|
||||
<Popover v-if="startYear !== endYear">
|
||||
<template #target="{ togglePopover }">
|
||||
<template #trigger>
|
||||
<Button
|
||||
class="flex items-center gap-2 text-xl-semibold cursor-pointer select-none"
|
||||
variant="ghost"
|
||||
@click="togglePopover"
|
||||
:label="currentYear + ''"
|
||||
icon-right="lucide-chevron-down"
|
||||
/>
|
||||
</template>
|
||||
<template #body-main="{ togglePopover }">
|
||||
<template #default="{ close: closePopover }">
|
||||
<div class="w-24">
|
||||
<div ref="yearsContainer" class="max-h-60 overflow-y-auto py-1">
|
||||
<div
|
||||
@@ -20,14 +19,10 @@
|
||||
:key="year"
|
||||
ref="yearItems"
|
||||
class="cursor-pointer px-3 py-1.5 text-sm hover:bg-surface-gray-2 flex items-center justify-between"
|
||||
@click="onYearChange(togglePopover, year)"
|
||||
@click="onYearChange(closePopover, year)"
|
||||
>
|
||||
{{ year }}
|
||||
<span
|
||||
class="lucide-check size-4"
|
||||
v-if="year === currentYear"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<LucideCheck class="size-4" v-if="year === currentYear" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +100,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import HLCalender from "./HLCalender.vue";
|
||||
import { holidayData } from "@/stores/holidayList";
|
||||
import { Button, dayjs, Popover } from "frappe-ui";
|
||||
@@ -125,7 +121,7 @@ const yearsList = computed(() => {
|
||||
return yearList;
|
||||
});
|
||||
|
||||
const onYearChange = (togglePopover: () => void, year: number) => {
|
||||
const onYearChange = (closePopover: () => void, year: number) => {
|
||||
currentYear.value = year;
|
||||
if (year === dayjs(holidayData.value.from_date).year()) {
|
||||
if (dayjs(holidayData.value.from_date).month() >= 6) {
|
||||
@@ -138,7 +134,7 @@ const onYearChange = (togglePopover: () => void, year: number) => {
|
||||
} else {
|
||||
visibleMonths.value = "first-half";
|
||||
}
|
||||
togglePopover();
|
||||
closePopover();
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
@@ -150,17 +146,14 @@ const goToToday = () => {
|
||||
watch(
|
||||
() => [holidayData.value.from_date, holidayData.value.to_date],
|
||||
([fromDate, toDate]) => {
|
||||
fromDate = dayjs(fromDate);
|
||||
toDate = dayjs(toDate);
|
||||
fromDate = dayjs(fromDate || dayjs());
|
||||
toDate = dayjs(toDate || dayjs());
|
||||
startYear.value = fromDate.year();
|
||||
endYear.value = toDate.year();
|
||||
currentYear.value = fromDate.year();
|
||||
visibleMonths.value = fromDate.month() >= 6 ? "second-half" : "first-half";
|
||||
}
|
||||
},
|
||||
// The list is loaded before this view mounts, so a change-only watch never fires.
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
const fromDate = dayjs(holidayData.value.from_date || dayjs());
|
||||
visibleMonths.value = fromDate.month() >= 6 ? "second-half" : "first-half";
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-md border px-2 border-outline-gray-2 text-sm">
|
||||
<div class="rounded-5 border px-2 border-outline-gray-2 text-sm">
|
||||
<div
|
||||
class="grid p-2 px-4 items-center gap-2"
|
||||
:style="{
|
||||
@@ -37,12 +37,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Dropdown placement="right" :options="dropdownOptions(holiday)">
|
||||
<Button
|
||||
icon="lucide-more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
/>
|
||||
<Dropdown
|
||||
align="end"
|
||||
:options="dropdownOptions(holiday)"
|
||||
@update:open="(open) => open && (isConfirmingDelete = false)"
|
||||
>
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-md border p-1 border-outline-gray-2 text-sm">
|
||||
<div class="rounded-5 border p-1 border-outline-gray-2 text-sm">
|
||||
<div
|
||||
class="grid p-2 items-center"
|
||||
:style="{
|
||||
@@ -32,12 +32,12 @@
|
||||
<div v-else>{{ holiday[column.key] }}</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Dropdown placement="right" :options="dropdownOptions(holiday)">
|
||||
<Button
|
||||
icon="lucide-more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
/>
|
||||
<Dropdown
|
||||
align="end"
|
||||
:options="dropdownOptions(holiday)"
|
||||
@update:open="(open) => open && (isConfirmingDelete = false)"
|
||||
>
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:description="__('Comma separated emails to invite.')"
|
||||
/>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-xs text-ink-gray-5">
|
||||
<label class="block text-base text-ink-gray-5">
|
||||
{{ __("Role") }}
|
||||
<span class="text-ink-red-6 select-none" aria-hidden="true">*</span>
|
||||
</label>
|
||||
@@ -45,7 +45,7 @@
|
||||
<li
|
||||
v-for="invite in pendingInvitesResource.data"
|
||||
:key="invite.name"
|
||||
class="flex items-center justify-between px-3 py-1 rounded-lg bg-surface-gray-2"
|
||||
class="flex items-center justify-between px-3 py-1 rounded-6 bg-surface-gray-2"
|
||||
>
|
||||
<div class="text-base">
|
||||
<span class="text-ink-gray-8">
|
||||
@@ -116,7 +116,7 @@ import {
|
||||
createResource,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { useOnboarding } from "@framework/ui";
|
||||
import { computed, ref } from "vue";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
|
||||
@@ -124,7 +124,7 @@ const authStore = useAuthStore();
|
||||
const { isAdmin, isManager } = authStore;
|
||||
|
||||
// @ts-expect-error
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
|
||||
|
||||
const emails = ref("");
|
||||
|
||||
@@ -215,7 +215,7 @@ const inviteByEmailResource = createResource({
|
||||
resetInputValues();
|
||||
handleInviteUserSuccess(data);
|
||||
pendingInvitesResource.reload();
|
||||
updateOnboardingStep("invite_your_team");
|
||||
updateOnboardingStep?.("invite_agents");
|
||||
capture("agents_invited", {
|
||||
data: {
|
||||
role: role.value,
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
<Link
|
||||
:model-value="user.doc?.language"
|
||||
doctype="Language"
|
||||
:placeholder="__('Select Language')"
|
||||
class="w-40"
|
||||
side="top"
|
||||
align="end"
|
||||
:placeholder="__('Select language')"
|
||||
hide-clear-button
|
||||
@update:model-value="updateLanguage"
|
||||
/>
|
||||
</div>
|
||||
@@ -36,9 +39,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Link from "@/components/frappe-ui/Link.vue";
|
||||
import TimezoneControl from "@/components/TimezoneControl.vue";
|
||||
import { __ } from "@/translation";
|
||||
import { Link } from "frappe-ui/frappe";
|
||||
|
||||
const props = defineProps<{ user: any }>();
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
<div class="flex flex-col gap-4 mt-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<slot name="title">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Theme")
|
||||
}}</span>
|
||||
<span class="text-base-medium text-ink-gray-8">{{ __("Theme") }}</span>
|
||||
</slot>
|
||||
<slot name="description">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
@@ -18,113 +16,64 @@
|
||||
<div
|
||||
v-for="option in themeOptions"
|
||||
:key="option.value"
|
||||
class="flex-1 max-w-[227px] min-h-[42px] overflow-hidden rounded-lg border cursor-pointer outline-none transition-colors focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
||||
class="flex-1 rounded-6 border cursor-pointer min-h-[42px]"
|
||||
:class="
|
||||
theme === option.value
|
||||
? 'border-outline-gray-5'
|
||||
: 'border-outline-gray-modals'
|
||||
? 'border-outline-gray-7'
|
||||
: 'border-outline-elevation-2'
|
||||
"
|
||||
role="radio"
|
||||
:aria-checked="theme === option.value"
|
||||
tabindex="0"
|
||||
@click="theme = option.value"
|
||||
@keydown.enter.prevent="theme = option.value"
|
||||
@keydown.space.prevent="theme = option.value"
|
||||
>
|
||||
<!-- Theme-independent window mock: each frame is a fixed-tone SVG so
|
||||
the Light/Dark/System previews always render the same regardless
|
||||
of the app's active theme. -->
|
||||
<div :class="{ flex: option.frames.length > 1 }">
|
||||
<div :class="{ flex: option.panes.length > 1 }">
|
||||
<div
|
||||
v-for="(frame, index) in option.frames"
|
||||
v-for="(pane, index) in option.panes"
|
||||
:key="index"
|
||||
:class="frame.frameClass"
|
||||
:class="pane.containerClass"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
:class="frame.fill ? 'w-full' : 'w-fit shrink-0'"
|
||||
>
|
||||
<svg
|
||||
width="207"
|
||||
height="68"
|
||||
viewBox="0 0 207 68"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="block h-auto rounded-tl-sm"
|
||||
:class="{ 'w-full': frame.fill }"
|
||||
>
|
||||
<path
|
||||
d="M0 4C0 1.79086 1.79086 0 4 0H207V83H0V4Z"
|
||||
:fill="tones[frame.tone].background"
|
||||
/>
|
||||
<template v-if="option.bars">
|
||||
<rect
|
||||
x="104"
|
||||
y="28"
|
||||
width="103"
|
||||
height="6"
|
||||
:fill="tones[frame.tone].bar"
|
||||
/>
|
||||
<rect
|
||||
x="104"
|
||||
y="39"
|
||||
width="103"
|
||||
height="6"
|
||||
:fill="tones[frame.tone].bar"
|
||||
/>
|
||||
<rect
|
||||
x="104"
|
||||
y="50"
|
||||
width="103"
|
||||
height="6"
|
||||
:fill="tones[frame.tone].bar"
|
||||
/>
|
||||
</template>
|
||||
<rect
|
||||
x="4"
|
||||
y="3"
|
||||
width="6"
|
||||
height="6"
|
||||
rx="3"
|
||||
fill="#FF5F57"
|
||||
/>
|
||||
<rect
|
||||
x="13"
|
||||
y="3"
|
||||
width="6"
|
||||
height="6"
|
||||
rx="3"
|
||||
fill="#FEBC2D"
|
||||
/>
|
||||
<rect
|
||||
x="22"
|
||||
y="3"
|
||||
width="6"
|
||||
height="6"
|
||||
rx="3"
|
||||
fill="#28C840"
|
||||
/>
|
||||
<line
|
||||
y1="13.5"
|
||||
x2="207"
|
||||
y2="13.5"
|
||||
:stroke="tones[frame.tone].line"
|
||||
/>
|
||||
</svg>
|
||||
<div :class="pane.screenClass">
|
||||
<div
|
||||
class="absolute flex items-center gap-1 text-xs text-ink-gray-5 font-semibold top-[24px] left-[10px]"
|
||||
class="flex gap-[3px] py-[3px] px-1 border-b"
|
||||
:class="
|
||||
pane.tone === 'light'
|
||||
? 'border-gray-100'
|
||||
: 'border-gray-800'
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="logoIsImage"
|
||||
:src="logo as string"
|
||||
class="size-5 object-cover"
|
||||
/>
|
||||
<component
|
||||
:is="logo"
|
||||
v-else-if="logo"
|
||||
class="size-5 shrink-0 rounded-[5px]"
|
||||
/>
|
||||
<div v-if="name">{{ __(name) }}</div>
|
||||
<div class="size-1.5 bg-[#FF5F57] rounded-full" />
|
||||
<div class="size-1.5 bg-[#FEBC2D] rounded-full" />
|
||||
<div class="size-1.5 bg-[#28C840] rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
class="flex items-start justify-between gap-2 p-2.5 pr-0 pb-1 min-h-[41px]"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-1 gap-1 text-xs-semibold text-ink-gray-5"
|
||||
>
|
||||
<img
|
||||
v-if="logoIsImage"
|
||||
:src="logo as string"
|
||||
class="size-5 object-cover"
|
||||
/>
|
||||
<component
|
||||
:is="logo"
|
||||
v-else-if="logo"
|
||||
class="size-5 shrink-0 rounded-4"
|
||||
/>
|
||||
<div>{{ __(name) }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="option.bars"
|
||||
class="flex flex-col flex-1 gap-[5px]"
|
||||
>
|
||||
<div
|
||||
v-for="bar in 3"
|
||||
:key="bar"
|
||||
class="w-full h-1.5"
|
||||
:class="
|
||||
pane.tone === 'light' ? 'bg-gray-100' : 'bg-gray-800'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +86,7 @@
|
||||
class="rounded-full size-3.5"
|
||||
:class="
|
||||
theme === option.value
|
||||
? 'border-4 border-outline-gray-5'
|
||||
? 'border-4 border-outline-gray-7'
|
||||
: 'border border-outline-gray-4'
|
||||
"
|
||||
/>
|
||||
@@ -151,22 +100,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { __ } from "@/translation";
|
||||
import { useTheme, type Theme } from "frappe-ui";
|
||||
import { useColorScheme, type ColorScheme } from "frappe-ui";
|
||||
import { computed, type Component } from "vue";
|
||||
|
||||
type Tone = "light" | "dark";
|
||||
|
||||
type Frame = {
|
||||
tone: Tone;
|
||||
/** Stretch to fill the frame (light/dark) or natural width (system). */
|
||||
fill: boolean;
|
||||
frameClass: string;
|
||||
type Pane = {
|
||||
tone: "light" | "dark";
|
||||
containerClass: string;
|
||||
screenClass: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Controlled value. When omitted the global frappe-ui theme is used. */
|
||||
modelValue?: Theme;
|
||||
modelValue?: ColorScheme;
|
||||
/** Brand logo shown in the previews: image URL or component. */
|
||||
logo?: string | Component;
|
||||
/** Brand name shown in the previews. */
|
||||
@@ -175,31 +121,25 @@ const props = withDefaults(
|
||||
{ logo: "", name: "" }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [theme: Theme] }>();
|
||||
const emit = defineEmits<{ "update:modelValue": [theme: ColorScheme] }>();
|
||||
|
||||
const { currentTheme, setTheme } = useTheme();
|
||||
|
||||
// Fixed window colors per tone — independent of the active app theme.
|
||||
const tones: Record<Tone, { background: string; bar: string; line: string }> = {
|
||||
light: { background: "#FFFFFF", bar: "#F3F3F3", line: "#EDEDED" },
|
||||
dark: { background: "#171717", bar: "#383838", line: "#383838" },
|
||||
};
|
||||
const { colorScheme, setColorScheme } = useColorScheme();
|
||||
|
||||
const themeOptions: {
|
||||
value: Theme;
|
||||
value: ColorScheme;
|
||||
label: string;
|
||||
panes: Pane[];
|
||||
bars: boolean;
|
||||
frames: Frame[];
|
||||
}[] = [
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
bars: true,
|
||||
frames: [
|
||||
panes: [
|
||||
{
|
||||
tone: "light",
|
||||
fill: true,
|
||||
frameClass: "pl-5 pt-3.5 bg-surface-gray-2 rounded-t-[10.5px]",
|
||||
containerClass: "pl-5 pt-3.5 bg-surface-gray-2 rounded-t-[10.5px]",
|
||||
screenClass: "bg-white rounded-tl-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -207,11 +147,11 @@ const themeOptions: {
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
bars: true,
|
||||
frames: [
|
||||
panes: [
|
||||
{
|
||||
tone: "dark",
|
||||
fill: true,
|
||||
frameClass: "pl-5 pt-3.5 bg-surface-gray-2 rounded-t-[10.5px]",
|
||||
containerClass: "pl-5 pt-3.5 bg-surface-gray-2 rounded-t-[10.5px]",
|
||||
screenClass: "bg-gray-900 rounded-tl-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -219,18 +159,18 @@ const themeOptions: {
|
||||
value: "system",
|
||||
label: "System",
|
||||
bars: false,
|
||||
frames: [
|
||||
panes: [
|
||||
{
|
||||
tone: "light",
|
||||
fill: false,
|
||||
frameClass:
|
||||
"flex flex-1 overflow-hidden pl-5 pt-3.5 bg-surface-gray-2 rounded-tl-[10.5px] max-h-[78px]",
|
||||
containerClass:
|
||||
"flex flex-1 pl-5 pt-3.5 bg-surface-gray-2 rounded-tl-[10.5px]",
|
||||
screenClass: "bg-white rounded-tl-1 w-full",
|
||||
},
|
||||
{
|
||||
tone: "dark",
|
||||
fill: false,
|
||||
frameClass:
|
||||
"flex flex-1 overflow-hidden pl-5 pt-3.5 bg-surface-gray-3 rounded-tr-[10.5px] max-h-[78px]",
|
||||
containerClass:
|
||||
"flex flex-1 pl-5 pt-3.5 bg-surface-gray-3 rounded-tr-[10.5px]",
|
||||
screenClass: "bg-gray-900 rounded-tl-1 w-full",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -240,15 +180,15 @@ const logoIsImage = computed(
|
||||
() => typeof props.logo === "string" && props.logo.length > 0
|
||||
);
|
||||
|
||||
const theme = computed<Theme>({
|
||||
const theme = computed<ColorScheme>({
|
||||
get() {
|
||||
const value = props.modelValue ?? currentTheme.value;
|
||||
const value = props.modelValue ?? colorScheme.value;
|
||||
if (value === "light") return "light";
|
||||
if (value === "dark") return "dark";
|
||||
return "system";
|
||||
},
|
||||
set(value) {
|
||||
setTheme(value);
|
||||
setColorScheme(value);
|
||||
emit("update:modelValue", value);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,31 +23,33 @@
|
||||
/>
|
||||
<div
|
||||
v-if="agentStatusStore.myStatus"
|
||||
class="absolute bottom-0.5 right-0.5 size-3.5 rounded-full outline outline-white outline-4"
|
||||
:class="
|
||||
agentStatusStore.statusColor(agentStatusStore.myStatus)
|
||||
"
|
||||
/>
|
||||
<Tooltip
|
||||
:hoverDelay="0"
|
||||
placement="bottom"
|
||||
:text="profileTooltipText"
|
||||
class="absolute -bottom-0.5 end-0 rounded-full bg-surface-elevation-2 p-1"
|
||||
>
|
||||
<div
|
||||
class="size-3.5 rounded-full"
|
||||
:class="
|
||||
agentStatusStore.statusColor(agentStatusStore.myStatus)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- one Tooltip each: it wires only its first child -->
|
||||
<Tooltip :hoverDelay="0" side="bottom" :text="uploadTooltip">
|
||||
<div
|
||||
class="z-1 absolute top-0 left-0 flex h-9 cursor-pointer items-center justify-center rounded-full !size-16"
|
||||
@click.stop="openFileSelector"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
v-if="user.doc?.user_image"
|
||||
:hoverDelay="0"
|
||||
side="bottom"
|
||||
:text="__('Remove Photo')"
|
||||
>
|
||||
<div
|
||||
v-if="user.doc?.user_image"
|
||||
class="z-1 size-4 absolute -top-1 -right-1 flex cursor-pointer items-center justify-center rounded-full bg-surface-base opacity-0 duration-300 ease-in-out group-hover:opacity-100 hover:bg-surface-gray-2 outline outline-black-overlay-50"
|
||||
class="z-1 size-4 absolute -top-1 -right-1 flex cursor-pointer items-center justify-center rounded-full bg-surface-base opacity-0 duration-300 ease-in-out group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:bg-surface-gray-2 outline outline-black-overlay-50"
|
||||
@click.stop="updateImage()"
|
||||
@mouseenter="isHoveringRemove = true"
|
||||
@mouseleave="isHoveringRemove = false"
|
||||
>
|
||||
<span
|
||||
class="lucide-x size-3.5 cursor-pointer text-ink-gray-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<LucideX class="size-3.5 cursor-pointer text-ink-gray-4" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
@@ -164,6 +166,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideX from "~icons/lucide/x";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -190,13 +193,11 @@ const showChangePasswordModal = ref(false);
|
||||
const { userId, hasAgentRecord } = useAuthStore();
|
||||
const user = createDocumentResource({ doctype: "User", name: userId });
|
||||
|
||||
const isHoveringRemove = ref(false);
|
||||
const editName = ref(false);
|
||||
|
||||
const profileTooltipText = computed(() => {
|
||||
if (isHoveringRemove.value) return __("Remove Photo");
|
||||
return user.doc?.user_image ? __("Change Photo") : __("Upload Photo");
|
||||
});
|
||||
const uploadTooltip = computed(() =>
|
||||
user.doc?.user_image ? __("Change Photo") : __("Upload Photo")
|
||||
);
|
||||
|
||||
const fullNameRef = useTemplateRef("fullNameRef");
|
||||
const fullName = computed({
|
||||
@@ -211,7 +212,7 @@ const fullName = computed({
|
||||
|
||||
function editFullName() {
|
||||
editName.value = true;
|
||||
nextTick(() => fullNameRef.value?.el?.focus());
|
||||
nextTick(() => fullNameRef.value?.focus());
|
||||
}
|
||||
|
||||
const isNameDirty = computed(() => {
|
||||
@@ -234,7 +235,6 @@ function save() {
|
||||
}
|
||||
|
||||
function updateImage(fileUrl = "") {
|
||||
isHoveringRemove.value = false;
|
||||
user.doc.user_image = fileUrl;
|
||||
save();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Update')"
|
||||
:loading="user?.save?.loading"
|
||||
:loading="user.save.loading || saveEmailAccountsResource.loading"
|
||||
@click="update"
|
||||
/></div
|
||||
></Transition>
|
||||
@@ -25,31 +25,16 @@
|
||||
{{ __("Manage your email signature.") }}
|
||||
</span>
|
||||
</div>
|
||||
<Editor
|
||||
:model-value="user?.doc?.email_signature"
|
||||
:placeholder="__('Write your email signature here.')"
|
||||
:extensions="extensions"
|
||||
:upload-function="(file) => uploadFunction(file)"
|
||||
@change="(val) => (user.doc.email_signature = val)"
|
||||
>
|
||||
<template #default="{ editor }">
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="overflow-x-auto hide-scrollbar rounded-t border border-b-0 border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1"
|
||||
>
|
||||
<EditorFixedMenu
|
||||
:editor="editor"
|
||||
:items="textEditorMenuItems"
|
||||
/>
|
||||
</div>
|
||||
<EditorBubbleMenu :editor="editor" :items="articleToolbar" />
|
||||
<EditorContent
|
||||
:editor="editor"
|
||||
class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded-b border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:shadow-sm focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors -mt-0.5 rtl:text-end"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Editor>
|
||||
<div>
|
||||
<CompactEditor
|
||||
v-model="user.doc.email_signature"
|
||||
:upload-fn="
|
||||
(file: any, options: any) =>
|
||||
uploadFunction(file, 'User', userId, false, options)
|
||||
"
|
||||
:placeholder="__('Write your email signature here.')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 mt-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -66,8 +51,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="user.doc.user_emails?.length"
|
||||
class="w-full border rounded-md mb-2 border-outline-elevation-2"
|
||||
v-if="linkedEmails.length"
|
||||
class="w-full border rounded-5 mb-2 border-outline-elevation-2"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-[4fr_4fr_0.3fr] gap-2 px-4 py-3 text-sm-medium text-ink-gray-5 border-b border-outline-elevation-2"
|
||||
@@ -77,48 +62,50 @@
|
||||
<span></span>
|
||||
</div>
|
||||
<div
|
||||
v-for="e in user.doc.user_emails"
|
||||
:key="e.name"
|
||||
v-for="e in linkedEmails"
|
||||
:key="e.email_account"
|
||||
class="grid grid-cols-[4fr_4fr_0.3fr] gap-2 group items-center px-4 py-2.5 text-base border-b border-outline-elevation-2 last:border-b-0"
|
||||
>
|
||||
<span class="text-ink-gray-8 font-medium truncate">
|
||||
{{ e.email_account }}
|
||||
</span>
|
||||
<span class="text-ink-gray-6 truncate">{{ e.email_id }}</span>
|
||||
<div class="group-hover:opacity-100 opacity-0 transition-opacity">
|
||||
<div
|
||||
class="group-hover:opacity-100 opacity-0 transition-opacity [@media(hover:none)]:opacity-100"
|
||||
>
|
||||
<Button
|
||||
class="w-10"
|
||||
variant="ghost"
|
||||
:tooltip="__('Remove')"
|
||||
:aria-label="__('Remove')"
|
||||
icon="lucide-x"
|
||||
@click.prevent="removeEmail(e)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Autocomplete
|
||||
value=""
|
||||
<Combobox
|
||||
:model-value="null"
|
||||
:options="filteredEmails"
|
||||
@change="(e) => addEmail(e)"
|
||||
@update:selected-option="(option) => option && addEmail(option)"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<template #trigger>
|
||||
<Button
|
||||
class="!bg-surface-elevation-2"
|
||||
variant="outline"
|
||||
:label="__('Add Email')"
|
||||
iconLeft="lucide-plus"
|
||||
@click="togglePopover()"
|
||||
/>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<template #item-label="{ item }">
|
||||
<div class="flex flex-col gap-1 text-ink-gray-9">
|
||||
<div>{{ option.label }}</div>
|
||||
<div>{{ item.label }}</div>
|
||||
<div class="text-ink-gray-4 text-sm">
|
||||
{{ option.email }}
|
||||
{{ item.email }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,48 +127,64 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import CompactEditor from "@/components/CompactEditor.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import Autocomplete from "@/components/frappe-ui/Autocomplete.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { __ } from "@/translation";
|
||||
import { textEditorMenuItems } from "@/editor-menu";
|
||||
import {
|
||||
CleanStyles,
|
||||
ComponentUtils,
|
||||
HandleExcelPaste,
|
||||
} from "@/tiptap-extensions";
|
||||
import { normalize, uploadFunction } from "@/utils";
|
||||
import { Button, createDocumentResource, toast } from "frappe-ui";
|
||||
import {
|
||||
Editor,
|
||||
EditorBubbleMenu,
|
||||
EditorContent,
|
||||
EditorFixedMenu,
|
||||
RichTextKit,
|
||||
articleToolbar,
|
||||
} from "frappe-ui/editor";
|
||||
Button,
|
||||
Combobox,
|
||||
createDocumentResource,
|
||||
createResource,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
|
||||
const extensions = [RichTextKit, ComponentUtils, HandleExcelPaste, CleanStyles];
|
||||
const { userId } = useAuthStore();
|
||||
const user = createDocumentResource({ doctype: "User", name: userId });
|
||||
const emit = defineEmits(["updateStep"]);
|
||||
|
||||
const currentUserEmailInfo = getUserEmailInfo();
|
||||
|
||||
// user_emails is System Manager-only, so the User doc never carries it for
|
||||
// agents; the list comes from the email info API and is saved through it
|
||||
const linkedEmails = ref([]);
|
||||
watch(
|
||||
() => currentUserEmailInfo.data?.outgoing_emails,
|
||||
(emails, previousEmails) => {
|
||||
// the info is shared with the composer; a reload from there must not
|
||||
// wipe unsaved edits here
|
||||
if (
|
||||
sortedAccountNames(linkedEmails.value) !==
|
||||
sortedAccountNames(previousEmails)
|
||||
)
|
||||
return;
|
||||
linkedEmails.value = [...(emails || [])];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const saveEmailAccountsResource = createResource({
|
||||
url: "helpdesk.api.auth.set_user_email_accounts",
|
||||
makeParams: () => ({
|
||||
email_accounts: linkedAccountNames(linkedEmails.value),
|
||||
}),
|
||||
});
|
||||
|
||||
const filteredEmails = computed(() => {
|
||||
if (!currentUserEmailInfo.data?.available_emails) return [];
|
||||
const linkedEmails = user.doc.user_emails?.map((e) => e.email_id) || [];
|
||||
const linkedAccounts = linkedAccountNames(linkedEmails.value);
|
||||
return currentUserEmailInfo.data.available_emails
|
||||
.map((doc) => ({
|
||||
label: doc.name,
|
||||
value: doc.name,
|
||||
email: doc.email_id,
|
||||
}))
|
||||
.filter((e) => !linkedEmails.includes(e.email));
|
||||
.filter((e) => !linkedAccounts.includes(e.value));
|
||||
});
|
||||
|
||||
const isSignatureDirty = computed(() => {
|
||||
@@ -192,10 +195,9 @@ const isSignatureDirty = computed(() => {
|
||||
});
|
||||
|
||||
const isUserEmailListDirty = computed(() => {
|
||||
const emailIds = (list = []) => list.map((e) => e.email_id).sort();
|
||||
return (
|
||||
JSON.stringify(emailIds(currentUserEmailInfo.data?.outgoing_emails)) !==
|
||||
JSON.stringify(emailIds(user.doc.user_emails))
|
||||
sortedAccountNames(currentUserEmailInfo.data?.outgoing_emails) !==
|
||||
sortedAccountNames(linkedEmails.value)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -209,28 +211,39 @@ if (isDirty.value) {
|
||||
disableSettingModalOutsideClick.value = false;
|
||||
}
|
||||
|
||||
function linkedAccountNames(list = []) {
|
||||
return list.map((e) => e.email_account);
|
||||
}
|
||||
|
||||
function sortedAccountNames(list) {
|
||||
return linkedAccountNames(list).sort().join();
|
||||
}
|
||||
|
||||
function addEmail(email) {
|
||||
if (!user.doc.user_emails) user.doc.user_emails = [];
|
||||
user.doc.user_emails.push({
|
||||
email_account: email.label,
|
||||
linkedEmails.value.push({
|
||||
email_account: email.value,
|
||||
email_id: email.email,
|
||||
});
|
||||
}
|
||||
|
||||
function removeEmail(email) {
|
||||
user.doc.user_emails = user.doc.user_emails.filter(
|
||||
(e) => e.email_id !== email.email_id
|
||||
linkedEmails.value = linkedEmails.value.filter(
|
||||
(e) => e.email_account !== email.email_account
|
||||
);
|
||||
}
|
||||
|
||||
function update() {
|
||||
user.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
toast.success(__("Email settings updated successfully."));
|
||||
currentUserEmailInfo.reload();
|
||||
user.reload();
|
||||
},
|
||||
});
|
||||
async function update() {
|
||||
// one after the other, since both save the same User doc
|
||||
try {
|
||||
if (isSignatureDirty.value) await user.save.submit();
|
||||
if (isUserEmailListDirty.value) await saveEmailAccountsResource.submit();
|
||||
} catch {
|
||||
return; // the fallback error handler already showed a toast
|
||||
}
|
||||
toast.success(__("Email settings updated successfully."));
|
||||
user.reload();
|
||||
await currentUserEmailInfo.reload();
|
||||
linkedEmails.value = [...currentUserEmailInfo.data.outgoing_emails];
|
||||
}
|
||||
|
||||
watch(isDirty, (val) => {
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import LockKeyhole from "~icons/lucide/lock-keyhole";
|
||||
import { Dialog, toast, createResource } from "frappe-ui";
|
||||
import { createResource, Dialog, Password, toast } from "frappe-ui";
|
||||
import { ref, watch } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
@update:model-value="savedRepliesSearchQuery = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="bg-surface-base hover:bg-surface-base focus:ring-0 border-outline-gray-2"
|
||||
:debounce="300"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -40,40 +39,21 @@
|
||||
class="absolute end-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="filterOptions" placement="right">
|
||||
<Dropdown
|
||||
:options="filterOptions"
|
||||
align="end"
|
||||
@update:open="(open) => open && (isConfirmingDelete = false)"
|
||||
>
|
||||
<template #default="{ open }">
|
||||
<Button
|
||||
:label="activeFilter"
|
||||
class="flex items-center justify-between w-fit p-4"
|
||||
>
|
||||
<Button :label="activeFilterLabel">
|
||||
<template #suffix>
|
||||
<span
|
||||
:class="[
|
||||
'h-4',
|
||||
open ? 'lucide-chevron-up' : 'lucide-chevron-down',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
<component
|
||||
:is="open ? LucideChevronUp : LucideChevronDown"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #item-label="{ item }">
|
||||
<button
|
||||
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded text-base"
|
||||
@click="item.onSelect"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<span class="whitespace-nowrap">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="activeFilter === item.value"
|
||||
class="lucide-check size-4 text-ink-gray-7"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
@@ -90,7 +70,7 @@
|
||||
!savedRepliesListResource?.data?.length
|
||||
"
|
||||
variant="badge"
|
||||
:icon="SavedReplyIcon"
|
||||
:icon="ZapIcon"
|
||||
:title="__('No saved replies found')"
|
||||
:description="__('Add one to get started.')"
|
||||
/>
|
||||
@@ -114,7 +94,7 @@
|
||||
:key="savedReply.name"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded"
|
||||
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4"
|
||||
>
|
||||
<div
|
||||
@click="
|
||||
@@ -154,14 +134,15 @@
|
||||
/>
|
||||
{{ savedReply.scope }}
|
||||
</div>
|
||||
<!-- the confirm state is shared, so each menu clears it on open -->
|
||||
<Dropdown
|
||||
placement="right"
|
||||
align="end"
|
||||
:options="dropdownOptions(savedReply)"
|
||||
@update:open="(open) => open && (isConfirmingDelete = false)"
|
||||
>
|
||||
<Button
|
||||
icon="lucide-more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
class="me-2"
|
||||
/>
|
||||
</Dropdown>
|
||||
@@ -202,6 +183,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideChevronUp from "~icons/lucide/chevron-up";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { __ } from "@/translation";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -211,19 +198,16 @@ import {
|
||||
TextInput,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, inject, ref, Ref, watch } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
|
||||
import { activeFilter } from "./savedReplies";
|
||||
import { useUserStore } from "../../../stores/user";
|
||||
import GlobeIcon from "~icons/lucide/globe";
|
||||
import UserIcon from "~icons/lucide/user";
|
||||
import UsersIcon from "~icons/lucide/users";
|
||||
import GlobeIcon from "~icons/lucide/globe";
|
||||
import { useUserStore } from "../../../stores/user";
|
||||
import { SavedReply, SavedReplyListResourceSymbol } from "../../../types";
|
||||
import SavedReplyIcon from "../../icons/SavedReplyIcon.vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import ZapIcon from "~icons/lucide/zap";
|
||||
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
|
||||
import { activeFilter } from "./savedReplies";
|
||||
|
||||
const { getUser } = useUserStore();
|
||||
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
|
||||
@@ -318,40 +302,27 @@ const duplicate = async () => {
|
||||
};
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
label: __("All"),
|
||||
value: "All",
|
||||
onSelect: () => {
|
||||
applyFilter("All");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Personal"),
|
||||
value: "Personal",
|
||||
onSelect: () => {
|
||||
applyFilter("Personal");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("My Team"),
|
||||
value: "Team",
|
||||
onSelect: () => {
|
||||
applyFilter("Team");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Global"),
|
||||
value: "Global",
|
||||
onSelect: () => {
|
||||
applyFilter("Global");
|
||||
},
|
||||
},
|
||||
const scopes = [
|
||||
{ label: __("All"), value: "All" },
|
||||
{ label: __("Personal"), value: "Personal" },
|
||||
{ label: __("My Team(s)"), value: "Team" },
|
||||
{ label: __("Global"), value: "Global" },
|
||||
];
|
||||
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
|
||||
options.pop();
|
||||
scopes.pop();
|
||||
}
|
||||
return options;
|
||||
return scopes.map((scope) => ({
|
||||
...scope,
|
||||
selected: activeFilter.value === scope.value,
|
||||
onClick: () => applyFilter(scope.value),
|
||||
}));
|
||||
});
|
||||
|
||||
const activeFilterLabel = computed(() => {
|
||||
return (
|
||||
filterOptions.value.find((option) => option.value === activeFilter.value)
|
||||
?.label ?? activeFilter.value
|
||||
);
|
||||
});
|
||||
|
||||
const applyFilter = (scope: string) => {
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
<ErrorMessage class="text-p-sm" :message="errors.title" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<FormLabel :label="__('Scope')" />
|
||||
<Select
|
||||
:label="__('Visibility')"
|
||||
v-model="savedReplyData.scope"
|
||||
:options="scopeDropdownOptions"
|
||||
required
|
||||
@@ -95,6 +95,16 @@
|
||||
<CompactEditor
|
||||
ref="content"
|
||||
v-model="savedReplyData.message"
|
||||
:upload-fn="
|
||||
(file: any, options: any) =>
|
||||
uploadFunction(
|
||||
file,
|
||||
'HD Saved Reply',
|
||||
savedReplyData.name,
|
||||
false,
|
||||
options
|
||||
)
|
||||
"
|
||||
:extensions="[FieldAutocomplete]"
|
||||
:placeholder="
|
||||
__(
|
||||
@@ -105,6 +115,10 @@
|
||||
/>
|
||||
<ErrorMessage class="text-p-sm" :message="errors.message" />
|
||||
</div>
|
||||
<ActionsEditor
|
||||
v-model="savedReplyData.actions"
|
||||
:error="errors.actions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
@@ -118,8 +132,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CompactEditor from "@/components/CompactEditor.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import DocumentationButton from "@/components/DocumentationButton.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { __ } from "@/translation";
|
||||
import { uploadFunction } from "@/utils";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
@@ -131,22 +151,18 @@ import {
|
||||
Select,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { computed, inject, onUnmounted, ref, watch } from "vue";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import { __ } from "@/translation";
|
||||
import PreviewDialog from "./components/PreviewDialog.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import CompactEditor from "@/components/CompactEditor.vue";
|
||||
import DocumentationButton from "@/components/DocumentationButton.vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { FieldAutocomplete } from "../../../tiptap-extensions";
|
||||
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
|
||||
import { computed, inject, onUnmounted, ref, watch } from "vue";
|
||||
import GlobeIcon from "~icons/lucide/globe";
|
||||
import UserIcon from "~icons/lucide/user";
|
||||
import UsersIcon from "~icons/lucide/users";
|
||||
import GlobeIcon from "~icons/lucide/globe";
|
||||
import { FieldAutocomplete } from "../../../tiptap-extensions";
|
||||
import { SavedReply, SavedReplyListResourceSymbol, Team } from "../../../types";
|
||||
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import ActionsEditor from "./components/ActionsEditor.vue";
|
||||
import { actionNeedsValue, isActionValueEmpty } from "./components/actionTypes";
|
||||
import PreviewDialog from "./components/PreviewDialog.vue";
|
||||
|
||||
const showConfirmDialog = ref({
|
||||
show: false,
|
||||
@@ -176,12 +192,14 @@ const savedReplyData = ref({
|
||||
scope: savedRepliesActiveScreen.value.data?.scope || "Personal",
|
||||
message: "",
|
||||
teams: [],
|
||||
actions: [],
|
||||
});
|
||||
const initialData = ref("");
|
||||
const errors = ref({
|
||||
title: "",
|
||||
message: "",
|
||||
teams: "",
|
||||
actions: "",
|
||||
});
|
||||
|
||||
const scopeDropdownOptions = computed(() => {
|
||||
@@ -226,6 +244,7 @@ const getSavedReplyData = createResource({
|
||||
scope: data.scope,
|
||||
message: data.message,
|
||||
teams: data.teams?.map((team) => team.team) || [],
|
||||
actions: JSON.parse(data.actions || "[]"),
|
||||
};
|
||||
initialData.value = JSON.stringify(savedReplyData.value);
|
||||
},
|
||||
@@ -299,10 +318,8 @@ const goBack = () => {
|
||||
const onSave = () => {
|
||||
validateData();
|
||||
|
||||
if (Object.values(errors.value).some((e) => e)) {
|
||||
toast.error(__("Please fill all the required fields"));
|
||||
return;
|
||||
}
|
||||
// Each field shows its own inline error; no toast needed
|
||||
if (Object.values(errors.value).some((e) => e)) return;
|
||||
|
||||
if (savedRepliesActiveScreen.value.data?.name) {
|
||||
updateSavedReply();
|
||||
@@ -320,6 +337,7 @@ const createSavedReply = () => {
|
||||
teams: savedReplyData.value.teams.map((team) => ({
|
||||
team: team,
|
||||
})),
|
||||
actions: savedReplyData.value.actions,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
@@ -359,6 +377,7 @@ const updateSavedReply = async () => {
|
||||
teams: savedReplyData.value.teams.map((team) => ({
|
||||
team: team,
|
||||
})),
|
||||
actions: savedReplyData.value.actions,
|
||||
});
|
||||
|
||||
if (savedReplyData.value.name !== savedReplyData.value.title) {
|
||||
@@ -421,6 +440,16 @@ const validateData = (key?: string) => {
|
||||
}
|
||||
break;
|
||||
|
||||
case "actions": {
|
||||
const hasEmpty = savedReplyData.value.actions.some(
|
||||
(action) =>
|
||||
actionNeedsValue(action.action_type) &&
|
||||
isActionValueEmpty(action.action_type, action.value)
|
||||
);
|
||||
errors.value.actions = hasEmpty ? __("Values can't be empty") : "";
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -437,6 +466,7 @@ const validateData = (key?: string) => {
|
||||
watch(
|
||||
savedReplyData,
|
||||
(newVal) => {
|
||||
if (errors.value.actions) validateData("actions");
|
||||
if (!initialData.value) return;
|
||||
isDirty.value = JSON.stringify(newVal) != initialData.value;
|
||||
if (isDirty.value) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<Select
|
||||
v-if="config.control === 'select'"
|
||||
class="w-full"
|
||||
variant="ghost"
|
||||
:model-value="(modelValue as string)"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="emit('update:modelValue', $event as string)"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="config.control === 'link'"
|
||||
class="w-full"
|
||||
variant="ghost"
|
||||
:doctype="config.doctype!"
|
||||
:filters="linkFilters(type)"
|
||||
:model-value="(modelValue as string)"
|
||||
:placeholder="placeholder"
|
||||
:title="(modelValue as string)"
|
||||
@update:model-value="emit('update:modelValue', ($event ?? '') as string)"
|
||||
/>
|
||||
<Combobox
|
||||
v-else-if="config.control === 'combobox'"
|
||||
class="w-full"
|
||||
variant="ghost"
|
||||
trigger="button"
|
||||
:model-value="(modelValue as string)"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="emit('update:modelValue', $event as string)"
|
||||
>
|
||||
<template v-if="type === 'Assign Agent'" #item-prefix="{ item }">
|
||||
<Avatar size="xs" :image="item.image" :label="item.label" />
|
||||
</template>
|
||||
</Combobox>
|
||||
<MultiSelect
|
||||
v-else-if="config.control === 'multiselect'"
|
||||
class="w-full"
|
||||
variant="ghost"
|
||||
:model-value="(modelValue as string[])"
|
||||
:options="options"
|
||||
:placeholder="__('Select tags')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #item-prefix="{ item }">
|
||||
<span
|
||||
class="size-2.5 shrink-0 rounded-full"
|
||||
:class="colorToken(item.color)"
|
||||
/>
|
||||
</template>
|
||||
<template #summary="{ selectedOptions }">
|
||||
<span
|
||||
v-if="selectedOptions.length"
|
||||
class="flex items-center gap-1.5 truncate"
|
||||
>
|
||||
<TagChip
|
||||
v-for="option in (selectedOptions as ActionOption[]).slice(0, 3)"
|
||||
:key="option.value"
|
||||
:tag="option.label"
|
||||
:color="colorToken(option.color)"
|
||||
/>
|
||||
<span v-if="selectedOptions.length > 3" class="text-ink-gray-5">
|
||||
+{{ selectedOptions.length - 3 }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>{{ __("Select tags") }}</span>
|
||||
</template>
|
||||
<!-- Custom summary renders its own dots; hide the default prefix so it adds no gap -->
|
||||
<template #prefix>
|
||||
<span class="hidden" />
|
||||
</template>
|
||||
</MultiSelect>
|
||||
<Editor
|
||||
v-else-if="config.control === 'editor'"
|
||||
class="w-full"
|
||||
:model-value="(modelValue as string)"
|
||||
:extensions="editorExtensions"
|
||||
:placeholder="__('Internal note. Type / for commands')"
|
||||
@update:model-value="emit('update:modelValue', $event ?? '')"
|
||||
>
|
||||
<template #default>
|
||||
<EditorContent
|
||||
class="prose prose-v3 [--prose-font-size:14px] min-h-8 max-h-30 w-full max-w-full overflow-auto rounded-4 bg-surface-gray-1 px-2 py-1.5 text-ink-gray-7 focus:ring-0"
|
||||
/>
|
||||
</template>
|
||||
</Editor>
|
||||
<span
|
||||
v-else-if="config.hint"
|
||||
class="flex items-center px-2 text-base text-ink-gray-7 cursor-default"
|
||||
>
|
||||
{{ config.hint }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { buildEditorExtensions } from "@/components/editor/config";
|
||||
import TagChip from "@/components/tag/TagChip.vue";
|
||||
import { useSavedReplyActionOptions } from "@/composables/useSavedReplyActionOptions";
|
||||
import { colorToken } from "@/composables/useTags";
|
||||
import { __ } from "@/translation";
|
||||
import { SavedReplyActionType } from "@/types";
|
||||
import { Link } from "@framework/ui";
|
||||
import { Avatar, Combobox, MultiSelect, Select } from "frappe-ui";
|
||||
import { Editor, EditorContent } from "frappe-ui/editor";
|
||||
import { computed } from "vue";
|
||||
import { ACTION_TYPES } from "./actionTypes";
|
||||
|
||||
const props = defineProps<{
|
||||
type: SavedReplyActionType;
|
||||
modelValue: string | string[];
|
||||
options: ActionOption[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string | string[]];
|
||||
}>();
|
||||
|
||||
const { linkFilters } = useSavedReplyActionOptions();
|
||||
|
||||
const config = computed(() => ACTION_TYPES[props.type]);
|
||||
const placeholder = computed(() =>
|
||||
__("Select {0}", (config.value.fieldname || __("a value")).toLowerCase())
|
||||
);
|
||||
// Slash commands ship with RichTextKit, so "/" works without a toolbar.
|
||||
const editorExtensions = buildEditorExtensions();
|
||||
</script>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-md-medium text-ink-gray-8">{{ __("Actions") }}</span>
|
||||
<span class="text-p-base text-ink-gray-6">
|
||||
{{ __("Applied to the ticket when this reply is sent") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-5 border border-outline-gray-2 px-1 text-sm">
|
||||
<template v-if="rows.length">
|
||||
<div
|
||||
class="grid items-center gap-6 p-2"
|
||||
:style="{ gridTemplateColumns }"
|
||||
>
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="ms-2 overflow-hidden text-ellipsis whitespace-nowrap text-ink-gray-5"
|
||||
>
|
||||
{{ column.label }}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<template v-for="(row, index) in rows" :key="index">
|
||||
<!-- Cells centre inside one line's height, so a row centres while it
|
||||
fits on one line and pins to the first line once it grows -->
|
||||
<div
|
||||
class="grid items-start gap-6 p-2"
|
||||
:style="{ gridTemplateColumns }"
|
||||
>
|
||||
<div class="flex min-h-8 items-center">
|
||||
<Combobox
|
||||
class="w-full"
|
||||
variant="ghost"
|
||||
trigger="button"
|
||||
:model-value="row.action_type"
|
||||
:options="typeOptions(row)"
|
||||
:placeholder="__('Select action')"
|
||||
@update:model-value="
|
||||
setActionType(row, $event as SavedReplyActionType)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-h-8 items-center">
|
||||
<ActionValue
|
||||
:type="row.action_type"
|
||||
:model-value="row.value"
|
||||
:options="valueOptions(row.action_type)"
|
||||
@update:model-value="row.value = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-h-8 items-center justify-end">
|
||||
<Dropdown
|
||||
align="end"
|
||||
:options="[
|
||||
{
|
||||
label: __('Delete'),
|
||||
icon: 'lucide-trash-2',
|
||||
onClick: () => rows.splice(index, 1),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="lucide-more-horizontal"
|
||||
:label="__('Row actions')"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="index !== rows.length - 1" />
|
||||
</template>
|
||||
</template>
|
||||
<div v-else class="p-4 text-center text-ink-gray-5">
|
||||
{{
|
||||
__(
|
||||
"No actions yet. Add one to update the ticket when this reply is sent"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-center justify-between">
|
||||
<Dropdown align="start" :options="addOptions">
|
||||
<Button
|
||||
variant="subtle"
|
||||
icon-left="lucide-plus"
|
||||
:label="__('Add action')"
|
||||
:disabled="!addOptions.length"
|
||||
/>
|
||||
</Dropdown>
|
||||
<ErrorMessage :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSavedReplyActionOptions } from "@/composables/useSavedReplyActionOptions";
|
||||
import { __ } from "@/translation";
|
||||
import { SavedReplyAction, SavedReplyActionType } from "@/types";
|
||||
import { getGridTemplateColumnsForTable } from "@/utils";
|
||||
import { Button, Combobox, Dropdown } from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import ActionValue from "./ActionValue.vue";
|
||||
import {
|
||||
ACTION_MENU_GROUPS,
|
||||
ACTION_TYPES,
|
||||
ASSIGNMENT_ACTIONS,
|
||||
isTagAction,
|
||||
type ActionOption,
|
||||
} from "./actionTypes";
|
||||
|
||||
/** One grid row. Tag rows hold every selected tag; the model stores one per tag. */
|
||||
type ActionRow = {
|
||||
action_type: SavedReplyActionType;
|
||||
value: string | string[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
const actions = defineModel<SavedReplyAction[]>({ required: true });
|
||||
|
||||
const { valueOptions: sharedValueOptions, tagOptions } =
|
||||
useSavedReplyActionOptions();
|
||||
|
||||
const columns = [
|
||||
{ key: "action_type", label: __("Action"), width: "190px" },
|
||||
{ key: "value", label: __("Value") },
|
||||
];
|
||||
|
||||
// Trailing 22px column holds the remove button
|
||||
const gridTemplateColumns = computed(() =>
|
||||
getGridTemplateColumnsForTable(columns)
|
||||
);
|
||||
|
||||
// The table edits rows in place, so it needs a real ref; the flat model is derived.
|
||||
const rows = ref<ActionRow[]>(toRows(actions.value));
|
||||
|
||||
/** Types still free, ignoring the row being changed: no duplicates, one assignment. */
|
||||
function typeOptions(row?: ActionRow) {
|
||||
const used = new Set(
|
||||
rows.value
|
||||
.filter((other) => other !== row)
|
||||
.map((other) => other.action_type)
|
||||
);
|
||||
const hasAssignment = ASSIGNMENT_ACTIONS.some((type) => used.has(type));
|
||||
return ACTION_MENU_GROUPS.map((group) => ({
|
||||
group: group.label,
|
||||
options: group.types
|
||||
.filter(
|
||||
(type) =>
|
||||
!used.has(type) &&
|
||||
!(hasAssignment && ASSIGNMENT_ACTIONS.includes(type))
|
||||
)
|
||||
.map((type) => ({
|
||||
label: ACTION_TYPES[type].label,
|
||||
value: type,
|
||||
icon: ACTION_TYPES[type].icon,
|
||||
})),
|
||||
})).filter((group) => group.options.length);
|
||||
}
|
||||
|
||||
/** The add button picks the type up front, so a row is never typeless. */
|
||||
const addOptions = computed(() =>
|
||||
typeOptions().map((group) => ({
|
||||
group: group.group,
|
||||
options: group.options.map((option) => ({
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
onClick: () => addAction(option.value),
|
||||
})),
|
||||
}))
|
||||
);
|
||||
|
||||
function addAction(type: SavedReplyActionType) {
|
||||
rows.value.push({ action_type: type, value: initialValue(type) });
|
||||
}
|
||||
|
||||
/** A value picked for the old type means nothing to the new one. */
|
||||
function setActionType(row: ActionRow, type: SavedReplyActionType) {
|
||||
row.action_type = type;
|
||||
row.value = initialValue(type);
|
||||
}
|
||||
|
||||
/** Tag rows hold a list of tags, every other row a single value. */
|
||||
function initialValue(type: SavedReplyActionType): string | string[] {
|
||||
return isTagAction(type) ? [] : "";
|
||||
}
|
||||
|
||||
/** Shared lists, minus the tags the opposite row already claimed. */
|
||||
function valueOptions(type: SavedReplyActionType): ActionOption[] {
|
||||
if (!isTagAction(type)) return sharedValueOptions(type);
|
||||
// A tag can't be both added and removed by the same reply
|
||||
const sibling = type === "Add Tag" ? "Remove Tag" : "Add Tag";
|
||||
const taken = new Set(
|
||||
rows.value
|
||||
.filter((row) => row.action_type === sibling)
|
||||
.flatMap((row) => row.value as string[])
|
||||
);
|
||||
return tagOptions().filter((tag) => !taken.has(String(tag.value)));
|
||||
}
|
||||
|
||||
function toRows(list: SavedReplyAction[]): ActionRow[] {
|
||||
const grouped: ActionRow[] = [];
|
||||
for (const action of list) {
|
||||
const tagRow = isTagAction(action.action_type)
|
||||
? grouped.find((row) => row.action_type === action.action_type)
|
||||
: undefined;
|
||||
if (tagRow) (tagRow.value as string[]).push(action.value);
|
||||
else
|
||||
grouped.push({
|
||||
action_type: action.action_type,
|
||||
value: isTagAction(action.action_type) ? [action.value] : action.value,
|
||||
});
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/** A tag row with nothing picked simply doesn't persist. */
|
||||
function toActions(list: ActionRow[]): SavedReplyAction[] {
|
||||
return list.flatMap((row) => {
|
||||
const type = row.action_type;
|
||||
if (Array.isArray(row.value))
|
||||
return row.value.map((value) => ({ action_type: type, value }));
|
||||
return [{ action_type: type, value: row.value }];
|
||||
});
|
||||
}
|
||||
|
||||
watch(rows, () => (actions.value = toActions(rows.value)), { deep: true });
|
||||
|
||||
// Reseed only on a genuine outside change (the saved reply loading), never on
|
||||
// our own write-back above.
|
||||
watch(actions, (list) => {
|
||||
if (JSON.stringify(list) !== JSON.stringify(toActions(rows.value)))
|
||||
rows.value = toRows(list);
|
||||
});
|
||||
</script>
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="props.items.length > 0"
|
||||
class="max-h-[300px] min-w-40 overflow-y-auto rounded-lg bg-surface-base p-1 text-base shadow-lg pointer-events-auto hide-scrollbar"
|
||||
data-suggestion-popup
|
||||
class="max-h-[300px] min-w-40 overflow-y-auto rounded-6 bg-surface-base p-1 text-base shadow-lg pointer-events-auto hide-scrollbar"
|
||||
>
|
||||
<button
|
||||
v-for="(item, index) in props.items"
|
||||
@@ -12,7 +13,7 @@
|
||||
}
|
||||
"
|
||||
:class="[
|
||||
'flex w-full items-center whitespace-nowrap rounded-md px-2 py-1.5 text-sm text-ink-gray-9',
|
||||
'flex w-full items-center whitespace-nowrap rounded-5 px-2 py-1.5 text-sm text-ink-gray-9',
|
||||
index === selectedIndex ? 'bg-surface-gray-2' : '',
|
||||
]"
|
||||
@click.stop.prevent="selectItem(index)"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user