Compare commits
1046 Commits
9041101505
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebcf9d5042 | ||
|
|
8ac7eb5a29 | ||
|
|
8bb7a61f56 | ||
|
|
ba99a48c88 | ||
| 37d408f762 | |||
| 6d7c91ceeb | |||
|
|
5933a59a14 | ||
|
|
5c3834cbbe | ||
|
|
c77fdf55b3 | ||
|
|
2c2e8ca112 | ||
|
|
4771ebbcfd | ||
|
|
08fbcc963d | ||
|
|
54e9396fdb | ||
|
|
315ec3d655 | ||
|
|
484c3d7402 | ||
|
|
e7ce850691 | ||
|
|
62b5715b98 | ||
|
|
593c70affb | ||
|
|
3a1a7db386 | ||
|
|
a5e948bba8 | ||
|
|
e63d83beb5 | ||
|
|
1ea8705552 | ||
|
|
61193b71f4 | ||
|
|
2331ddfc67 | ||
|
|
afe9674a6a | ||
|
|
5b22ef46c0 | ||
|
|
8f1604e237 | ||
|
|
a9f4eb1291 | ||
|
|
26301c26e9 | ||
|
|
63321fe2c8 | ||
|
|
68848fc642 | ||
|
|
aa7ec019bc | ||
|
|
eb33155db2 | ||
|
|
3088b14d83 | ||
|
|
bf89f3ba2f | ||
|
|
2198adf902 | ||
|
|
c5145c6c24 | ||
|
|
499bcd5281 | ||
|
|
dc4bbdaa55 | ||
|
|
bf19ebd3a8 | ||
|
|
fa7e59b4ad | ||
|
|
5fcd3ddabe | ||
|
|
a9dd43d0ea | ||
|
|
22e005f19c | ||
|
|
5a6a7ff646 | ||
|
|
b3c8fbd833 | ||
|
|
f828c76a0f | ||
|
|
2634a4e316 | ||
|
|
fb0517caa0 | ||
|
|
90151be166 | ||
|
|
9b0a7f5fa5 | ||
|
|
aa93375e6c | ||
|
|
e8edf33be6 | ||
|
|
619f02a74b | ||
|
|
b77c4867e1 | ||
|
|
c1260edb00 | ||
|
|
41d5ef5fd5 | ||
|
|
14937fd4fc | ||
|
|
58826fe30f | ||
|
|
0da9eec0af | ||
|
|
bb47fd5ba9 | ||
|
|
db49cb2d64 | ||
|
|
58732148e2 | ||
|
|
08eb7ef17b | ||
|
|
8bda7edb7b | ||
|
|
49d596216d | ||
|
|
faa9c94970 | ||
|
|
c596d1e215 | ||
|
|
235958e432 | ||
|
|
7f85dbccec | ||
|
|
5b69ddf9b5 | ||
|
|
dfb7152aa3 | ||
|
|
2a311bfb6f | ||
|
|
0e90627144 | ||
|
|
aac1692058 | ||
|
|
d58d362c72 | ||
|
|
e7f2386d14 | ||
|
|
79a50d2454 | ||
|
|
936f82c477 | ||
|
|
133037698c | ||
|
|
07c58251a1 | ||
|
|
c88d36df1e | ||
|
|
08373dc2ab | ||
|
|
44ca59c64a | ||
|
|
c961923fa0 | ||
|
|
72cee75474 | ||
|
|
cb3af6fa63 | ||
|
|
0ff14a959d | ||
|
|
35adf49015 | ||
|
|
e5f0d55ff0 | ||
|
|
ba395fe982 | ||
|
|
8ab6776fa9 | ||
|
|
61d13aeb12 | ||
|
|
24bfe69985 | ||
|
|
7b2a4fe24a | ||
|
|
6f86b822bf | ||
|
|
c3b2907ebf | ||
|
|
48c5b82c73 | ||
|
|
af273a9a1c | ||
|
|
3b80ccd8db | ||
|
|
6484d551d1 | ||
|
|
719d7b5e88 | ||
|
|
3cd9d89f0b | ||
|
|
6e44da1993 | ||
|
|
44b7a210ce | ||
|
|
0e382f77ef | ||
|
|
7a1a247113 | ||
|
|
2c6ab3c331 | ||
|
|
79dba165c5 | ||
|
|
d83f3464cd | ||
|
|
e2ef8f732d | ||
|
|
919904a7f1 | ||
|
|
8453226f29 | ||
|
|
03759ca3c3 | ||
|
|
c1608f8cc4 | ||
|
|
73b20653f0 | ||
|
|
7e683f8b44 | ||
|
|
eba1815390 | ||
|
|
7564f0418b | ||
|
|
7e9cca2782 | ||
|
|
dbc7e7d6d4 | ||
|
|
ae25cfae6e | ||
|
|
970635430b | ||
|
|
fe869a5988 | ||
|
|
7ea8040790 | ||
|
|
9f6f717585 | ||
|
|
641d729bd1 | ||
|
|
ee73d8db86 | ||
|
|
c7b5f9a04d | ||
|
|
fa4c3a8ad7 | ||
|
|
71318bff04 | ||
|
|
186ddc93c8 | ||
|
|
2f1d9a8690 | ||
|
|
5fc7c52bfe | ||
|
|
d0da6e7401 | ||
|
|
a437c197a5 | ||
|
|
944fd6d013 | ||
|
|
80a9f2abe2 | ||
|
|
c0298f0a70 | ||
|
|
c30b21e5ae | ||
|
|
3e3afa63c2 | ||
|
|
7ef8aad2c8 | ||
|
|
c00cb100a9 | ||
|
|
f824ac3c28 | ||
|
|
2dea096fa0 | ||
|
|
f1853a3c97 | ||
|
|
4995f8e3fd | ||
|
|
f59eecd34e | ||
|
|
560ac8d5c4 | ||
|
|
eab929da47 | ||
|
|
d370ca796f | ||
|
|
e9f0b12550 | ||
|
|
a4035168be | ||
|
|
70872857d1 | ||
|
|
332334b556 | ||
|
|
1d91baa9c5 | ||
|
|
1e8040ef7b | ||
|
|
ad6e0a3b80 | ||
|
|
8f6810923d | ||
|
|
990db83ab3 | ||
|
|
01f08ba449 | ||
|
|
7a3701cc10 | ||
|
|
f021ddd84c | ||
|
|
0e3157c57e | ||
|
|
22eb8b9f3f | ||
|
|
9609398643 | ||
|
|
cd0d4c413d | ||
|
|
1bbdff9aaf | ||
|
|
8754d0498c | ||
|
|
395ac52740 | ||
|
|
29cdbe5b8b | ||
|
|
0677c21dc7 | ||
|
|
1a58e2669f | ||
|
|
3fa27024f9 | ||
|
|
04c4069c75 | ||
|
|
dd77b01ff1 | ||
|
|
085614bca6 | ||
|
|
ef2606c41a | ||
|
|
1d95361587 | ||
|
|
6ead16edf0 | ||
|
|
31d21bf689 | ||
|
|
c5ee140551 | ||
|
|
8e97b2f5bb | ||
|
|
19171a8019 | ||
|
|
3f49cf0c9c | ||
|
|
8f9cc536e2 | ||
|
|
573bc74a41 | ||
|
|
bb2552b30c | ||
|
|
20ac312f57 | ||
|
|
f58842438b | ||
|
|
a34f99ed49 | ||
|
|
44b7243f75 | ||
|
|
d2f7d80114 | ||
|
|
192b246381 | ||
|
|
17d9a3991e | ||
|
|
3407a02046 | ||
|
|
be3546e79c | ||
|
|
556067de7a | ||
|
|
4ce08af516 | ||
|
|
8a4477a01f | ||
|
|
21d868a355 | ||
|
|
68a2cc1003 | ||
|
|
5a288836e0 | ||
|
|
4eab5e2867 | ||
|
|
6082093fb6 | ||
|
|
66c26c2a2c | ||
|
|
f7eaf3faaa | ||
|
|
82a43b4f24 | ||
|
|
5bd33a1536 | ||
|
|
a063c0735c | ||
|
|
732db8290d | ||
|
|
2b1d57f2bc | ||
|
|
c8c051c1de | ||
|
|
13139bc2de | ||
|
|
9814abf55f | ||
|
|
249ecb8c4c | ||
|
|
582540e7f0 | ||
|
|
2f3fa7c295 | ||
|
|
3b49aac1b3 | ||
|
|
dc25b408e6 | ||
|
|
c8d9b97ab7 | ||
|
|
754d3cf2ca | ||
|
|
e4268d0437 | ||
|
|
8febe21aa8 | ||
|
|
5384b26610 | ||
|
|
2a4650e5ed | ||
|
|
737993c543 | ||
|
|
58b49e3608 | ||
|
|
27553464d6 | ||
|
|
be76268c70 | ||
|
|
df2f2e6603 | ||
|
|
fb1e1ec2e4 | ||
|
|
ac81d1817b | ||
|
|
da33e1d3bd | ||
|
|
24a511f48e | ||
|
|
14e669435f | ||
|
|
0407f01016 | ||
|
|
2a63f781ac | ||
|
|
3e7555264f | ||
|
|
ab155ae609 | ||
|
|
2c60521894 | ||
|
|
4fd3e2549a | ||
|
|
6d3f7ef3c1 | ||
|
|
10a9a5230e | ||
|
|
bfda88dfd2 | ||
|
|
4be34848a4 | ||
|
|
c51eae5665 | ||
|
|
6c06a86af4 | ||
|
|
8be53d9050 | ||
|
|
015f903d68 | ||
|
|
dc06ad6b22 | ||
|
|
5689fbb455 | ||
|
|
62631daafb | ||
|
|
a9a322c9af | ||
|
|
933bc58264 | ||
|
|
8896a79c09 | ||
|
|
3170c066dc | ||
|
|
7360780022 | ||
|
|
7fe398fc66 | ||
|
|
5970540a99 | ||
|
|
a882432702 | ||
|
|
f8b6dfc981 | ||
|
|
486e2b4a37 | ||
|
|
d8891b8a7d | ||
|
|
98c5318b66 | ||
|
|
a353635bb9 | ||
|
|
9eeb948f04 | ||
|
|
383850aeb8 | ||
|
|
fb0499acc4 | ||
|
|
ff978f5c29 | ||
|
|
e8768d5687 | ||
|
|
fa1b583968 | ||
|
|
b50d584a5b | ||
|
|
5271709094 | ||
|
|
b66b603af2 | ||
|
|
53c77f9070 | ||
|
|
49ed082831 | ||
|
|
05d21cf817 | ||
|
|
39473f0037 | ||
|
|
0e8b232ef1 | ||
|
|
aeb2724e82 | ||
|
|
b429fb2e47 | ||
|
|
14a1c2ac07 | ||
|
|
36b0594960 | ||
|
|
cc3cb7ac8d | ||
|
|
8bf896f766 | ||
|
|
9e61982dcb | ||
|
|
59e40d5a83 | ||
|
|
412bdeb085 | ||
|
|
4ef0bd30d9 | ||
|
|
87e1fa8d6c | ||
|
|
984ea27d59 | ||
|
|
9d2c7a7ce0 | ||
|
|
3b2768cdcc | ||
|
|
9ef5f01a80 | ||
|
|
fe492d1134 | ||
|
|
3a15e132c2 | ||
|
|
2fb20e2db1 | ||
|
|
1fd9b4e626 | ||
|
|
11668235c2 | ||
|
|
d349af940a | ||
|
|
7eadb6b683 | ||
|
|
904b6c3462 | ||
|
|
733ab34007 | ||
|
|
6215817954 | ||
|
|
1760b01914 | ||
|
|
d218a4773a | ||
|
|
6fbe32cacd | ||
|
|
c10dd9f5b6 | ||
|
|
7eb1237aa0 | ||
|
|
570e8eaf34 | ||
|
|
297ffa5171 | ||
|
|
355c752ec1 | ||
|
|
f1031c49a9 | ||
|
|
043c7902a3 | ||
|
|
338f46ac17 | ||
|
|
9987ea1db6 | ||
|
|
d507479bda | ||
|
|
afbdb46fe8 | ||
|
|
1308101fe6 | ||
|
|
c2d6e160d9 | ||
|
|
28c88ec519 | ||
|
|
f77eeafb9e | ||
|
|
92b8343db3 | ||
|
|
ac44cdc303 | ||
|
|
75dcea4c7e | ||
|
|
ee03d75b5f | ||
|
|
31f372629a | ||
|
|
1b30f476e8 | ||
|
|
e4738e9a50 | ||
|
|
893f9d34fd | ||
|
|
2c17a03d36 | ||
|
|
74cbe1bb47 | ||
|
|
88c468068b | ||
|
|
6b5e269564 | ||
|
|
97d9460157 | ||
|
|
0d614a5919 | ||
|
|
3f6afbf1ff | ||
|
|
6d0996ebfd | ||
|
|
14e085c826 | ||
|
|
53bf2f1fe8 | ||
|
|
b06e199432 | ||
|
|
06e1ec38ad | ||
|
|
3063236bfb | ||
|
|
e3a2eb382d | ||
|
|
566ef576f5 | ||
|
|
80ad2da206 | ||
|
|
5d720031b6 | ||
|
|
ac719b9e8e | ||
|
|
db9ebb1d86 | ||
|
|
37f6d62101 | ||
|
|
f7a0fc8b5f | ||
|
|
0fbe015607 | ||
|
|
61909c8498 | ||
|
|
f297f2630b | ||
|
|
9425f2aa0e | ||
|
|
535bb60d69 | ||
|
|
5175050ed6 | ||
|
|
66486a5a1c | ||
|
|
2d4bf49ab9 | ||
|
|
50a091a7b0 | ||
|
|
854ebee2e6 | ||
|
|
c829ab65d9 | ||
|
|
1e0e10ca59 | ||
|
|
a8cdc76278 | ||
|
|
c310460727 | ||
|
|
3e98d962aa | ||
|
|
ad90b89d25 | ||
|
|
92eac9634a | ||
|
|
96015246bd | ||
|
|
0883aedc1d | ||
|
|
c97a5e813c | ||
|
|
2abc243b88 | ||
|
|
62b18e6ffa | ||
|
|
36d813b90f | ||
|
|
5ce8e8c4ff | ||
|
|
3b88892905 | ||
|
|
fe1aa3dd40 | ||
|
|
ec5e197716 | ||
|
|
244b5e445c | ||
|
|
0f927071c0 | ||
|
|
376de99ef7 | ||
|
|
7a649957dd | ||
|
|
0968c90717 | ||
|
|
e22eca9888 | ||
|
|
5890885475 | ||
|
|
4a012a99a4 | ||
|
|
e2c0355821 | ||
|
|
bcf27b7150 | ||
|
|
078f18d99c | ||
|
|
19258e263d | ||
|
|
7a52a2bf46 | ||
|
|
a03be5ab4d | ||
|
|
de13c5ddfb | ||
|
|
02564b2e77 | ||
|
|
201e0b96a3 | ||
|
|
e7ccf0a711 | ||
|
|
c59be28a26 | ||
|
|
a0ede1dd2a | ||
|
|
0aeada4549 | ||
|
|
6d988eb2b4 | ||
|
|
b58d04c7dc | ||
|
|
e2479cd787 | ||
|
|
ca30ab8a5e | ||
|
|
da87845a4c | ||
|
|
e7c2ec6965 | ||
|
|
a7bcc53e0a | ||
|
|
6a5978fed6 | ||
|
|
8ff339b7ed | ||
|
|
4ace8b2ec0 | ||
|
|
40c917f255 | ||
|
|
bf024af8aa | ||
|
|
675caa380f | ||
|
|
ad092a71d5 | ||
|
|
971fe8fe64 | ||
|
|
fa63a1d5e5 | ||
|
|
ece642796a | ||
|
|
ce8f1a5b77 | ||
|
|
973630059f | ||
|
|
0f7e9d2d95 | ||
|
|
a7f494f4d8 | ||
|
|
eb37bd1106 | ||
|
|
02c1f4c19e | ||
|
|
00040061f6 | ||
|
|
cf6292c2c6 | ||
|
|
0cf9984b6b | ||
|
|
e793afd063 | ||
|
|
6ce655c5b6 | ||
|
|
dd7eb9a9c8 | ||
|
|
bd8baa6671 | ||
|
|
d387c7b493 | ||
|
|
c7a991e1b0 | ||
|
|
f7e8707220 | ||
|
|
27cbc265e2 | ||
|
|
949ea333eb | ||
|
|
d32a80ef85 | ||
|
|
d0ee3fe2d0 | ||
|
|
d4e3676032 | ||
|
|
0265c9dc4c | ||
|
|
4c81eef80d | ||
|
|
57c7a8d85c | ||
|
|
775d5ecf3a | ||
|
|
60fcf0472e | ||
|
|
a09599ec8a | ||
|
|
fb5d79fc77 | ||
|
|
8ad5288226 | ||
|
|
65b18641b5 | ||
|
|
78494b9963 | ||
|
|
1d83402163 | ||
|
|
64bdd85b03 | ||
|
|
0f1b6f3eeb | ||
|
|
566711df22 | ||
|
|
c9c6aef466 | ||
|
|
ad650c73c1 | ||
|
|
af5bc2b071 | ||
|
|
b5fcfb62de | ||
|
|
7dccef6b10 | ||
|
|
e1d343528d | ||
|
|
6eeb688f06 | ||
|
|
86e3794d00 | ||
|
|
45e98b9ddc | ||
|
|
aad875f72c | ||
|
|
1698bf0bca | ||
|
|
ea94813d94 | ||
|
|
142b893f2c | ||
|
|
f88dd84ed3 | ||
|
|
50660f6720 | ||
|
|
958128060c | ||
|
|
b2b1d2bb00 | ||
|
|
a3069bd760 | ||
|
|
f39ee39452 | ||
|
|
a43e90e2d0 | ||
|
|
2aa3765ed3 | ||
|
|
45dae0b9d3 | ||
|
|
b1789cdcba | ||
|
|
e21f0e3a7f | ||
|
|
ea9975db2c | ||
|
|
b1c8e01bf5 | ||
|
|
1d19389fc0 | ||
|
|
dbd3e17b26 | ||
|
|
b17c7ca2db | ||
|
|
e17af04c9a | ||
|
|
638a9abf88 | ||
|
|
89f9cbd30d | ||
|
|
204fb669c0 | ||
|
|
7a24a83d9e | ||
|
|
edc6007fb6 | ||
|
|
27a36540d4 | ||
|
|
6dd1274150 | ||
|
|
6a80c2ab38 | ||
|
|
8633444b91 | ||
|
|
cb014a9507 | ||
|
|
c241abb820 | ||
|
|
a497a2d838 | ||
|
|
5e78848d38 | ||
|
|
adf897cc08 | ||
|
|
5053b4e45f | ||
|
|
3151854bfd | ||
|
|
db5868f69a | ||
|
|
9b6f7635bc | ||
|
|
55ff59095c | ||
|
|
3dce1c0930 | ||
|
|
fd33aa8b70 | ||
|
|
05170da762 | ||
|
|
64f0c693d2 | ||
|
|
aac1e2d01f | ||
|
|
5472c4d387 | ||
|
|
39a31a0baf | ||
|
|
cffc740ed3 | ||
|
|
114f3aae6d | ||
|
|
fea7f8f9ae | ||
|
|
830f513a06 | ||
|
|
e76ff45241 | ||
|
|
66f19d06b0 | ||
|
|
987b655976 | ||
|
|
8619712d20 | ||
|
|
bc6ca205d5 | ||
|
|
48d17e88d9 | ||
|
|
1e9f1b7661 | ||
|
|
faa6fbf68e | ||
|
|
789b1016fb | ||
|
|
8fb491ac18 | ||
|
|
3eea872137 | ||
|
|
e4d98019e7 | ||
|
|
7847f681e9 | ||
|
|
26d7971d99 | ||
|
|
e25ef7e07b | ||
|
|
85fafe7d56 | ||
|
|
fdced5c204 | ||
|
|
b21fe69123 | ||
|
|
4572d03470 | ||
|
|
d348fd9f99 | ||
|
|
4b6e91e81f | ||
|
|
9126e7cd27 | ||
|
|
713ee5287f | ||
|
|
79109f1265 | ||
|
|
20f16849bf | ||
|
|
99968f5961 | ||
|
|
0741fbf583 | ||
|
|
784ed37de0 | ||
|
|
1c29b4966c | ||
|
|
a06ea92c8e | ||
|
|
60a47889d2 | ||
|
|
d758039b2c | ||
|
|
4b507f0706 | ||
|
|
5986838056 | ||
|
|
e02c99bd16 | ||
|
|
a3f580a9fb | ||
|
|
43fb0f92df | ||
|
|
dd4fbfa8a2 | ||
|
|
cf2e57ec40 | ||
|
|
daf2d28f3a | ||
|
|
d5e48f9502 | ||
|
|
3318a1c599 | ||
|
|
3fa2320fe3 | ||
|
|
c0c8fb5bf3 | ||
|
|
acde5ad1d1 | ||
|
|
0a9b18d04d | ||
|
|
3013372711 | ||
|
|
76979e292d | ||
|
|
115d52776f | ||
|
|
f1b392ac9b | ||
|
|
00198464e9 | ||
|
|
1b84f00673 | ||
|
|
3d9850dc73 | ||
|
|
dfcf295493 | ||
|
|
b987bf7f20 | ||
|
|
e1d160a898 | ||
|
|
a3fb63cd08 | ||
|
|
1faa697b6c | ||
|
|
01094cd10a | ||
|
|
4141022431 | ||
|
|
69d0efbfa7 | ||
|
|
66cc7d392e | ||
|
|
8048cb47c5 | ||
|
|
09c668f7ed | ||
|
|
984a63c46a | ||
|
|
80c978d265 | ||
|
|
cde6828c1f | ||
|
|
51c1b816a1 | ||
|
|
356ce7478c | ||
|
|
2210ef7af7 | ||
|
|
0884e1315b | ||
|
|
58b26b6e32 | ||
|
|
2631681c1d | ||
|
|
82d6284b06 | ||
|
|
46d13d65c1 | ||
|
|
9da6cff8a5 | ||
|
|
dc724831c3 | ||
|
|
776730447a | ||
|
|
82c6fdf475 | ||
|
|
ca2b175e1c | ||
|
|
ec7e250f96 | ||
|
|
126570fcca | ||
|
|
a7bbb7f150 | ||
|
|
4345ff18bd | ||
|
|
26409b0336 | ||
|
|
7653b64353 | ||
|
|
32dd5832b8 | ||
|
|
59072c3580 | ||
|
|
d121cd3526 | ||
|
|
cfbac5f2c4 | ||
|
|
371c72b96c | ||
|
|
55a02004bd | ||
|
|
b3119f5295 | ||
|
|
f57b64531c | ||
|
|
94a3afdc9b | ||
|
|
b65102e6c8 | ||
|
|
0f1b48b3e3 | ||
|
|
0a62da02fe | ||
|
|
ae54e39b69 | ||
|
|
ea565d7334 | ||
|
|
d11f625ecb | ||
|
|
ab6761a6ce | ||
|
|
081bc0eaa6 | ||
|
|
30748b7287 | ||
|
|
ad928eb2a6 | ||
|
|
bdb281dfa3 | ||
|
|
8d26800a5d | ||
|
|
f0d85b391c | ||
|
|
ac205b6944 | ||
|
|
c6979a2f61 | ||
|
|
0bbf4a9925 | ||
|
|
16b047bb73 | ||
|
|
f674fdcc0d | ||
|
|
05b40ae47e | ||
|
|
ce5413f622 | ||
|
|
fbf4971f52 | ||
|
|
a2e2de2ec3 | ||
|
|
9937851146 | ||
|
|
a0bdb84d3f | ||
|
|
21fca61fab | ||
|
|
1157e6a007 | ||
|
|
6d9db8ef46 | ||
|
|
8c23510622 | ||
|
|
660945b8fa | ||
|
|
6550e1c926 | ||
|
|
49bc5750a1 | ||
|
|
163e4b8b1e | ||
|
|
52fb5e2ad8 | ||
|
|
2596b85eb2 | ||
|
|
1210a6aa87 | ||
|
|
d3a27e8bc9 | ||
|
|
6cabb4eed7 | ||
|
|
38e6320d8f | ||
|
|
8d41a3d688 | ||
|
|
7bf6311a90 | ||
|
|
41660a1dfe | ||
|
|
46b467847e | ||
|
|
810635d964 | ||
|
|
b0a96641ef | ||
|
|
f2846da4ad | ||
|
|
e150225226 | ||
|
|
4580ab0181 | ||
|
|
f8c10d1807 | ||
|
|
f783c6a62f | ||
|
|
1bc610bd76 | ||
|
|
f49bb98b92 | ||
|
|
819318de37 | ||
|
|
3ebff2143a | ||
|
|
80deed2be7 | ||
|
|
8de3996d36 | ||
|
|
b56fd01f39 | ||
|
|
820ea7e2a4 | ||
|
|
3de5fb0622 | ||
|
|
a5f112ff16 | ||
|
|
32b3fceb3b | ||
|
|
1d7c88674d | ||
|
|
ce158692d4 | ||
|
|
9845498f76 | ||
|
|
7b8250056b | ||
|
|
80a217e646 | ||
|
|
0877e32e1b | ||
|
|
316e739dd6 | ||
|
|
43efebe3a7 | ||
|
|
ca849da815 | ||
|
|
9470cc192c | ||
|
|
631008832c | ||
|
|
7fc066679d | ||
|
|
73ee1b2f09 | ||
|
|
4c98282335 | ||
|
|
7e3c5beaea | ||
|
|
59d27e06a0 | ||
|
|
2caddafd0b | ||
|
|
cbfc10c08b | ||
|
|
ae8ffd4cbd | ||
|
|
e768d5d55c | ||
|
|
829245f373 | ||
|
|
c901a15969 | ||
|
|
017798ce89 | ||
|
|
d16874da7c | ||
|
|
d8c9204b61 | ||
|
|
1ab51b423f | ||
|
|
ce110e8758 | ||
|
|
e257a3f757 | ||
|
|
49a6305dec | ||
|
|
1b30df0c41 | ||
|
|
edb2369967 | ||
|
|
4f93908ab1 | ||
|
|
dc95c63c62 | ||
|
|
28a0cb5b12 | ||
|
|
a4cbcc2f32 | ||
|
|
0add20e637 | ||
|
|
50a8550a0c | ||
|
|
dbd6ac10e9 | ||
|
|
1252234eed | ||
|
|
5ee7a6efb6 | ||
|
|
a4bfe3752b | ||
|
|
dd034ec7ae | ||
|
|
c93b189ede | ||
|
|
2819531d57 | ||
|
|
dc1ce8e55e | ||
|
|
2e0266a265 | ||
|
|
bcd7aca2ff | ||
|
|
8c66558b63 | ||
|
|
a263bcd8c3 | ||
|
|
97a873c6b0 | ||
|
|
fc43259dcb | ||
|
|
73116446e2 | ||
|
|
f0d3439071 | ||
|
|
31a1aa7cac | ||
|
|
34cd751114 | ||
|
|
196d4a835f | ||
|
|
d82517f402 | ||
|
|
bcda74a455 | ||
|
|
1f65439efd | ||
|
|
f95b62a6a6 | ||
|
|
0dc0794add | ||
|
|
552b5845ea | ||
|
|
b05739257d | ||
|
|
61215cf0ad | ||
|
|
bc84e46e09 | ||
|
|
645581e202 | ||
|
|
4dddb9f2e1 | ||
|
|
6e93f952ab | ||
|
|
93ffcdb8f9 | ||
|
|
040d74c20a | ||
|
|
5825bcf9b3 | ||
|
|
114d183524 | ||
|
|
de356c4e64 | ||
|
|
fedd5e6a14 | ||
|
|
29217ab2bb | ||
|
|
95f37f7120 | ||
|
|
87a7b93334 | ||
|
|
0d430ad86c | ||
|
|
308a108e60 | ||
|
|
17401a330d | ||
|
|
f9d7463710 | ||
|
|
097c53c7b1 | ||
|
|
9ae2e5babb | ||
|
|
1f29f7a282 | ||
|
|
1d1fcd5f6d | ||
|
|
b6f8f87923 | ||
|
|
466b248c30 | ||
|
|
7c6747aeb0 | ||
|
|
836a6d1203 | ||
|
|
a3bd9d2706 | ||
|
|
2cdd45da96 | ||
|
|
0367c1db72 | ||
|
|
5f17802ab8 | ||
|
|
b3e90c7f2f | ||
|
|
6c2978306c | ||
|
|
b951e1567c | ||
|
|
97cdb57406 | ||
|
|
651300b043 | ||
|
|
b537e2789d | ||
|
|
3cd766bc74 | ||
|
|
30ee06c0ab | ||
|
|
74a1f1dc77 | ||
|
|
7a6f6d2c7c | ||
|
|
de8868ec68 | ||
|
|
7105d6271f | ||
|
|
b04a3de201 | ||
|
|
0107032ee3 | ||
|
|
34e07b0083 | ||
|
|
6c16516e89 | ||
|
|
7bc6dff6ea | ||
|
|
1c0be8a2ec | ||
|
|
4a9f1197fb | ||
|
|
ad4e7496cb | ||
|
|
d321b81a64 | ||
|
|
3691e3f240 | ||
|
|
62e2fe56d0 | ||
|
|
4a6a98c533 | ||
|
|
4e5d44c464 | ||
|
|
b2e8fbd84d | ||
|
|
41dde09a6a | ||
|
|
7bbfb85b0e | ||
|
|
c99d05cf6c | ||
|
|
f856aaaacd | ||
|
|
d029d4e371 | ||
|
|
04f3744624 | ||
|
|
4e1d00afff | ||
|
|
ccfa281490 | ||
|
|
8979b5ef0c | ||
|
|
765bd8bfab | ||
|
|
4e7f371c49 | ||
|
|
5ca5624f99 | ||
|
|
6e5066022b | ||
|
|
b16d8cbd6d | ||
|
|
030b3095a9 | ||
|
|
d9febdaf82 | ||
|
|
3143fc8b2a | ||
|
|
d8396d13b4 | ||
|
|
6a474b25ef | ||
|
|
c8494f3246 | ||
|
|
1d66f9695a | ||
|
|
053a9cd3a9 | ||
|
|
b7546fd2f4 | ||
|
|
61fc0a9ce7 | ||
|
|
251abad821 | ||
|
|
f581e7894d | ||
|
|
c16b81bfa2 | ||
|
|
4fcb19010c | ||
|
|
7f1e2a18ea | ||
|
|
7f4bb9e05d | ||
|
|
58d9dd3c26 | ||
|
|
609ed3cb09 | ||
|
|
67dd8ac151 | ||
|
|
fc82ec8070 | ||
|
|
f218230ad7 | ||
|
|
f8380226ee | ||
|
|
b087faeb90 | ||
|
|
a333b0b754 | ||
|
|
2614fbc94c | ||
|
|
66c70dd233 | ||
|
|
8d1c0a7bd1 | ||
|
|
eae74dacae | ||
|
|
ca0fed9f17 | ||
|
|
cfc5d94711 | ||
|
|
924a11e4f4 | ||
|
|
3be3124951 | ||
|
|
c846e36032 | ||
|
|
cf9e5f861b | ||
|
|
219139e45b | ||
|
|
eab43a66cf | ||
|
|
faa18d6a88 | ||
|
|
470e446ae6 | ||
|
|
af3f1a5fc3 | ||
|
|
3b925246ee | ||
|
|
e2d7b409bd | ||
|
|
731e242974 | ||
|
|
9614f8eb9d | ||
|
|
a98e8025c4 | ||
|
|
397b7ee032 | ||
|
|
22b041d252 | ||
|
|
5f20d8ad63 | ||
|
|
d3879a9d11 | ||
|
|
0d0b6dcd36 | ||
|
|
588f796069 | ||
|
|
440c51f1ca | ||
|
|
b99b2bd123 | ||
|
|
e502ff3491 | ||
|
|
1ae0f87f9c | ||
|
|
5a3e72eaaf | ||
|
|
41c8384ef5 | ||
|
|
12a5641cb4 | ||
|
|
e8cd305171 | ||
|
|
880b799df5 | ||
|
|
4c6dc25589 | ||
|
|
10a301eebc | ||
|
|
e6e50c96e4 | ||
|
|
fab2ee8420 | ||
|
|
58eb3ccacb | ||
|
|
aacd5ab7a1 | ||
|
|
38b0b9ceb1 | ||
|
|
9fb2a169e8 | ||
|
|
5977be4a14 | ||
|
|
5b5c53bebc | ||
|
|
9b38e62eaf | ||
|
|
7a490b19bd | ||
|
|
4d34e9e702 | ||
|
|
6fbd504e39 | ||
|
|
b0b79f1d19 | ||
|
|
3d7a3ecfc5 | ||
|
|
9b7d763d52 | ||
|
|
aae8624269 | ||
|
|
7fd16915c0 | ||
|
|
eacb9fe356 | ||
|
|
a7305b679e | ||
|
|
d48405d440 | ||
|
|
35157c0b58 | ||
|
|
ee7aacd776 | ||
|
|
2c08b94c3a | ||
|
|
d34cfbc327 | ||
|
|
d9933e6933 | ||
|
|
7dbb215a18 | ||
|
|
132f8be21c | ||
|
|
5fac2198cd | ||
|
|
f94b4d1205 | ||
|
|
fc75f92e89 | ||
|
|
998049872d | ||
|
|
f9ed0bab5e | ||
|
|
b87497411b | ||
|
|
b6be206630 | ||
|
|
f7a4350fe8 | ||
|
|
802a104a49 | ||
|
|
35aea2dd77 | ||
|
|
6ef7dd75e9 | ||
|
|
115ccdb26a | ||
|
|
6f888bcf4a | ||
|
|
d55de747f5 | ||
|
|
ba45c57cc6 | ||
|
|
8632f81237 | ||
|
|
f7c2ec7fa6 | ||
|
|
6617c1ef54 | ||
|
|
229c537731 | ||
|
|
d0060d828f | ||
|
|
98f9778464 | ||
|
|
1f6a0194f7 | ||
|
|
c7915e2c3d | ||
|
|
41f7979eb4 | ||
|
|
343ed8d22f | ||
|
|
1c038d3334 | ||
|
|
1c3cdec563 | ||
|
|
6c37f0f4fe | ||
|
|
f9967bff2e | ||
|
|
96a9e34487 | ||
|
|
2eb1574131 | ||
|
|
583871912b | ||
|
|
f4483d7973 | ||
|
|
21f49690bd | ||
|
|
18ebac2130 | ||
|
|
1deee7e396 | ||
|
|
0a72f0a9a9 | ||
|
|
1932338660 | ||
|
|
a481bcd974 | ||
|
|
d86fd0f6f6 | ||
|
|
bca70e0842 | ||
|
|
cf659f93d8 | ||
|
|
8bfc2a5297 | ||
|
|
783f0ed750 | ||
|
|
90e4097fa3 | ||
|
|
2aac558d4a | ||
|
|
9d3714eb90 | ||
|
|
46f5808fdb | ||
|
|
8dbc85d03d | ||
|
|
f4be59f958 | ||
|
|
4d38f0637c | ||
|
|
feb7758830 | ||
|
|
915be8dbdc | ||
|
|
3a6d0998c4 | ||
|
|
048bc7e421 | ||
|
|
3b0643da47 | ||
|
|
f873b396b6 | ||
|
|
5ec67115ba | ||
|
|
2a21714ed1 | ||
|
|
1913529bf0 | ||
|
|
419f47d36e | ||
|
|
fba2b1ea9b | ||
|
|
9f53e2c8da | ||
|
|
13f2a79ebb | ||
|
|
800f624d1d | ||
|
|
91e7b18506 | ||
|
|
cc3a46b1ff | ||
|
|
a4aed7b61f | ||
|
|
1aee97c64f | ||
|
|
d33980a8c6 | ||
|
|
c589def0de | ||
|
|
c70ef1e2e5 | ||
|
|
60b6a73bd7 | ||
|
|
b755f3b29b | ||
|
|
be4eeeb9d7 | ||
|
|
31beae63fd | ||
|
|
16a530fb50 | ||
|
|
b8c5b7f479 | ||
|
|
f64475b793 | ||
|
|
8e6de04e23 | ||
|
|
25f6440b1b | ||
|
|
c1bdfe33f0 | ||
|
|
d7de538345 | ||
|
|
d2950bc0b5 | ||
|
|
6333f58f56 | ||
|
|
bc187aabfe | ||
|
|
f825887181 | ||
|
|
2bdc35055c | ||
|
|
b6602f9e4b | ||
|
|
ab366837a2 | ||
|
|
c0a7a9b753 | ||
|
|
c951732eb4 | ||
|
|
1b638d118d | ||
|
|
1173ac6504 | ||
|
|
9447903d5b | ||
|
|
fff9769791 | ||
|
|
d5c5faf0ca | ||
|
|
85d793ee64 | ||
|
|
cf9c9fb5d3 | ||
|
|
b7144727e9 | ||
|
|
60cd84972c | ||
|
|
5854d2514f | ||
|
|
226893a8b2 | ||
|
|
b182d5ea16 | ||
|
|
6bc28eafbf | ||
|
|
1983190da3 | ||
|
|
656c0cf012 | ||
|
|
60ddc1f8b2 | ||
|
|
c3604ab74a | ||
|
|
eac0428dc6 | ||
|
|
53cd427a75 | ||
|
|
96cf6ddbf9 | ||
|
|
66c2ec013c | ||
|
|
e3d5bf0220 | ||
|
|
3281358282 | ||
|
|
18e499e6de | ||
|
|
514d52f895 | ||
|
|
8719fa6696 | ||
|
|
9c38444c4b | ||
|
|
a5f9adc875 | ||
|
|
74de43c3d6 | ||
|
|
9f81bf695c | ||
|
|
3caf743f29 | ||
|
|
ad28218893 | ||
|
|
760811f172 | ||
|
|
68e7684da3 | ||
|
|
ca2cc7bbda | ||
|
|
741cc4ccc7 | ||
|
|
0c1f1fada4 | ||
|
|
5a91c73a91 | ||
|
|
9da1bfeea1 | ||
|
|
d4b603a4dd | ||
|
|
a1a302f222 | ||
|
|
7d2b98f674 | ||
|
|
2f5b0a3bf8 | ||
|
|
1efd5ebad5 | ||
|
|
acb5e5e1c9 | ||
|
|
0f24fd6edc | ||
|
|
99c448e0e5 | ||
|
|
cabb499a43 | ||
|
|
6933105261 | ||
|
|
bf36890bd3 | ||
|
|
05b6b97b2a | ||
|
|
6a8aca39a0 | ||
|
|
7434a324fb | ||
|
|
fd934e1e82 | ||
|
|
dd94d12e3a | ||
|
|
fe85dd867d | ||
|
|
12f2047910 | ||
|
|
cb6931bd88 | ||
|
|
c76f9141fc | ||
|
|
ddf70ce3d4 | ||
|
|
7beb82e804 | ||
|
|
8d9b8951bf | ||
|
|
b20d045c8e | ||
|
|
736ce8eb3f | ||
|
|
3409049559 | ||
|
|
5212122946 |
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -3,10 +3,15 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- main-hotfix
|
||||
pull_request: {}
|
||||
jobs:
|
||||
tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
services:
|
||||
redis-cache:
|
||||
image: redis:alpine
|
||||
@@ -30,13 +35,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: setup python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.14'
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '24'
|
||||
check-latest: true
|
||||
- name: setup cache for bench
|
||||
uses: actions/cache@v4
|
||||
@@ -69,6 +74,9 @@ jobs:
|
||||
- name: setup requirements
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench setup requirements --dev
|
||||
- name: block endpoints
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site frappe.local set-config block_endpoints 1
|
||||
- name: allow tests
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site frappe.local set-config allow_tests true
|
||||
@@ -77,4 +85,27 @@ jobs:
|
||||
run: bench --site frappe.local build
|
||||
- name: run tests
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site frappe.local run-tests --app lms
|
||||
run: bench --site frappe.local run-tests --app lms --coverage
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: Server
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
9
.github/workflows/generate-pot-file.yml
vendored
9
.github/workflows/generate-pot-file.yml
vendored
@@ -22,9 +22,14 @@ jobs:
|
||||
ref: ${{ matrix.branch }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run script to update POT file
|
||||
run: |
|
||||
|
||||
8
.github/workflows/linters.yml
vendored
8
.github/workflows/linters.yml
vendored
@@ -16,9 +16,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 200
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Check commit titles
|
||||
@@ -35,9 +35,9 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
|
||||
4
.github/workflows/make_release_pr.yml
vendored
4
.github/workflows/make_release_pr.yml
vendored
@@ -18,9 +18,9 @@ jobs:
|
||||
owner: frappe
|
||||
repo: lms
|
||||
title: |-
|
||||
"chore: merge 'develop' into 'main'"
|
||||
"chore: merge 'main-hotfix' into 'main'"
|
||||
body: "Automated weekly release"
|
||||
base: main
|
||||
head: develop
|
||||
head: main-hotfix
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
4
.github/workflows/on_release.yml
vendored
4
.github/workflows/on_release.yml
vendored
@@ -15,9 +15,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
- name: Setup dependencies
|
||||
run: |
|
||||
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||
|
||||
13
.github/workflows/ui-tests.yml
vendored
13
.github/workflows/ui-tests.yml
vendored
@@ -4,7 +4,10 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- main-hotfix
|
||||
|
||||
permissions:
|
||||
# Do not change this as GITHUB_TOKEN is being used by roulette
|
||||
@@ -36,9 +39,9 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
@@ -48,9 +51,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,4 +12,6 @@ node_modules
|
||||
package-lock.json
|
||||
lms/public/frontend
|
||||
lms/www/lms.html
|
||||
frappe-ui
|
||||
lms/www/_lms.html
|
||||
frappe-ui
|
||||
frappe-semgrep-rules
|
||||
30
.mergify.yml
Normal file
30
.mergify.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
pull_request_rules:
|
||||
- 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 }}"
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"branches": ["develop"],
|
||||
"branches": ["main"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer", {
|
||||
"preset": "angular"
|
||||
|
||||
2
codecov.yml
Normal file
2
codecov.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
ignore:
|
||||
- "**/test_helper.py"
|
||||
@@ -27,6 +27,10 @@ describe("Batch Creation", () => {
|
||||
cy.get("input[placeholder='Jane']").type(randomName);
|
||||
cy.get("button").contains("Add").click();
|
||||
|
||||
// Open Settings
|
||||
cy.get("span").contains("Learning").click();
|
||||
cy.get("span").contains("Settings").click();
|
||||
|
||||
// Add evaluator
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("span")
|
||||
@@ -48,26 +52,23 @@ describe("Batch Creation", () => {
|
||||
|
||||
// Create a batch
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Batch").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/batches/new/edit");
|
||||
cy.get("label").contains("Title").type("Test Batch");
|
||||
|
||||
cy.get("label").contains("Start Date").type("2030-10-01");
|
||||
cy.get("label").contains("End Date").type("2030-10-31");
|
||||
cy.get("label").contains("Start Time").type("10:00");
|
||||
cy.get("label").contains("End Time").type("11:00");
|
||||
cy.get("label").contains("Timezone").type("IST");
|
||||
cy.get("label").contains("Seat Count").type("10");
|
||||
cy.get("label").contains("Published").click();
|
||||
|
||||
cy.get("label")
|
||||
.contains("Short Description")
|
||||
.contains("Description")
|
||||
.type("Test Batch Short Description to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
@@ -85,13 +86,14 @@ describe("Batch Creation", () => {
|
||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Save").click();
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.button("Save").click();
|
||||
cy.wait(1000);
|
||||
let batchName;
|
||||
cy.url().then((url) => {
|
||||
console.log(url);
|
||||
batchName = url.split("/").pop();
|
||||
batchName = url.split("/").pop().split("#")[0];
|
||||
cy.wrap(batchName).as("batchName");
|
||||
});
|
||||
cy.wait(500);
|
||||
@@ -110,7 +112,7 @@ describe("Batch Creation", () => {
|
||||
.click();
|
||||
|
||||
cy.get("@batchName").then((batchName) => {
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
|
||||
cy.get("div").contains("Test Batch").should("be.visible");
|
||||
cy.get("div")
|
||||
.contains("Test Batch Short Description to test the UI")
|
||||
@@ -123,14 +125,11 @@ describe("Batch Creation", () => {
|
||||
.should("be.visible");
|
||||
cy.get("span").contains("IST").should("be.visible");
|
||||
cy.get("a").contains("Evaluator").should("be.visible");
|
||||
cy.get("div")
|
||||
.contains("10")
|
||||
.should("be.visible")
|
||||
.get("span")
|
||||
.contains("Seats Left")
|
||||
.should("be.visible");
|
||||
cy.contains("div:visible", "10 Seats Left").should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).click();
|
||||
});
|
||||
|
||||
cy.get("div").contains("Test Batch").should("be.visible");
|
||||
@@ -152,17 +151,22 @@ describe("Batch Creation", () => {
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
)
|
||||
.should("be.visible");
|
||||
cy.get("button:visible").contains("Manage Batch").click();
|
||||
cy.get("button:visible").contains("Dashboard").click();
|
||||
|
||||
/* Add student to batch */
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.get('div[role="dialog"]').first().find("button").eq(1).click();
|
||||
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
|
||||
cy.get("button").contains("Enroll").click();
|
||||
cy.get('div[role="dialog"]')
|
||||
.first()
|
||||
.find("div[label='Student']")
|
||||
.find("div")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("input[placeholder='Search']").type(randomEmail);
|
||||
cy.get("div").contains(randomEmail).click();
|
||||
cy.get("button").contains("Submit").click();
|
||||
|
||||
// Verify Seat Count
|
||||
cy.get("span").contains("Details").click();
|
||||
cy.get("button:visible").contains("Overview").click();
|
||||
cy.contains("div:visible", "9 Seats Left").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,8 @@ describe("Course Creation", () => {
|
||||
|
||||
// Create a course
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Course").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
cy.get("label").contains("Title").type("Test Course");
|
||||
cy.get("label")
|
||||
@@ -34,27 +34,13 @@ describe("Course Creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
cy.get("label")
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||
cy.get("label")
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click().type("frappe");
|
||||
cy.wait(500);
|
||||
cy.get("input")
|
||||
.invoke("attr", "aria-controls")
|
||||
.as("instructor_list_id");
|
||||
@@ -67,13 +53,29 @@ describe("Course Creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Save").last().click();
|
||||
|
||||
// Edit Course Details
|
||||
cy.wait(500);
|
||||
cy.get("label")
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||
cy.get("label")
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("div").contains("Business").click();
|
||||
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Chapter
|
||||
cy.wait(1000);
|
||||
cy.button("Add Chapter").click();
|
||||
cy.button("Add").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.get("[data-dismissable-layer]")
|
||||
|
||||
Submodule frappe-ui updated: f1bde9bcb2...78025c6794
4
frontend/auto-imports.d.ts
vendored
4
frontend/auto-imports.d.ts
vendored
@@ -6,5 +6,7 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
const LucideGithub: typeof import('~icons/lucide/github').default
|
||||
const LucideLinkedin: typeof import('~icons/lucide/linkedin').default
|
||||
const LucideTwitter: typeof import('~icons/lucide/twitter').default
|
||||
}
|
||||
|
||||
36
frontend/components.d.ts
vendored
36
frontend/components.d.ts
vendored
@@ -8,14 +8,10 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
||||
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
|
||||
AppHeader: typeof import('./src/components/AppHeader.vue')['default']
|
||||
Apps: typeof import('./src/components/Apps.vue')['default']
|
||||
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
|
||||
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
|
||||
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
|
||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
|
||||
Assessments: typeof import('./src/components/Assessments.vue')['default']
|
||||
Assignment: typeof import('./src/components/Assignment.vue')['default']
|
||||
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
||||
@@ -24,16 +20,8 @@ declare module 'vue' {
|
||||
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
|
||||
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
|
||||
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
|
||||
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
||||
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
||||
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
||||
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
|
||||
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
|
||||
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
||||
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
||||
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
||||
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
||||
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
||||
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
||||
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
||||
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
||||
@@ -42,12 +30,18 @@ declare module 'vue' {
|
||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||
CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default']
|
||||
CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default']
|
||||
Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default']
|
||||
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
||||
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
|
||||
CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default']
|
||||
CouponList: typeof import('./src/components/Settings/Coupons/CouponList.vue')['default']
|
||||
Coupons: typeof import('./src/components/Settings/Coupons/Coupons.vue')['default']
|
||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
||||
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
|
||||
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
||||
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
||||
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
||||
@@ -73,11 +67,9 @@ declare module 'vue' {
|
||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
||||
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
|
||||
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
||||
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
|
||||
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
|
||||
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
|
||||
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
||||
@@ -88,6 +80,7 @@ declare module 'vue' {
|
||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||
NumberChartGraph: typeof import('./src/components/NumberChartGraph.vue')['default']
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
|
||||
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default']
|
||||
@@ -105,18 +98,19 @@ declare module 'vue' {
|
||||
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
|
||||
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
|
||||
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
|
||||
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
||||
SidebarLink: typeof import('./src/components/Sidebar/SidebarLink.vue')['default']
|
||||
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
||||
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
||||
Tags: typeof import('./src/components/Tags.vue')['default']
|
||||
TransactionDetails: typeof import('./src/components/Settings/TransactionDetails.vue')['default']
|
||||
Transactions: typeof import('./src/components/Settings/Transactions.vue')['default']
|
||||
TransactionDetails: typeof import('./src/components/Settings/Transactions/TransactionDetails.vue')['default']
|
||||
TransactionList: typeof import('./src/components/Settings/Transactions/TransactionList.vue')['default']
|
||||
Transactions: typeof import('./src/components/Settings/Transactions/Transactions.vue')['default']
|
||||
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
||||
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
||||
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||
UserDropdown: typeof import('./src/components/Sidebar/UserDropdown.vue')['default']
|
||||
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
||||
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
|
||||
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||
|
||||
@@ -6,55 +6,57 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"serve": "vite preview",
|
||||
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry",
|
||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry && yarn copy-colors-json",
|
||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/_lms.html",
|
||||
"copy-colors-json": "cp node_modules/frappe-ui/tailwind/colors.json src/utils/frappe-ui-colors.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@editorjs/checklist": "^1.6.0",
|
||||
"@editorjs/code": "^2.9.0",
|
||||
"@editorjs/editorjs": "^2.29.0",
|
||||
"@editorjs/embed": "^2.7.0",
|
||||
"@editorjs/header": "^2.8.1",
|
||||
"@editorjs/inline-code": "^1.5.0",
|
||||
"@editorjs/nested-list": "^1.4.2",
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"@editorjs/table": "^2.4.2",
|
||||
"@vueuse/router": "^12.7.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"apexcharts": "^4.3.0",
|
||||
"chart.js": "^4.4.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"dayjs": "^1.11.6",
|
||||
"dompurify": "^3.2.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.201",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"plyr": "^3.7.8",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss": "3.4.15",
|
||||
"thememirror": "^2.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vue": "^3.4.23",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-draggable-next": "^2.2.1",
|
||||
"vue-router": "^4.0.12",
|
||||
"vue3-apexcharts": "^1.8.0",
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
"@codemirror/lang-json": "6.0.1",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@editorjs/checklist": "1.6.0",
|
||||
"@editorjs/code": "2.9.0",
|
||||
"@editorjs/editorjs": "2.29.0",
|
||||
"@editorjs/embed": "2.7.0",
|
||||
"@editorjs/header": "2.8.1",
|
||||
"@editorjs/inline-code": "1.5.0",
|
||||
"@editorjs/nested-list": "1.4.2",
|
||||
"@editorjs/paragraph": "2.11.3",
|
||||
"@editorjs/simple-image": "1.6.0",
|
||||
"@editorjs/table": "2.4.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"ace-builds": "1.36.2",
|
||||
"apexcharts": "4.3.0",
|
||||
"chart.js": "4.4.1",
|
||||
"codemirror": "6.0.1",
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.2.6",
|
||||
"feather-icons": "4.28.0",
|
||||
"frappe-ui": "^0.1.261",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-vue-next": "0.383.0",
|
||||
"markdown-it": "14.0.0",
|
||||
"pinia": "2.0.33",
|
||||
"plyr": "3.7.8",
|
||||
"socket.io-client": "4.7.2",
|
||||
"thememirror": "2.0.1",
|
||||
"typescript": "5.7.2",
|
||||
"vue": "^3.5.27",
|
||||
"vue-chartjs": "5.3.0",
|
||||
"vue-codemirror": "6.1.1",
|
||||
"vue-draggable-next": "2.2.1",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue3-apexcharts": "1.8.0",
|
||||
"vuedraggable": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"postcss": "^8.4.5",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-pwa": "^1.0.2"
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"autoprefixer": "10.4.2",
|
||||
"postcss": "8.4.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"vite": "5.0.11",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
<Layout class="isolate text-base">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<!--<InstallPrompt v-if="isMobile" />-->
|
||||
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
|
||||
<Dialogs />
|
||||
</FrappeUIProvider>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FrappeUIProvider } from 'frappe-ui'
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useScreenSize } from './utils/composables'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { posthogSettings } from '@/telemetry'
|
||||
import DesktopLayout from './components/DesktopLayout.vue'
|
||||
import MobileLayout from './components/MobileLayout.vue'
|
||||
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
||||
@@ -23,7 +22,7 @@ import InstallPrompt from './components/InstallPrompt.vue'
|
||||
const { isMobile } = useScreenSize()
|
||||
const router = useRouter()
|
||||
const noSidebar = ref(false)
|
||||
const { userResource } = usersStore()
|
||||
const { settings } = useSettings()
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.query.fromLesson || to.path === '/persona') {
|
||||
@@ -47,10 +46,4 @@ const Layout = computed(() => {
|
||||
onUnmounted(() => {
|
||||
noSidebar.value = false
|
||||
})
|
||||
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
posthogSettings.reload()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,152 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Light.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Black.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2 text-ink-gray-7">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ timeAgo(comm.communication_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
|
||||
v-html="comm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No announcements') }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.prose-sm p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -26,28 +26,52 @@
|
||||
v-model="quiz"
|
||||
doctype="LMS Quiz"
|
||||
:label="__('Select a quiz')"
|
||||
placeholder=" "
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<Link
|
||||
v-else
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
:label="__('Select an assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<div v-else class="space-y-4">
|
||||
<Link
|
||||
v-if="filterAssignmentsByCourse"
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
:filters="{
|
||||
course: route.params.courseName,
|
||||
}"
|
||||
placeholder=" "
|
||||
:label="__('Select an Assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<Link
|
||||
v-else
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
placeholder=" "
|
||||
:label="__('Select an Assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:label="__('Filter assignments by course')"
|
||||
v-model="filterAssignmentsByCourse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog } from 'frappe-ui'
|
||||
import { onMounted, ref, nextTick } from 'vue'
|
||||
import { Dialog, FormControl } from 'frappe-ui'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = ref(false)
|
||||
const quiz = ref(null)
|
||||
const assignment = ref(null)
|
||||
const filterAssignmentsByCourse = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
@@ -71,7 +95,10 @@ const addAssessment = () => {
|
||||
}
|
||||
|
||||
const redirectToForm = () => {
|
||||
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
|
||||
else window.open('/lms/assignments/new', '_blank')
|
||||
if (props.type == 'quiz') {
|
||||
window.open(getLmsRoute('quizzes?new=true'), '_blank')
|
||||
} else {
|
||||
window.open(getLmsRoute('assignments?new=true'), '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7 font-medium mb-2">
|
||||
{{ __('Question') }}:
|
||||
<div class="text-ink-gray-9 font-semibold mb-5">
|
||||
{{ __('Assignment Question') }}
|
||||
</div>
|
||||
<div
|
||||
v-html="assignment.data.question"
|
||||
@@ -25,9 +25,9 @@
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div class="p-5 space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ __('Submission') }}
|
||||
</div>
|
||||
@@ -42,7 +42,11 @@
|
||||
>
|
||||
{{ submissionResource.doc?.status }}
|
||||
</Badge>
|
||||
<Button variant="solid" @click="submitAssignment()">
|
||||
<Button
|
||||
v-if="canModifyAssignment"
|
||||
variant="solid"
|
||||
@click="submitAssignment()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -53,7 +57,7 @@
|
||||
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
|
||||
submissionResource.doc?.owner == user.data?.name
|
||||
"
|
||||
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm mb-4"
|
||||
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm"
|
||||
>
|
||||
{{ __("You've successfully submitted the assignment.") }}
|
||||
{{
|
||||
@@ -63,17 +67,24 @@
|
||||
}}
|
||||
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||
</div>
|
||||
<div v-if="showUploader()">
|
||||
<div class="text-xs text-ink-gray-5 mt-1 mb-2">
|
||||
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
||||
<div v-if="showUploader()" class="border rounded-lg p-3">
|
||||
<div class="font-semibold mb-2">
|
||||
{{ __('Upload Assignment') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-5 text-sm mt-1 mb-4">
|
||||
{{
|
||||
__('You can only upload {0} files').format(assignment.data.type)
|
||||
}}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!submissionFile"
|
||||
v-if="!attachment"
|
||||
:fileTypes="getType()"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
:validateFile="validateFile"
|
||||
:validateFile="
|
||||
(file) => validateFile(file, assignment.data.type.toLowerCase())
|
||||
"
|
||||
@success="(file) => saveSubmission(file)"
|
||||
>
|
||||
<template #default="{ uploading, progress, openFileSelector }">
|
||||
@@ -87,21 +98,20 @@
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex text-ink-gray-7">
|
||||
<div class="border self-start rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<a
|
||||
:href="submissionFile.file_url"
|
||||
:href="attachment"
|
||||
target="_blank"
|
||||
class="flex flex-col cursor-pointer !no-underline"
|
||||
class="cursor-pointer !no-underline text-sm leading-5"
|
||||
>
|
||||
<span class="text-sm leading-5">
|
||||
{{ submissionFile.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-5 mt-1">
|
||||
{{ getFileSize(submissionFile.file_size) }}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<span>
|
||||
{{ attachment.split('/').pop() }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<X
|
||||
v-if="canModifyAssignment"
|
||||
@@ -130,10 +140,11 @@
|
||||
@change="(val) => (answer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
:readonly="!canModifyAssignment"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -142,13 +153,13 @@
|
||||
user.data?.name == submissionResource.doc?.owner &&
|
||||
submissionResource.doc?.comments
|
||||
"
|
||||
class="mt-8 p-3 bg-surface-blue-2 rounded-md"
|
||||
class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
|
||||
>
|
||||
<div class="text-sm text-ink-gray-5 font-medium mb-2">
|
||||
{{ __('Comments by Evaluator') }}:
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Comments by Evaluator') }}
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-ink-gray-9"
|
||||
class="leading-6 text-ink-gray-9"
|
||||
v-html="submissionResource.doc.comments"
|
||||
></div>
|
||||
</div>
|
||||
@@ -179,7 +190,10 @@
|
||||
"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,11 +215,11 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { getFileSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { validateFile } from '@/utils'
|
||||
|
||||
const submissionFile = ref(null)
|
||||
const answer = ref(null)
|
||||
const attachment = ref(null)
|
||||
const comments = ref(null)
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
@@ -255,129 +269,98 @@ const assignment = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const newSubmission = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (showUploader()) {
|
||||
doc.assignment_attachment = submissionFile.value.file_url
|
||||
} else {
|
||||
doc.answer = answer.value
|
||||
}
|
||||
return {
|
||||
doc: doc,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
submissionFile.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const submissionResource = createDocumentResource({
|
||||
doctype: 'LMS Assignment Submission',
|
||||
name: props.submissionName,
|
||||
auto: false,
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
auto: false,
|
||||
cache: [user.data?.name, props.assignmentID],
|
||||
})
|
||||
|
||||
watch(submissionResource, () => {
|
||||
if (submissionResource.doc) {
|
||||
if (submissionResource.doc.assignment_attachment) {
|
||||
imageResource.reload({
|
||||
image: submissionResource.doc.assignment_attachment,
|
||||
})
|
||||
}
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
if (submissionResource.doc.comments) {
|
||||
comments.value = submissionResource.doc.comments
|
||||
}
|
||||
if (submissionResource.isDirty) {
|
||||
isDirty.value = true
|
||||
} else if (showUploader() && !submissionFile.value) {
|
||||
isDirty.value = true
|
||||
} else if (!showUploader() && !answer.value) {
|
||||
isDirty.value = true
|
||||
} else {
|
||||
isDirty.value = false
|
||||
}
|
||||
if (!submissionResource.doc) return
|
||||
console.log(submissionResource.doc)
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
})
|
||||
|
||||
watch(submissionFile, () => {
|
||||
if (props.submissionName == 'new' && submissionFile.value) {
|
||||
isDirty.value = true
|
||||
if (submissionResource.doc.assignment_attachment) {
|
||||
attachment.value = submissionResource.doc.assignment_attachment
|
||||
}
|
||||
if (submissionResource.doc.comments) {
|
||||
comments.value = submissionResource.doc.comments
|
||||
}
|
||||
})
|
||||
|
||||
const submitAssignment = () => {
|
||||
if (props.submissionName != 'new') {
|
||||
let evaluator =
|
||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||
? user.data?.name
|
||||
: null
|
||||
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
assignment_attachment: submissionFile.value?.file_url,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
updateSubmission()
|
||||
} else {
|
||||
addNewSubmission()
|
||||
}
|
||||
}
|
||||
|
||||
const addNewSubmission = () => {
|
||||
newSubmission.submit(
|
||||
{},
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (!showUploader()) {
|
||||
doc.answer = answer.value
|
||||
} else {
|
||||
doc.assignment_attachment = attachment.value
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: doc,
|
||||
})
|
||||
.then((data) => {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
isDirty.value = false
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const updateSubmission = () => {
|
||||
let evaluator =
|
||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||
? user.data?.name
|
||||
: null
|
||||
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
assignment_attachment: attachment.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -385,7 +368,7 @@ const addNewSubmission = () => {
|
||||
|
||||
const saveSubmission = (file) => {
|
||||
isDirty.value = true
|
||||
submissionFile.value = file
|
||||
attachment.value = file.file_url
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
@@ -419,24 +402,9 @@ const getType = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let type = assignment.data?.type
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
} else if (
|
||||
type == 'Document' &&
|
||||
!['doc', 'docx', 'xml'].includes(extension)
|
||||
) {
|
||||
return 'Only document file is allowed.'
|
||||
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
||||
return 'Only PDF file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const removeSubmission = () => {
|
||||
isDirty.value = true
|
||||
submissionFile.value = null
|
||||
submissionResource.doc.assignment_attachment = ''
|
||||
}
|
||||
|
||||
const canGradeSubmission = computed(() => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
:endDate="batch.data.evaluation_end_date"
|
||||
:courses="batch.data.courses"
|
||||
/>
|
||||
<Assessments :batch="batch.data.name" />
|
||||
<!-- <StudentHeatmap /> -->
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,354 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="">
|
||||
<div class="w-full flex items-center justify-between pb-4">
|
||||
<div class="font-medium text-ink-gray-7">
|
||||
{{ __('Statistics') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Students'), value: students.data?.length || 0 }"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Certified'),
|
||||
value: certificationCount.data || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Courses'),
|
||||
value: batch.data.courses?.length || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
:config="{
|
||||
data: chartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
swapXY: true,
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-ink-gray-7 font-medium">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="students.data?.length">
|
||||
<ListView
|
||||
:columns="getStudentColumns()"
|
||||
:rows="students.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in getStudentColumns()"
|
||||
:title="item.label"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in students.data"
|
||||
class="group cursor-pointer"
|
||||
@click="openStudentProgressModal(row)"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="text-sm"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'full_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['user_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="column.key == 'progress'"
|
||||
class="flex items-center space-x-4 w-full"
|
||||
>
|
||||
<ProgressBar :progress="row[column.key]" size="sm" />
|
||||
<div class="text-xs">{{ row[column.key] }}%</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeStudents(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('There are no students in this batch.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StudentModal
|
||||
:batch="props.batch.data.name"
|
||||
v-model="showStudentModal"
|
||||
v-model:reloadStudents="students"
|
||||
v-model:batchModal="props.batch"
|
||||
/>
|
||||
<BatchStudentProgress
|
||||
:student="selectedStudent"
|
||||
v-model="showStudentProgressModal"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
AxisChart,
|
||||
Button,
|
||||
createResource,
|
||||
FeatherIcon,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
NumberChart,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
BookOpen,
|
||||
GraduationCap,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
} from 'lucide-vue-next'
|
||||
import { ref, watch } from 'vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||
import ApexChart from 'vue3-apexcharts'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const showStudentModal = ref(false)
|
||||
const showStudentProgressModal = ref(false)
|
||||
const selectedStudent = ref(null)
|
||||
const chartData = ref(null)
|
||||
const showProgressChart = ref(false)
|
||||
const assessmentCount = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const students = createResource({
|
||||
url: 'lms.lms.utils.get_batch_students',
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
chartData.value = getChartData()
|
||||
showProgressChart.value =
|
||||
data.length &&
|
||||
(props.batch?.data?.courses?.length || assessmentCount.value)
|
||||
},
|
||||
})
|
||||
|
||||
const getStudentColumns = () => {
|
||||
let columns = [
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: '20rem',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Progress',
|
||||
key: 'progress',
|
||||
width: '15rem',
|
||||
icon: 'activity',
|
||||
},
|
||||
{
|
||||
label: 'Last Active',
|
||||
key: 'last_active',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
const openStudentModal = () => {
|
||||
showStudentModal.value = true
|
||||
}
|
||||
|
||||
const openStudentProgressModal = (row) => {
|
||||
showStudentProgressModal.value = true
|
||||
selectedStudent.value = row
|
||||
}
|
||||
|
||||
const deleteStudents = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
documents: values.students,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeStudents = (selections, unselectAll) => {
|
||||
deleteStudents.submit(
|
||||
{
|
||||
students: Array.from(selections),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
students.reload()
|
||||
props.batch.reload()
|
||||
toast.success(__('Students deleted successfully'))
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const getChartData = () => {
|
||||
let tasks = []
|
||||
let data = []
|
||||
|
||||
students.data.forEach((row) => {
|
||||
tasks = countAssessments(row, tasks)
|
||||
tasks = countCourses(row, tasks)
|
||||
})
|
||||
|
||||
tasks.forEach((task) => {
|
||||
data.push({
|
||||
task: task.label,
|
||||
value: task.value,
|
||||
})
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const countAssessments = (row, tasks) => {
|
||||
Object.keys(row.assessments).forEach((assessment) => {
|
||||
if (row.assessments[assessment].result === 'Pass') {
|
||||
tasks.filter((task) => task.label === assessment).length
|
||||
? tasks.filter((task) => task.label === assessment)[0].value++
|
||||
: tasks.push({
|
||||
value: 1,
|
||||
label: assessment,
|
||||
})
|
||||
}
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
const countCourses = (row, tasks) => {
|
||||
Object.keys(row.courses).forEach((course) => {
|
||||
if (row.courses[course] === 100) {
|
||||
tasks.filter((task) => task.label === course).length
|
||||
? tasks.filter((task) => task.label === course)[0].value++
|
||||
: tasks.push({
|
||||
value: 1,
|
||||
label: course,
|
||||
})
|
||||
}
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
watch(students, () => {
|
||||
if (students.data?.length) {
|
||||
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
|
||||
}
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: {
|
||||
batch_name: props.batch?.data?.name,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
@@ -68,11 +68,12 @@ const props = defineProps({
|
||||
|
||||
const certification = createResource({
|
||||
url: 'lms.lms.api.get_certification_details',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: user.data ? true : false,
|
||||
cache: ['certificationData', user.data?.name],
|
||||
})
|
||||
|
||||
const downloadCertificate = () => {
|
||||
|
||||
272
frontend/src/components/CommandPalette/CommandPalette.vue
Normal file
272
frontend/src/components/CommandPalette/CommandPalette.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '2xl' }">
|
||||
<template #body>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center space-x-2 pl-4.5 border-b">
|
||||
<Search class="size-4 text-ink-gray-4" />
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
class="w-full border-none bg-transparent py-3 !pl-2 pr-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
|
||||
@input="onInput"
|
||||
v-model="query"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-auto mb-2">
|
||||
<div v-if="query.length" class="mt-5 space-y-5">
|
||||
<CommandPaletteGroup
|
||||
:list="searchResults"
|
||||
@navigateTo="navigateTo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-5">
|
||||
<CommandPaletteGroup
|
||||
:list="jumpToOptions"
|
||||
@navigateTo="navigateTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<MoveUp
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<MoveDown
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<span>
|
||||
{{ __('to navigate') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<CornerDownLeft
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<span>
|
||||
{{ __('to select') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
|
||||
<span>
|
||||
{{ __('to close') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource, debounce, Dialog } from 'frappe-ui'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
CornerDownLeft,
|
||||
FileSearch,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
Search,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
import CommandPaletteGroup from './CommandPaletteGroup.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const router = useRouter()
|
||||
const query = ref<string>('')
|
||||
const searchResults = ref<Array<any>>([])
|
||||
|
||||
const search = createResource({
|
||||
url: 'lms.command_palette.search_sqlite',
|
||||
makeParams: () => ({
|
||||
query: query.value,
|
||||
}),
|
||||
onSuccess() {
|
||||
generateSearchResults()
|
||||
},
|
||||
})
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
if (query.value.length > 2) {
|
||||
search.reload()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const onInput = () => {
|
||||
debouncedSearch()
|
||||
}
|
||||
|
||||
const generateSearchResults = () => {
|
||||
search.data?.forEach((type: any) => {
|
||||
let result: { title: string; items: any[] } = { title: '', items: [] }
|
||||
result.title = type.title
|
||||
type.items.forEach((item: any) => {
|
||||
let paramName = item.doctype === 'LMS Course' ? 'courseName' : 'batchName'
|
||||
item.route = {
|
||||
name: item.doctype === 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[paramName]: item.name,
|
||||
},
|
||||
}
|
||||
item.isActive = false
|
||||
})
|
||||
result.items = type.items
|
||||
searchResults.value.push(result)
|
||||
})
|
||||
}
|
||||
|
||||
const appendSearchPage = () => {
|
||||
let searchPage: { title: string; items: Array<any> } = {
|
||||
title: '',
|
||||
items: [],
|
||||
}
|
||||
searchPage.title = __('Jump to')
|
||||
searchPage.items = [
|
||||
{
|
||||
title: __('Search for ') + `"${query.value}"`,
|
||||
route: {
|
||||
name: 'Search',
|
||||
query: {
|
||||
q: query.value,
|
||||
},
|
||||
},
|
||||
icon: FileSearch,
|
||||
isActive: true,
|
||||
},
|
||||
]
|
||||
searchResults.value = [searchPage]
|
||||
}
|
||||
|
||||
watch(
|
||||
query,
|
||||
() => {
|
||||
appendSearchPage()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(show, () => {
|
||||
if (!show.value) {
|
||||
query.value = ''
|
||||
searchResults.value = []
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
addKeyboardShortcuts()
|
||||
})
|
||||
|
||||
const addKeyboardShortcuts = () => {
|
||||
window.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowUp' && show.value) {
|
||||
e.preventDefault()
|
||||
shortcutForArrowKey(-1)
|
||||
} else if (e.key === 'ArrowDown' && show.value) {
|
||||
shortcutForArrowKey(1)
|
||||
} else if (e.key === 'Enter' && show.value) {
|
||||
shortcutForEnter()
|
||||
} else if (e.key === 'Escape' && show.value) {
|
||||
show.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const shortcutForArrowKey = (direction: number) => {
|
||||
let currentList = query.value.length
|
||||
? searchResults.value
|
||||
: jumpToOptions.value
|
||||
let allItems = currentList.flatMap((result: any) => result.items)
|
||||
let indexOfActive = allItems.findIndex((option: any) => option.isActive)
|
||||
let newIndex = indexOfActive + direction
|
||||
if (newIndex < 0) newIndex = allItems.length - 1
|
||||
if (newIndex >= allItems.length) newIndex = 0
|
||||
allItems[indexOfActive].isActive = false
|
||||
allItems[newIndex].isActive = true
|
||||
nextTick(scrollActiveItemIntoView)
|
||||
}
|
||||
|
||||
const scrollActiveItemIntoView = () => {
|
||||
const activeItem = document.querySelector(
|
||||
'.hover\\:bg-surface-gray-2.bg-surface-gray-2'
|
||||
) as HTMLElement
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutForEnter = () => {
|
||||
let currentList = query.value.length
|
||||
? searchResults.value
|
||||
: jumpToOptions.value
|
||||
let allItems = currentList.flatMap((result: any) => result.items)
|
||||
let activeOption = allItems.find((option) => option.isActive)
|
||||
if (activeOption) {
|
||||
navigateTo(activeOption.route)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateTo = (route: {
|
||||
name: string
|
||||
params?: Record<string, any>
|
||||
query?: Record<string, any>
|
||||
}) => {
|
||||
show.value = false
|
||||
query.value = ''
|
||||
router.replace({ name: route.name, params: route.params, query: route.query })
|
||||
}
|
||||
|
||||
const jumpToOptions = ref([
|
||||
{
|
||||
title: __('Jump to'),
|
||||
items: [
|
||||
{
|
||||
title: 'Advanced Search',
|
||||
icon: Search,
|
||||
route: {
|
||||
name: 'Search',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
title: 'Courses',
|
||||
icon: BookOpen,
|
||||
route: {
|
||||
name: 'Courses',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
title: 'Batches',
|
||||
icon: Users,
|
||||
route: {
|
||||
name: 'Batches',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
title: 'Jobs',
|
||||
icon: Briefcase,
|
||||
route: {
|
||||
name: 'Jobs',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
<style>
|
||||
mark {
|
||||
background-color: theme('colors.amber.100');
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div v-for="result in list" class="px-2.5 space-y-2">
|
||||
<div class="text-ink-gray-5 px-2">
|
||||
{{ result.title }}
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
v-for="item in result.items"
|
||||
class="flex items-center justify-between p-2 rounded hover:bg-surface-gray-2 cursor-pointer"
|
||||
:class="{ 'bg-surface-gray-2': item.isActive }"
|
||||
@click="emit('navigateTo', item.route)"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="size-4 stroke-1.5 text-ink-gray-6"
|
||||
/>
|
||||
<div v-html="item.title"></div>
|
||||
</div>
|
||||
<div v-if="item.modified" class="text-ink-gray-5">
|
||||
{{ dayjs.unix(item.modified).fromNow(true) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
const emit = defineEmits(['navigateTo'])
|
||||
|
||||
const props = defineProps<{
|
||||
list: Array<{
|
||||
title: string
|
||||
items: Array<{
|
||||
title: string
|
||||
icon?: any
|
||||
isActive?: boolean
|
||||
modified?: string
|
||||
}>
|
||||
}>
|
||||
}>()
|
||||
</script>
|
||||
@@ -48,7 +48,7 @@ const settingsStore = useSettings()
|
||||
|
||||
const sendMail = (close: Function) => {
|
||||
call('frappe.core.doctype.communication.email.make', {
|
||||
recipients: settingsStore.contactUsEmail?.data,
|
||||
recipients: settingsStore.settings?.data?.contact_us_email,
|
||||
subject: subject.value,
|
||||
content: message.value,
|
||||
send_email: true,
|
||||
|
||||
@@ -16,13 +16,18 @@
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
@click="
|
||||
() => {
|
||||
showOptions = !showOptions
|
||||
togglePopover()
|
||||
}
|
||||
"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center w-[90%]">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
class="block truncate text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
@@ -99,18 +104,17 @@
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col space-y-1 text-ink-gray-8">
|
||||
<div>
|
||||
{{ option.label }}
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value == option.label && option.description
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
option.description &&
|
||||
option.description != option.label
|
||||
"
|
||||
class="text-xs text-ink-gray-7"
|
||||
v-html="option.description"
|
||||
></div>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
@@ -120,7 +124,7 @@
|
||||
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
|
||||
{{ __('No results found') }}
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
@@ -284,7 +288,7 @@ const inputClasses = computed(() => {
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white 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-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
disabled: [
|
||||
|
||||
@@ -3,59 +3,67 @@
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="overflow-x-auto border rounded-md">
|
||||
<div
|
||||
class="grid items-center space-x-4 p-2 border-b"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<div class="overflow-visible border border-outline-gray-modals rounded-md">
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
v-for="(column, index) in columns"
|
||||
:key="index"
|
||||
class="text-sm text-ink-gray-5"
|
||||
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
{{ column }}
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="rowIndex"
|
||||
class="grid items-center space-x-4 p-2"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<template v-for="key in Object.keys(row)" :key="key">
|
||||
<input
|
||||
v-if="showKey(key)"
|
||||
v-model="row[key]"
|
||||
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="relative" ref="menuRef">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
||||
>
|
||||
<template #icon>
|
||||
<Ellipsis
|
||||
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||
v-for="(column, index) in columns"
|
||||
:key="index"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
<button
|
||||
@click="deleteRow(rowIndex)"
|
||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||
{{ column }}
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="rowIndex"
|
||||
class="grid items-center space-x-4 p-2"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<template v-for="key in Object.keys(row)" :key="key">
|
||||
<input
|
||||
v-if="showKey(key)"
|
||||
v-model="row[key]"
|
||||
class="py-1.5 px-2 w-full border-none bg-transparent text-ink-gray-8 focus:ring-0 focus:border focus:border-outline-gray-3 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<Ellipsis
|
||||
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
ref="menuRef"
|
||||
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
|
||||
:class="
|
||||
rowIndex == (rows?.length ?? 0) - 1
|
||||
? 'bottom-full mb-1'
|
||||
: 'top-full mt-1'
|
||||
"
|
||||
>
|
||||
<button
|
||||
@click="deleteRow(rowIndex)"
|
||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,17 +81,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const rows = defineModel<Cell[][]>()
|
||||
const rows = defineModel<Record<string, string>[]>()
|
||||
const menuRef = ref(null)
|
||||
const menuOpenIndex = ref<number | null>(null)
|
||||
const menuTopPosition = ref<string>('')
|
||||
const menuLeftPosition = ref('0px')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Cell[][]): void
|
||||
(e: 'update:modelValue', value: Record<string, string>[]): void
|
||||
}>()
|
||||
|
||||
type Cell = {
|
||||
@@ -93,19 +103,19 @@ type Cell = {
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: Cell[][]
|
||||
modelValue?: Record<string, string>[]
|
||||
columns?: string[]
|
||||
label?: string
|
||||
}>(),
|
||||
{
|
||||
columns: [],
|
||||
columns: () => [] as string[],
|
||||
}
|
||||
)
|
||||
|
||||
const columns = ref(props.columns)
|
||||
|
||||
watch(rows, () => {
|
||||
if (rows.value?.length < 1) {
|
||||
if (rows.value && rows.value.length < 1) {
|
||||
addRow()
|
||||
}
|
||||
})
|
||||
@@ -119,12 +129,25 @@ const addRow = () => {
|
||||
newRow[column.toLowerCase().split(' ').join('_')] = ''
|
||||
})
|
||||
rows.value.push(newRow)
|
||||
focusNewRowInput()
|
||||
emit('update:modelValue', rows.value)
|
||||
}
|
||||
|
||||
const focusNewRowInput = () => {
|
||||
nextTick(() => {
|
||||
const rowElements = document.querySelectorAll('.overflow-x-auto .grid')[
|
||||
rows.value!.length
|
||||
]
|
||||
const firstInput = rowElements.querySelector('input')
|
||||
if (firstInput) {
|
||||
;(firstInput as HTMLInputElement).focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleteRow = (index: number) => {
|
||||
rows.value.splice(index, 1)
|
||||
emit('update:modelValue', rows.value)
|
||||
rows.value?.splice(index, 1)
|
||||
emit('update:modelValue', rows.value ?? [])
|
||||
}
|
||||
|
||||
const getGridTemplateColumns = () => {
|
||||
@@ -133,7 +156,6 @@ const getGridTemplateColumns = () => {
|
||||
|
||||
const toggleMenu = (index: number, event: MouseEvent) => {
|
||||
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
|
||||
menuTopPosition.value = `${event.clientY + 10}px`
|
||||
}
|
||||
|
||||
onClickOutside(menuRef, () => {
|
||||
|
||||
@@ -107,7 +107,7 @@ async function setLanguageExtension() {
|
||||
if (!languageImport) return
|
||||
|
||||
const module = await languageImport()
|
||||
languageExtension.value = (module as any)[props.language]()
|
||||
languageExtension.value = (module as any)[props.language]?.()
|
||||
|
||||
if (props.completions) {
|
||||
const languageData = (module as any)[`${props.language}Language`]
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
:style="
|
||||
modelValue
|
||||
? {
|
||||
backgroundColor:
|
||||
theme.backgroundColor[modelValue.toLowerCase()][400],
|
||||
backgroundColor: getColor(
|
||||
modelValue.toLowerCase(),
|
||||
400
|
||||
),
|
||||
}
|
||||
: {}
|
||||
"
|
||||
@@ -55,8 +57,7 @@
|
||||
:key="color"
|
||||
class="size-5 rounded-full cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
theme.backgroundColor[color.toLowerCase()][400],
|
||||
backgroundColor: getColor(color.toLowerCase(), 400),
|
||||
}"
|
||||
@click="
|
||||
(e) => {
|
||||
@@ -79,7 +80,7 @@
|
||||
import { Button, FormControl, Popover } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { Palette, X } from 'lucide-vue-next'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { getColor } from '@/utils'
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
class="w-4 h-4 text-ink-gray-7 stroke-1.5"
|
||||
:is="icons.Folder"
|
||||
/>
|
||||
<span v-if="selectedIcon">
|
||||
<span v-if="selectedIcon" class="text-ink-gray-7">
|
||||
{{ selectedIcon }}
|
||||
</span>
|
||||
<span v-else class="text-ink-gray-5">
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:filterable="false"
|
||||
:readonly="attrs.readonly"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
@@ -67,6 +66,7 @@ import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@@ -96,13 +96,14 @@ const value = computed({
|
||||
set: (val) => {
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
@@ -121,6 +122,16 @@ watchDebounced(
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => settingsStore.isSettingsOpen,
|
||||
(isOpen, wasOpen) => {
|
||||
if (wasOpen && !isOpen) {
|
||||
reload('')
|
||||
}
|
||||
},
|
||||
{ debounce: 200 }
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value],
|
||||
|
||||
@@ -1,160 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||
<label v-if="label" class="block mb-1" :class="labelClasses">
|
||||
{{ label }}
|
||||
<span class="text-ink-red-3" v-if="required">*</span>
|
||||
<span v-if="required" class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<div class="w-full">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full focus-visible:!ring-0"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen, close }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full focus-visible:!ring-0"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
<ComboboxOptions
|
||||
v-show="open"
|
||||
static
|
||||
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal border-2 border-outline-gray-modals max-h-[13rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||
>
|
||||
<template v-if="options.length">
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<div class="h-10"></div>
|
||||
<div
|
||||
v-if="attrs.onCreate"
|
||||
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value === option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</template>
|
||||
|
||||
<div v-else class="text-ink-gray-7 px-4 py-2">
|
||||
{{ __('No results found') }}
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attrs.onCreate"
|
||||
class="p-1 bg-surface-white border-t rounded-b-lg"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
|
||||
<!-- Selected values -->
|
||||
<div v-if="values?.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||
<div
|
||||
v-for="value in values"
|
||||
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
|
||||
:key="value"
|
||||
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
<span class="break-all">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span>{{ value }}</span>
|
||||
<X
|
||||
class="size-4 stroke-1.5 cursor-pointer"
|
||||
@click="removeValue(value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { createResource, Popover, Button } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { ref, computed, useAttrs, watch } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { X, Plus } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
label: String,
|
||||
size: { type: String, default: 'sm' },
|
||||
doctype: { type: String, required: true },
|
||||
filters: { type: Object, default: () => ({}) },
|
||||
validate: Function,
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
},
|
||||
required: Boolean,
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
const attrs = useAttrs()
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const trigger = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const showOptions = ref(false)
|
||||
const selectedValue = ref(null)
|
||||
const error = ref(null)
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
val?.value && addValue(val.value)
|
||||
},
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
watch(selectedValue, (val) => {
|
||||
if (!val?.value) return
|
||||
query.value = ''
|
||||
addValue(val.value)
|
||||
selectedValue.value = null
|
||||
emit('update:modelValue', values.value)
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
@@ -171,7 +156,6 @@ watchDebounced(
|
||||
const filterOptions = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
method: 'POST',
|
||||
cache: [text.value, props.doctype],
|
||||
auto: true,
|
||||
params: {
|
||||
txt: text.value,
|
||||
@@ -180,7 +164,8 @@ const filterOptions = createResource({
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
return filterOptions.data || []
|
||||
const allOptions = filterOptions.data || []
|
||||
return allOptions.filter((option) => !values.value?.includes(option.value))
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
@@ -193,70 +178,46 @@ function reload(val) {
|
||||
filterOptions.reload()
|
||||
}
|
||||
|
||||
const addValue = (value) => {
|
||||
function onFocus() {
|
||||
if (!filterOptions.data?.length) {
|
||||
reload('')
|
||||
}
|
||||
trigger.value?.$el.click()
|
||||
}
|
||||
|
||||
function addValue(value) {
|
||||
error.value = null
|
||||
if (value) {
|
||||
const splitValues = value.split(',')
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim()
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value)
|
||||
return
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value]
|
||||
} else {
|
||||
values.value.push(value)
|
||||
}
|
||||
value = value.replace(value, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
!error.value && (value = '')
|
||||
|
||||
if (!value) return
|
||||
|
||||
const splitValues = value.split(',')
|
||||
|
||||
splitValues.forEach((val) => {
|
||||
val = val.trim()
|
||||
|
||||
if (!val) return
|
||||
if (values.value?.includes(val)) return
|
||||
|
||||
if (props.validate && !props.validate(val)) {
|
||||
error.value = props.errorMessage(val)
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.value) values.value = [val]
|
||||
else values.value.push(val)
|
||||
})
|
||||
}
|
||||
|
||||
function removeValue(value) {
|
||||
let indexToRemove = values.value.indexOf(value)
|
||||
if (indexToRemove > -1) {
|
||||
values.value.splice(indexToRemove, 1)
|
||||
}
|
||||
emit('update:modelValue', values.value)
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[props.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
const labelClasses = computed(() => [
|
||||
{ sm: 'text-xs', md: 'text-base' }[props.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
<div class="mb-4">
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
<span v-if="required" class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!modelValue"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file: File) => saveImage(file)"
|
||||
:fileTypes="[fileType]"
|
||||
:validateFile="(file: File) => validateFile(file, true, type)"
|
||||
@success="(file: File) => saveFile(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-7 px-20">
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
<component
|
||||
:is="props.type === 'image' ? Image : Video"
|
||||
class="size-5 stroke-1 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
@@ -28,7 +31,20 @@
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
|
||||
<img
|
||||
v-if="type == 'image'"
|
||||
:src="modelValue"
|
||||
:class="[
|
||||
'border object-cover',
|
||||
shape === 'circle'
|
||||
? 'w-20 h-20 rounded-full'
|
||||
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
|
||||
]"
|
||||
/>
|
||||
<video v-else controls class="border rounded-md w-44 h-auto">
|
||||
<source :src="modelValue" />
|
||||
{{ __('Your browser does not support the video tag.') }}
|
||||
</video>
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
@@ -47,7 +63,8 @@
|
||||
<script setup lang="ts">
|
||||
import { validateFile } from '@/utils'
|
||||
import { Button, FileUploader } from 'frappe-ui'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
import { Image, Video } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
@@ -55,18 +72,28 @@ const emit = defineEmits<{
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
modelValue: string | null
|
||||
label?: string
|
||||
description?: string
|
||||
type?: 'image' | 'video'
|
||||
required?: boolean
|
||||
shape?: 'square' | 'circle'
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: '',
|
||||
description: '',
|
||||
type: 'image',
|
||||
required: true,
|
||||
shape: 'square',
|
||||
}
|
||||
)
|
||||
|
||||
const saveImage = (file: any) => {
|
||||
const fileType = computed(() => {
|
||||
return props.type === 'image' ? 'image/*' : 'video/*'
|
||||
})
|
||||
|
||||
const saveFile = (file: any) => {
|
||||
emit('update:modelValue', file.file_url)
|
||||
}
|
||||
|
||||
|
||||
@@ -136,11 +136,11 @@
|
||||
import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { formatAmount } from '@/utils'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import colors from '@/utils/frappe-ui-colors.json'
|
||||
|
||||
const { user } = sessionStore()
|
||||
|
||||
@@ -152,19 +152,10 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const getGradientColor = () => {
|
||||
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||
let colorMap = theme.backgroundColor[color]
|
||||
let colorMap = colors[theme][color]
|
||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
||||
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
||||
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
||||
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
||||
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
||||
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
||||
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
||||
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
||||
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<CertificationLinks :courseName="course.data.name" class="w-full" />
|
||||
</div>
|
||||
<router-link
|
||||
v-else-if="course.data.paid_course"
|
||||
v-else-if="course.data.paid_course && !isAdmin"
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
@@ -56,14 +56,15 @@
|
||||
</Button>
|
||||
</router-link>
|
||||
<Badge
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
v-else-if="course.data.disable_self_learning && !isAdmin"
|
||||
theme="blue"
|
||||
size="lg"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
{{ __('Contact the Administrator to enroll for this course') }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
||||
v-else-if="!isAdmin"
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
@@ -88,35 +89,6 @@
|
||||
</template>
|
||||
{{ __('Get Certificate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
class="w-full mt-2"
|
||||
size="md"
|
||||
@click="showProgressSummary"
|
||||
>
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
{{ __('Progress Summary') }}
|
||||
</template>
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="user?.data?.is_moderator || is_instructor()"
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: course.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
@@ -168,12 +140,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseProgressSummary
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
v-model="showProgressModal"
|
||||
:courseName="course.data.name"
|
||||
:enrollments="course.data.enrollments"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -189,15 +155,14 @@ import {
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||
import { formatAmount } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const showProgressModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
@@ -215,13 +180,17 @@ const video_link = computed(() => {
|
||||
|
||||
function enrollStudent() {
|
||||
if (!user.data) {
|
||||
toast.success(__('You need to login first to enroll for this course'))
|
||||
toast.warning(__('You need to login first to enroll for this course'))
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 500)
|
||||
} else {
|
||||
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
|
||||
course: props.course.data.name,
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Enrollment',
|
||||
course: props.course.data.name,
|
||||
member: user.data.name,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
capture('enrolled_in_course', {
|
||||
@@ -290,7 +259,7 @@ const fetchCertificate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const showProgressSummary = () => {
|
||||
showProgressModal.value = true
|
||||
}
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || is_instructor()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||
{{ __('Add Chapter') }}
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
@@ -95,8 +98,8 @@
|
||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.number.split('.')[0],
|
||||
lessonNumber: lesson.number.split('.')[1],
|
||||
chapterNumber: lesson.number.split('-')[0],
|
||||
lessonNumber: lesson.number.split('-')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
@@ -109,6 +112,14 @@
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<NotebookPen
|
||||
v-else-if="lesson.icon === 'icon-assignment'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<SquareCode
|
||||
v-else-if="lesson.icon === 'icon-code'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||
@@ -174,7 +185,11 @@ import {
|
||||
FilePenLine,
|
||||
HelpCircle,
|
||||
MonitorPlay,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
SquareCode,
|
||||
Trash2,
|
||||
Notebook,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||
@@ -389,8 +404,8 @@ const redirectToChapter = (chapter) => {
|
||||
|
||||
const isActiveLesson = (lessonNumber) => {
|
||||
return (
|
||||
route.params.chapterNumber == lessonNumber.split('.')[0] &&
|
||||
route.params.lessonNumber == lessonNumber.split('.')[1]
|
||||
route.params.chapterNumber == lessonNumber.split('-')[0] &&
|
||||
route.params.lessonNumber == lessonNumber.split('-')[1]
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div class="grid gap-8 mt-10">
|
||||
<div v-for="(review, index) in reviews.data">
|
||||
<div class="flex items-center">
|
||||
<div class="flex">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -46,11 +46,11 @@
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
type: Object,
|
||||
type: Object || null,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppSidebar from '@/components/Sidebar/AppSidebar.vue'
|
||||
</script>
|
||||
|
||||
@@ -93,11 +93,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
TextEditor,
|
||||
Button,
|
||||
Dropdown,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const showTopics = defineModel('showTopics')
|
||||
const newReply = ref('')
|
||||
@@ -107,6 +115,7 @@ const allUsers = inject('$allUsers')
|
||||
const mentionUsers = ref([])
|
||||
const renderEditor = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
topic: {
|
||||
@@ -143,19 +152,6 @@ const replies = createResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const newReplyResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
reply: newReply.value,
|
||||
topic: props.topic.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const fetchMentionUsers = () => {
|
||||
if (user.data?.is_student) {
|
||||
renderEditor.value = true
|
||||
@@ -178,78 +174,61 @@ const fetchMentionUsers = () => {
|
||||
}
|
||||
|
||||
const postReply = () => {
|
||||
newReplyResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!newReply.value) {
|
||||
return 'Reply cannot be empty'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
newReply.value = ''
|
||||
replies.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editReplyResource = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
if (!newReply.value) {
|
||||
toast.error(__('Reply cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
name: values.name,
|
||||
fieldname: 'reply',
|
||||
value: values.reply,
|
||||
}
|
||||
},
|
||||
})
|
||||
reply: newReply.value,
|
||||
topic: props.topic.name,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
newReply.value = ''
|
||||
replies.reload()
|
||||
capture('discussion_reply_created')
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const postEdited = (reply) => {
|
||||
editReplyResource.submit(
|
||||
{
|
||||
name: reply.name,
|
||||
reply: reply.reply,
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
if (!reply.reply) {
|
||||
return 'Reply cannot be empty'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
reply.editable = false
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!reply.reply) {
|
||||
toast.error(__('Reply cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.set_value', {
|
||||
doctype: 'Discussion Reply',
|
||||
name: reply.name,
|
||||
fieldname: 'reply',
|
||||
value: reply.reply,
|
||||
})
|
||||
.then(() => {
|
||||
reply.editable = false
|
||||
replies.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteReplyResource = createResource({
|
||||
url: 'frappe.client.delete',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Discussion Reply',
|
||||
name: values.name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteReply = (reply) => {
|
||||
deleteReplyResource.submit(
|
||||
{
|
||||
name: reply.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
call('frappe.client.delete', {
|
||||
doctype: 'Discussion Reply',
|
||||
name: reply.name,
|
||||
})
|
||||
.then(() => {
|
||||
replies.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Dialog v-model="showDialog">
|
||||
<template #body-title>
|
||||
<h2 class="text-lg font-bold">{{ __('Install Frappe Learning') }}</h2>
|
||||
<h2 class="text-lg font-bold">{{ __('Установить приложение IIE') }}</h2>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<p>
|
||||
@@ -29,7 +29,7 @@
|
||||
class="mb-1 flex flex-row items-center justify-between px-3 text-center"
|
||||
>
|
||||
<span class="text-base font-bold text-gray-900">
|
||||
{{ __('Install Frappe Learning') }}
|
||||
{{ __('Установить приложение IIE') }}
|
||||
</span>
|
||||
<span class="inline-flex items-baseline">
|
||||
<FeatherIcon
|
||||
@@ -76,7 +76,14 @@ const isIos = () => {
|
||||
const isInStandaloneMode = () =>
|
||||
'standalone' in window.navigator && window.navigator.standalone
|
||||
|
||||
if (isIos() && !isInStandaloneMode()) iosInstallMessage.value = true
|
||||
if (
|
||||
isIos() &&
|
||||
!isInStandaloneMode() &&
|
||||
localStorage.getItem('learningIosInstallPromptShown') !== 'true'
|
||||
) {
|
||||
iosInstallMessage.value = true
|
||||
localStorage.setItem('learningIosInstallPromptShown', 'true')
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasPermission() && !props.zoomAccount"
|
||||
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
|
||||
>
|
||||
<AlertCircle class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Please add a zoom account to the batch to create live classes.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="liveClasses.data?.length"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
:class="{
|
||||
'cursor-pointer': hasPermission() && cls.attendees > 0,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
openAttendanceModal(cls)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
:batch="props.batch"
|
||||
:zoomAccount="props.zoomAccount"
|
||||
v-model="showLiveClassModal"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Calendar,
|
||||
Video,
|
||||
Monitor,
|
||||
Info,
|
||||
AlertCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const showAttendance = ref(false)
|
||||
const attendanceFor = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: String,
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch,
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'duration',
|
||||
'attendees',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!props.zoomAccount) return false
|
||||
return hasPermission()
|
||||
}
|
||||
|
||||
const hasPermission = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const canAccessClass = (cls) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const openAttendanceModal = (cls) => {
|
||||
if (!hasPermission()) return
|
||||
if (cls.attendees <= 0) return
|
||||
showAttendance.value = true
|
||||
attendanceFor.value = cls
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -76,12 +76,20 @@ const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
// Вызываем addSideBar только если userResource уже загружен
|
||||
if (userResource.data) {
|
||||
addSideBar()
|
||||
}
|
||||
addOtherLinks()
|
||||
filterLinksToShow(data)
|
||||
sidebarSettings.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (userResource.data) {
|
||||
addSideBar()
|
||||
} else {
|
||||
destructureSidebarLinks()
|
||||
}
|
||||
filterLinksToShow(data)
|
||||
addOtherLinks()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const handleOutsideClick = (e) => {
|
||||
@@ -100,6 +108,16 @@ watch(showMenu, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
const destructureSidebarLinks = () => {
|
||||
let links = []
|
||||
sidebarLinks.value.forEach((link) => {
|
||||
link.items?.forEach((item) => {
|
||||
links.push(item)
|
||||
})
|
||||
})
|
||||
sidebarLinks.value = links
|
||||
}
|
||||
|
||||
const filterLinksToShow = (data) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (!parseInt(data[key])) {
|
||||
|
||||
@@ -20,11 +20,15 @@
|
||||
:options="assessmentTypes"
|
||||
v-model="assessmentType"
|
||||
:label="__('Type')"
|
||||
placeholder=" "
|
||||
@update:modelValue="() => (assessment = null)"
|
||||
/>
|
||||
<Link
|
||||
v-if="assessmentType"
|
||||
v-model="assessment"
|
||||
:doctype="assessmentType"
|
||||
:label="__('Assessment')"
|
||||
placeholder=" "
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
close()
|
||||
@@ -49,7 +53,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
:label="__('Submission Type')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="assignment.course"
|
||||
:label="__('Course')"
|
||||
doctype="LMS Course"
|
||||
placeholder=" "
|
||||
/>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Question') }}
|
||||
@@ -37,7 +43,7 @@
|
||||
@change="(val) => (assignment.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,6 +72,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { escapeHTML, sanitizeHTML } from '@/utils'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
|
||||
const show = defineModel()
|
||||
const assignments = defineModel<Assignments>('assignments')
|
||||
@@ -74,6 +82,7 @@ interface Assignment {
|
||||
title: string
|
||||
type: string
|
||||
question: string
|
||||
course?: string
|
||||
}
|
||||
|
||||
interface Assignments {
|
||||
@@ -88,6 +97,7 @@ const assignment = reactive({
|
||||
title: '',
|
||||
type: '',
|
||||
question: '',
|
||||
course: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
@@ -106,6 +116,7 @@ watch(
|
||||
assignment.title = row.title
|
||||
assignment.type = row.type
|
||||
assignment.question = row.question
|
||||
assignment.course = row.course || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -121,35 +132,49 @@ watch(show, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
assignment.title = escapeHTML(assignment.title.trim())
|
||||
assignment.question = sanitizeHTML(assignment.question)
|
||||
}
|
||||
|
||||
const saveAssignment = () => {
|
||||
validateFields()
|
||||
if (props.assignmentID == 'new') {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
createAssignment()
|
||||
} else {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
updateAssignment()
|
||||
}
|
||||
}
|
||||
|
||||
const createAssignment = () => {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateAssignment = () => {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const assignmentOptions = computed(() => {
|
||||
return [
|
||||
{ label: 'PDF', value: 'PDF' },
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a course'),
|
||||
size: 'sm',
|
||||
title: __('Add a course to the batch'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Submit'),
|
||||
@@ -19,14 +19,13 @@
|
||||
v-model="course"
|
||||
:label="__('Course')"
|
||||
:required="true"
|
||||
:filters="{ published: 1 }"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
close()
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: 'new',
|
||||
},
|
||||
name: 'Courses',
|
||||
query: { newCourse: '1' },
|
||||
})
|
||||
}
|
||||
"
|
||||
@@ -42,7 +41,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||
import { Dialog, toast } from 'frappe-ui'
|
||||
import { ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
@@ -64,37 +63,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const createBatchCourse = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Batch Course',
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addCourse = (close) => {
|
||||
createBatchCourse.submit(
|
||||
{},
|
||||
courses.value.insert.submit(
|
||||
{
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_course')
|
||||
|
||||
close()
|
||||
courses.value.reload()
|
||||
course.value = null
|
||||
evaluator.value = null
|
||||
toast.success(__('Course added to batch successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.log(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-5 space-y-10 text-base">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar :image="student.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ student.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="
|
||||
Object.keys(student.assessments).length ||
|
||||
Object.keys(student.courses).length
|
||||
"
|
||||
:theme="student.progress === 100 ? 'green' : 'red'"
|
||||
>
|
||||
{{ student.progress }}% {{ __('Complete') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ student.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Assessments -->
|
||||
<div
|
||||
v-if="Object.keys(student.assessments).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Assessment') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Percentage/Status') }}
|
||||
</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="assessment in Object.keys(student.assessments)"
|
||||
class="flex items-center text-ink-gray-7 font-medium"
|
||||
:to="{
|
||||
name:
|
||||
student.assessments[assessment].type == 'LMS Assignment'
|
||||
? 'AssignmentSubmission'
|
||||
: '',
|
||||
params:
|
||||
student.assessments[assessment].type == 'LMS Assignment'
|
||||
? {
|
||||
assignmentID:
|
||||
student.assessments[assessment].assessment,
|
||||
submissionName:
|
||||
student.assessments[assessment].submission,
|
||||
}
|
||||
: {},
|
||||
}"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ assessment }}
|
||||
</span>
|
||||
<span v-if="isAssignment(student.assessments[assessment].status)">
|
||||
<Badge
|
||||
:theme="
|
||||
getStatusTheme(student.assessments[assessment].status)
|
||||
"
|
||||
>
|
||||
{{ student.assessments[assessment].status }}
|
||||
</Badge>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ student.assessments[assessment].status }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Courses -->
|
||||
<div
|
||||
v-if="Object.keys(student.courses).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Courses') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Progress') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="course in Object.keys(student.courses)"
|
||||
class="flex items-center text-ink-gray-7 font-medium"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ course }}
|
||||
</span>
|
||||
<span>
|
||||
{{ Math.floor(student.courses[course]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap -->
|
||||
<StudentHeatmap :member="student.email" :days="120" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Avatar, Badge, Dialog } from 'frappe-ui'
|
||||
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const props = defineProps({
|
||||
student: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const isAssignment = (value) => {
|
||||
return isNaN(value)
|
||||
}
|
||||
|
||||
const getStatusTheme = (status) => {
|
||||
if (status === 'Pass') {
|
||||
return 'green'
|
||||
} else if (status == 'Not Graded') {
|
||||
return 'orange'
|
||||
} else {
|
||||
return 'red'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -80,13 +80,13 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { reactive, watch, inject } from 'vue'
|
||||
import { getFileSize } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const show = defineModel()
|
||||
const outline = defineModel('outline')
|
||||
const user = inject('$user')
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Course Progress Summary'),
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div
|
||||
class="flex flex-col-reverse md:flex-row justify-between md:space-x-10 text-base mt-10"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-[70vh] overflow-y-auto">
|
||||
<ListView
|
||||
v-if="progressList.loading || progressList.data?.length"
|
||||
:columns="progressColumns"
|
||||
:rows="progressList.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in progressColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
:name="item.icon?.toString()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in progressList.data">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="progressList.data && progressList.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="progressList.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 self-start w-full space-y-5">
|
||||
<div
|
||||
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
|
||||
>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Enrollments'),
|
||||
value: memberCount || 0,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Average Progress %'),
|
||||
value: chartDetails.data?.average_progress || 0,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart
|
||||
:config="{
|
||||
data: chartDetails.data?.progress_distribution || [],
|
||||
title: __('Progress Distribution'),
|
||||
categoryColumn: 'category',
|
||||
valueColumn: 'count',
|
||||
colors: [
|
||||
theme.colors.red['400'],
|
||||
theme.colors.amber['400'],
|
||||
theme.colors.pink['400'],
|
||||
theme.colors.blue['400'],
|
||||
theme.colors.green['400'],
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
DonutChart,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
NumberChart,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
courseName?: string
|
||||
enrollments?: number
|
||||
}>()
|
||||
|
||||
const memberCount = ref<number>(props.enrollments || 0)
|
||||
|
||||
const chartDetails = createResource({
|
||||
url: 'lms.lms.api.get_course_progress_distribution',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const progressList = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
course: props.courseName,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'member_username',
|
||||
'progress',
|
||||
],
|
||||
pageLength: 50,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.courseName,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
filters: filters,
|
||||
})
|
||||
progressList.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data: any[]) {
|
||||
memberCount.value = filterApplied ? data.length : props.enrollments || 0
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: '60%',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
align: 'right',
|
||||
icon: 'trending-up',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -26,7 +26,7 @@
|
||||
@change="(val) => (topic.reply = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,17 +34,13 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
import { singularize } from '@/utils'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const topics = defineModel('reloadTopics')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -66,64 +62,50 @@ const topic = reactive({
|
||||
reply: '',
|
||||
})
|
||||
|
||||
const topicResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Topic',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
title: topic.title,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const replyResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
topic: values.topic,
|
||||
reply: topic.reply,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitTopic = (close) => {
|
||||
topicResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!topic.title) {
|
||||
return 'Title cannot be empty.'
|
||||
}
|
||||
if (!topic.reply) {
|
||||
return 'Reply cannot be empty.'
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
replyResource.submit(
|
||||
{
|
||||
topic: data.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
topic.title = ''
|
||||
topic.reply = ''
|
||||
topics.value.reload()
|
||||
close()
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!topic.title) {
|
||||
toast.error(__('Title cannot be empty.'))
|
||||
return
|
||||
}
|
||||
if (!topic.reply) {
|
||||
toast.error(__('Details cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Topic',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
title: topic.title,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
createReply(data.name, close)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const createReply = (topicName, close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
topic: topicName,
|
||||
reply: topic.reply,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
topic.title = ''
|
||||
topic.reply = ''
|
||||
topics.value.reload()
|
||||
capture('discussion_topic_created')
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,91 +1,85 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Edit your profile'),
|
||||
size: '3xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => saveProfile(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-header>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('Edit Profile') }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="saveProfile()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-4">
|
||||
<!-- <Uploader
|
||||
v-model="profile.image.file_url"
|
||||
label="Profile Image"
|
||||
description="Your profile image to help others recognize you."
|
||||
/> -->
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="profile.image.file_url"
|
||||
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<Uploader
|
||||
v-model="profile.image"
|
||||
:label="__('Profile Image')"
|
||||
:required="true"
|
||||
shape="circle"
|
||||
/>
|
||||
|
||||
<div class="text-base flex flex-col ml-2">
|
||||
<span>
|
||||
{{ profile.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
{{ getFileSize(profile.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.last_name"
|
||||
:label="__('Last Name')"
|
||||
/>
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
|
||||
<FormControl
|
||||
v-model="profile.linkedin"
|
||||
:label="__('LinkedIn ID')"
|
||||
/>
|
||||
<FormControl v-model="profile.github" :label="__('GitHub ID')" />
|
||||
<FormControl
|
||||
v-model="profile.twitter"
|
||||
:label="__('Twitter ID')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl v-model="profile.first_name" :label="__('First Name')" />
|
||||
<FormControl v-model="profile.last_name" :label="__('Last Name')" />
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
<Link
|
||||
:label="__('Language')"
|
||||
v-model="profile.language"
|
||||
doctype="Language"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="profile.open_to"
|
||||
type="select"
|
||||
:options="[' ', 'Work', 'Hiring']"
|
||||
:label="__('Open to')"
|
||||
:placeholder="__('Looking for new work or hiring talent?')"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Language')"
|
||||
v-model="profile.language"
|
||||
doctype="Language"
|
||||
/>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
:rows="15"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[280px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,22 +88,22 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { getFileSize, decodeEntities } from '@/utils'
|
||||
import { sanitizeHTML } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const show = defineModel()
|
||||
const reloadProfile = defineModel('reloadProfile')
|
||||
const hasLanguageChanged = ref(false)
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
@@ -124,19 +118,10 @@ const profile = reactive({
|
||||
headline: '',
|
||||
bio: '',
|
||||
image: '',
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
profile.image = data
|
||||
},
|
||||
open_to: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
twitter: '',
|
||||
})
|
||||
|
||||
const updateProfile = createResource({
|
||||
@@ -146,7 +131,7 @@ const updateProfile = createResource({
|
||||
doctype: 'User',
|
||||
name: props.profile.data.name,
|
||||
fieldname: {
|
||||
user_image: profile.image.file_url,
|
||||
user_image: profile.image || null,
|
||||
...profile,
|
||||
},
|
||||
}
|
||||
@@ -156,28 +141,13 @@ const updateProfile = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const saveProfile = (close) => {
|
||||
profile.bio = DOMPurify.sanitize(decodeEntities(profile.bio), {
|
||||
ALLOWED_TAGS: [
|
||||
'b',
|
||||
'i',
|
||||
'em',
|
||||
'strong',
|
||||
'a',
|
||||
'p',
|
||||
'br',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'img',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'src'],
|
||||
})
|
||||
const saveProfile = () => {
|
||||
profile.bio = sanitizeHTML(profile.bio)
|
||||
updateProfile.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
show.value = false
|
||||
reloadProfile.value.reload()
|
||||
if (hasLanguageChanged.value) {
|
||||
hasLanguageChanged.value = false
|
||||
@@ -191,20 +161,26 @@ const saveProfile = (close) => {
|
||||
)
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
profile.image = file
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
profile.image = null
|
||||
}
|
||||
watch(
|
||||
() => profile,
|
||||
(newVal) => {
|
||||
if (!props.profile.data) return
|
||||
let keys = Object.keys(newVal)
|
||||
keys.splice(keys.indexOf('image'), 1)
|
||||
for (let key of keys) {
|
||||
if (newVal[key] !== props.profile.data[key]) {
|
||||
isDirty.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if (profile.image !== props.profile.data.user_image) {
|
||||
isDirty.value = true
|
||||
return
|
||||
}
|
||||
isDirty.value = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.profile.data,
|
||||
@@ -215,15 +191,20 @@ watch(
|
||||
profile.headline = newVal.headline
|
||||
profile.language = newVal.language
|
||||
profile.bio = newVal.bio
|
||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||
profile.open_to = newVal.open_to
|
||||
profile.linkedin = newVal.linkedin
|
||||
profile.github = newVal.github
|
||||
profile.twitter = newVal.twitter
|
||||
profile.image = newVal.user_image
|
||||
isDirty.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => profile.language,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
() => {
|
||||
if (profile.language !== props.profile.data.language) {
|
||||
hasLanguageChanged.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
:rows="10"
|
||||
:placeholder="
|
||||
__(
|
||||
'<p>Dear {{ member_name }},</p>\n\n<p>You have been enrolled in our upcoming batch {{ batch_name }}.</p>\n\n<p>Thanks,</p>\n<p>Frappe Learning</p>'
|
||||
'<p>Dear {{ member_name }},</p>\n\n<p>You have been enrolled in our upcoming batch {{ batch_name }}.</p>\n\n<p>Thanks,</p>\n<p>IIE</p>'
|
||||
)
|
||||
"
|
||||
/>
|
||||
@@ -64,10 +64,10 @@
|
||||
:fixedMenu="true"
|
||||
:placeholder="
|
||||
__(
|
||||
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
|
||||
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nIIE'
|
||||
)
|
||||
"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Schedule Evaluation'),
|
||||
title: __('Schedule your evaluation'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
@@ -14,64 +14,71 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Course') }}
|
||||
<div class="flex flex-col gap-4 text-base max-h-[60vh]">
|
||||
<FormControl
|
||||
v-model="evaluation.course"
|
||||
type="select"
|
||||
:label="__('Course')"
|
||||
:options="getCourses()"
|
||||
/>
|
||||
<div v-if="slots.data?.length" class="space-y-4 overflow-y-auto mt-4">
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ __('Available Slots') }}
|
||||
</div>
|
||||
<Select v-model="evaluation.course" :options="getCourses()" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Date') }}
|
||||
</div>
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="evaluation.date"
|
||||
:min="
|
||||
dayjs()
|
||||
.add(dayjs.duration({ days: 1 }))
|
||||
.format('YYYY-MM-DD')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="slots.data?.length">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Select a slot') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div v-for="slot in slots.data">
|
||||
<div
|
||||
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
|
||||
@click="saveSlot(slot)"
|
||||
:class="{
|
||||
'border-outline-gray-4':
|
||||
evaluation.start_time == slot.start_time,
|
||||
}"
|
||||
>
|
||||
{{ formatTime(slot.start_time) }} -
|
||||
{{ formatTime(slot.end_time) }}
|
||||
<div class="space-y-5">
|
||||
<div v-for="row in slots.data" class="space-y-2">
|
||||
<div class="flex items-center text-ink-gray-7 space-x-2">
|
||||
<Calendar class="size-3" />
|
||||
<div class="text-ink-gray-9">
|
||||
{{ dayjs(row.date).format('DD MMMM YYYY') }}
|
||||
</div>
|
||||
<div>·</div>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ row.day }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="slot in row.slots"
|
||||
class="text-base text-center border rounded-md text-ink-gray-8 p-2 cursor-pointer text-ink-gray-7 hover:bg-surface-gray-2 hover:border-outline-gray-3"
|
||||
@click="saveSlot(slot, row)"
|
||||
:class="{
|
||||
'border-outline-gray-4 text-ink-gray-9':
|
||||
evaluation.date == row.date &&
|
||||
evaluation.start_time == slot.start_time,
|
||||
}"
|
||||
>
|
||||
{{ formatTime(slot.start_time) }} -
|
||||
{{ formatTime(slot.end_time) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="evaluation.course && evaluation.date"
|
||||
class="text-sm italic text-ink-red-4"
|
||||
>
|
||||
{{ __('No slots available for this date.') }}
|
||||
<div v-else-if="!evaluation.course" class="text-ink-gray-7">
|
||||
{{ __('Please select a course to view available slots.') }}
|
||||
</div>
|
||||
<div v-else class="text-ink-red-3">
|
||||
{{ __('No slots available for the selected course.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui'
|
||||
import { reactive, watch, inject } from 'vue'
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
dayjs,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const show = defineModel()
|
||||
const evaluations = defineModel('reloadEvals')
|
||||
|
||||
@@ -90,7 +97,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const evaluation = reactive({
|
||||
const evaluation = ref({
|
||||
course: '',
|
||||
date: '',
|
||||
start_time: '',
|
||||
@@ -100,48 +107,28 @@ const evaluation = reactive({
|
||||
member: user.data.name,
|
||||
})
|
||||
|
||||
const createEvaluation = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Certificate Request',
|
||||
batch_name: values.batch,
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function submitEvaluation(close) {
|
||||
createEvaluation.submit(evaluation, {
|
||||
validate() {
|
||||
if (!evaluation.course) {
|
||||
return 'Please select a course.'
|
||||
}
|
||||
if (!evaluation.date) {
|
||||
return 'Please select a date.'
|
||||
}
|
||||
if (!evaluation.start_time) {
|
||||
return 'Please select a slot.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
|
||||
return 'Please select a future date.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
||||
return `Please select a date before the end date ${dayjs(
|
||||
props.endDate
|
||||
).format('DD MMMM YYYY')}.`
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
evaluations.value.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
if (!evaluation.value.date || !evaluation.value.start_time) {
|
||||
toast.warning(__('Please select a slot for your evaluation.'), {
|
||||
duration: 10,
|
||||
})
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Certificate Request',
|
||||
batch_name: evaluation.value.batch,
|
||||
...evaluation.value,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
evaluations.value.reload()
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err.messages?.[0] || err)
|
||||
toast.warning(__(err.messages?.[0] || err), { duration: 20 })
|
||||
})
|
||||
}
|
||||
|
||||
const getCourses = () => {
|
||||
@@ -156,7 +143,7 @@ const getCourses = () => {
|
||||
}
|
||||
|
||||
if (courses.length === 1) {
|
||||
evaluation.course = courses[0].value
|
||||
evaluation.value.course = courses[0].value
|
||||
}
|
||||
|
||||
return courses
|
||||
@@ -167,34 +154,22 @@ const slots = createResource({
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: values.course,
|
||||
date: values.date,
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => evaluation.date,
|
||||
(date) => {
|
||||
evaluation.start_time = ''
|
||||
if (date && evaluation.course) {
|
||||
slots.submit(evaluation)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => evaluation.course,
|
||||
() => evaluation.value.course,
|
||||
(course) => {
|
||||
evaluation.date = ''
|
||||
evaluation.start_time = ''
|
||||
slots.reset()
|
||||
slots.reload(evaluation.value)
|
||||
}
|
||||
)
|
||||
|
||||
const saveSlot = (slot) => {
|
||||
evaluation.start_time = slot.start_time
|
||||
evaluation.end_time = slot.end_time
|
||||
evaluation.day = slot.day
|
||||
const saveSlot = (slot, row) => {
|
||||
evaluation.value.start_time = slot.start_time
|
||||
evaluation.value.end_time = slot.end_time
|
||||
evaluation.value.date = row.date
|
||||
evaluation.value.day = row.day
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Course')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('course', event.course)"
|
||||
>
|
||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ event.course_title }}
|
||||
@@ -30,7 +33,10 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="event.batch_title" :text="__('Batch')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('batch', event.batch_name)"
|
||||
>
|
||||
<Users class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ event.batch_title }}
|
||||
@@ -189,6 +195,8 @@ const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const tabIndex = ref(0)
|
||||
const showCertification = ref(false)
|
||||
const evaluation = reactive({})
|
||||
const certificate = reactive({})
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
@@ -197,9 +205,6 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const evaluation = reactive({})
|
||||
const certificate = reactive({})
|
||||
|
||||
watch(user, () => {
|
||||
if (userIsEvaluator()) {
|
||||
defaultTemplate.reload()
|
||||
@@ -335,7 +340,7 @@ const certificateDetails = createResource({
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
certificate.template = defaultTemplate.data.value
|
||||
certificate.template = defaultTemplate.data?.value
|
||||
},
|
||||
auto: false,
|
||||
})
|
||||
@@ -378,6 +383,16 @@ const openCertificate = (certificate) => {
|
||||
)
|
||||
}
|
||||
|
||||
const openLink = (type, name) => {
|
||||
let url = ''
|
||||
if (type === 'course') {
|
||||
url = `/lms/courses/${name}`
|
||||
} else if (type === 'batch') {
|
||||
url = `/lms/batches/${name}#students`
|
||||
}
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-5 min-h-[300px]">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Training Feedback') }}
|
||||
</div>
|
||||
<ListView
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 text-base">
|
||||
<p class="text-ink-gray-9">
|
||||
{{
|
||||
__(
|
||||
@@ -29,6 +29,7 @@
|
||||
<FileUploader
|
||||
:fileTypes="['.pdf']"
|
||||
:validateFile="validateFile"
|
||||
:uploadArgs="{ private: 1 }"
|
||||
@success="
|
||||
(file) => {
|
||||
resume = file
|
||||
@@ -38,6 +39,9 @@
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
<template #prefix>
|
||||
<Upload class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload your resume'
|
||||
}}
|
||||
@@ -65,7 +69,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
|
||||
import { FileText } from 'lucide-vue-next'
|
||||
import { FileText, Upload } from 'lucide-vue-next'
|
||||
import { ref, inject } from 'vue'
|
||||
import { getFileSize } from '@/utils/'
|
||||
|
||||
@@ -95,7 +99,7 @@ const jobApplication = createResource({
|
||||
doc: {
|
||||
doctype: 'LMS Job Application',
|
||||
user: user.data?.name,
|
||||
resume: resume.value?.file_name,
|
||||
resume: resume.value?.file_url,
|
||||
job: props.job,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -84,16 +84,10 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
createResource,
|
||||
Tooltip,
|
||||
FormControl,
|
||||
Autocomplete,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
|
||||
import { reactive, inject, onMounted } from 'vue'
|
||||
import { getTimezones, getUserTimezone } from '@/utils/'
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
|
||||
const liveClasses = defineModel('reloadLiveClasses')
|
||||
const show = defineModel()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
class="text-base"
|
||||
:options="{
|
||||
title: __('Add web page to sidebar'),
|
||||
size: 'lg',
|
||||
@@ -17,15 +16,17 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link
|
||||
v-model="page.webpage"
|
||||
doctype="Web Page"
|
||||
:label="__('Web Page')"
|
||||
:filters="{
|
||||
published: 1,
|
||||
}"
|
||||
/>
|
||||
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
|
||||
<div class="text-base">
|
||||
<Link
|
||||
v-model="page.webpage"
|
||||
doctype="Web Page"
|
||||
:label="__('Web Page')"
|
||||
:filters="{
|
||||
published: 1,
|
||||
}"
|
||||
/>
|
||||
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
@change="(val) => (question.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a Student'),
|
||||
size: 'sm',
|
||||
title: __('Enroll a Student'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
@@ -18,10 +18,25 @@
|
||||
<Link
|
||||
doctype="User"
|
||||
v-model="student"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
placeholder=" "
|
||||
:label="__('Student')"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Members', close)
|
||||
() => {
|
||||
openSettings('Members')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Payment"
|
||||
v-model="payment"
|
||||
placeholder=" "
|
||||
:label="__('Payment')"
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Transactions')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -30,54 +45,49 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||
import { call, Dialog, toast } from 'frappe-ui'
|
||||
import { ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const students = defineModel('reloadStudents')
|
||||
const batchModal = defineModel('batchModal')
|
||||
const student = ref()
|
||||
const student = ref(null)
|
||||
const payment = ref(null)
|
||||
const user = inject('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const show = defineModel()
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
students: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const studentResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
batch: props.batch,
|
||||
member: student.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addStudent = (close) => {
|
||||
studentResource.submit(
|
||||
{},
|
||||
props.students.insert.submit(
|
||||
{
|
||||
member: student.value,
|
||||
payment: payment.value,
|
||||
batch: props.batch.data?.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
|
||||
students.value.reload()
|
||||
batchModal.value.reload()
|
||||
student.value = null
|
||||
payment.value = null
|
||||
props.batch.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user