Compare commits
808 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6841e94ab0 | ||
|
|
4f04698a60 | ||
|
|
82a76b0cad | ||
|
|
62445a18f3 | ||
|
|
91ee5004a4 | ||
|
|
e0ec87c969 | ||
|
|
4835f72f2c | ||
|
|
3ab329d373 | ||
|
|
7c97ffecb5 | ||
|
|
90727590dd | ||
|
|
1c82a67364 | ||
|
|
d08ef83659 | ||
|
|
13bce7e034 | ||
|
|
54888d03bc | ||
|
|
e6d9f3a50d | ||
|
|
74b455287e | ||
|
|
e2adb45493 | ||
|
|
d4e9a6bec2 | ||
|
|
e6741496dc | ||
|
|
9304a83bef | ||
|
|
3173f41e63 | ||
|
|
866dd9bd31 | ||
|
|
f10ad9f525 | ||
|
|
81d78b9613 | ||
|
|
4886df7d6f | ||
|
|
62dfb75169 | ||
|
|
3d8d248599 | ||
|
|
e1ce3eb484 | ||
|
|
a8aa495542 | ||
|
|
c0c74dec83 | ||
|
|
f67c3ffc4c | ||
|
|
11470f256d | ||
|
|
fe37d119f2 | ||
|
|
8c8d9119d4 | ||
|
|
21cbf80f23 | ||
|
|
966e1c3d91 | ||
|
|
57e7baf59e | ||
|
|
675e02fcbd | ||
|
|
841aede8cd | ||
|
|
6ae68bd122 | ||
|
|
d49fc85163 | ||
|
|
56e025a4f0 | ||
|
|
a94df2fdf0 | ||
|
|
cfe19894d1 | ||
|
|
40a6ee9ca5 | ||
|
|
4facf1cf5d | ||
|
|
b81f611a0e | ||
|
|
8a4d1f4810 | ||
|
|
1bdea093b0 | ||
|
|
a1181f3d49 | ||
|
|
ba8e3d448e | ||
|
|
1ee3229104 | ||
|
|
84487602cc | ||
|
|
7fb460019e | ||
|
|
66b14a5b16 | ||
|
|
3696836de6 | ||
|
|
434fea3a95 | ||
|
|
75f937e11a | ||
|
|
85b5730114 | ||
|
|
8c125df9aa | ||
|
|
83322e2052 | ||
|
|
b6eeec8e60 | ||
|
|
b957f3b4e3 | ||
|
|
9c1fd5a68c | ||
|
|
652af9f6a5 | ||
|
|
dc6ede4d80 | ||
|
|
8c6bbb895f | ||
|
|
4d0f92e265 | ||
|
|
0349188c42 | ||
|
|
b1772383f4 | ||
|
|
b71f2148c9 | ||
|
|
e9c10c7f9e | ||
|
|
4d67e8bda9 | ||
|
|
c80483c053 | ||
|
|
2cd77ce455 | ||
|
|
95c17537c1 | ||
|
|
3662fadad4 | ||
|
|
ccce44a1c8 | ||
|
|
ff67c9a952 | ||
|
|
c13ab00344 | ||
|
|
b6ec5e1e3a | ||
|
|
5f41db83c2 | ||
|
|
95521d3b8d | ||
|
|
64d718d198 | ||
|
|
353ef508df | ||
|
|
8b50449c1f | ||
|
|
b7ae82bde2 | ||
|
|
0d472ae66f | ||
|
|
4e609e02e5 | ||
|
|
8d49f2ed4e | ||
|
|
f3274e70a6 | ||
|
|
9d3a05f1bd | ||
|
|
053a9b1074 | ||
|
|
fc4b700624 | ||
|
|
314dfa60e2 | ||
|
|
b01090902a | ||
|
|
82a3b7c986 | ||
|
|
fb3533ad49 | ||
|
|
dd7e4d4297 | ||
|
|
902853d649 | ||
|
|
6eed6438cb | ||
|
|
644f1706a2 | ||
|
|
80e3592669 | ||
|
|
121ced42ec | ||
|
|
a37a1b1ef8 | ||
|
|
fd48fef299 | ||
|
|
9b61037311 | ||
|
|
4035931cbb | ||
|
|
e2e3104474 | ||
|
|
88a038c0ea | ||
|
|
45c68d6ca4 | ||
|
|
56728310f4 | ||
|
|
9bbbf610b7 | ||
|
|
6255768c97 | ||
|
|
513309c160 | ||
|
|
bbe15afbe9 | ||
|
|
f9c11f8129 | ||
|
|
376f31ebae | ||
|
|
849471bfed | ||
|
|
6b10fa7401 | ||
|
|
3a61e84c50 | ||
|
|
dcf05cde07 | ||
|
|
34f0bf5253 | ||
|
|
c4d00017e0 | ||
|
|
735d978894 | ||
|
|
9d0898cdfe | ||
|
|
1e7e3e7036 | ||
|
|
48e0ec1f70 | ||
|
|
af2b4dd3cb | ||
|
|
64ffaddf3c | ||
|
|
7d7394521b | ||
|
|
f36b2183d6 | ||
|
|
83fda560c1 | ||
|
|
d99a09efba | ||
|
|
259a50c468 | ||
|
|
3e0f7b5758 | ||
|
|
21c9e30207 | ||
|
|
8ae9dfbd88 | ||
|
|
3089d0b993 | ||
|
|
47cec6e4c9 | ||
|
|
f370b565c2 | ||
|
|
155a710aa8 | ||
|
|
591a02e8a7 | ||
|
|
f90bbb2de7 | ||
|
|
28e1956708 | ||
|
|
b55e5c9f8f | ||
|
|
a9e8bd5558 | ||
|
|
95ac0983a3 | ||
|
|
7c59b4a210 | ||
|
|
de3befec08 | ||
|
|
6ff3847c6c | ||
|
|
ea90e7e93c | ||
|
|
48ffa0f970 | ||
|
|
4f5346ed31 | ||
|
|
940482dd9a | ||
|
|
8285f8ec5a | ||
|
|
afa2317131 | ||
|
|
d3d5fe0e1b | ||
|
|
b088a8fe3d | ||
|
|
bb88101255 | ||
|
|
a7645afd22 | ||
|
|
7379e734a0 | ||
|
|
3d82d37943 | ||
|
|
553acd8fcc | ||
|
|
6f13164998 | ||
|
|
9efb583cdc | ||
|
|
b68257e176 | ||
|
|
680b5ff160 | ||
|
|
beb4813c53 | ||
|
|
ce8703799b | ||
|
|
5825dd36d3 | ||
|
|
cba85ab96d | ||
|
|
cc3bbfd9af | ||
|
|
4f88948844 | ||
|
|
699cbeadb3 | ||
|
|
6382898213 | ||
|
|
3dfc579745 | ||
|
|
649863d094 | ||
|
|
1be693b826 | ||
|
|
0933bae314 | ||
|
|
f159b2b31c | ||
|
|
25ab1fffa1 | ||
|
|
ebab15f046 | ||
|
|
77135cde1d | ||
|
|
3a14141a4e | ||
|
|
3d24741062 | ||
|
|
e087001905 | ||
|
|
cc41a2fda1 | ||
|
|
5dee203401 | ||
|
|
9bce0a34e3 | ||
|
|
085069abb0 | ||
|
|
a22a260e27 | ||
|
|
b6ff6230e7 | ||
|
|
ab9d57345b | ||
|
|
71fcf9f168 | ||
|
|
f60ddb579e | ||
|
|
117b4f10e7 | ||
|
|
09822c2937 | ||
|
|
01d4b85205 | ||
|
|
83489b0983 | ||
|
|
8cf26e1a75 | ||
|
|
9528bfde62 | ||
|
|
292663a5e3 | ||
|
|
9f0be768aa | ||
|
|
efd73f9c0b | ||
|
|
cdc9af2ed4 | ||
|
|
fc3cd9a9ce | ||
|
|
eb3e6faba4 | ||
|
|
267823414e | ||
|
|
a4859d2686 | ||
|
|
22ea32cf01 | ||
|
|
8b759bc867 | ||
|
|
0e913739d4 | ||
|
|
ba8141ea6a | ||
|
|
9317b87564 | ||
|
|
8ef804bd58 | ||
|
|
641419656f | ||
|
|
6b6d3aaa7a | ||
|
|
28c7b32bd5 | ||
|
|
3936737b48 | ||
|
|
088a01d716 | ||
|
|
c84e3229f6 | ||
|
|
d2ddc9099f | ||
|
|
6ac0a6e562 | ||
|
|
e2ed3bc7a7 | ||
|
|
6a58779ffe | ||
|
|
f3ae225d64 | ||
|
|
74776b3663 | ||
|
|
db1250ee95 | ||
|
|
f20e5311a9 | ||
|
|
e22cce9fa6 | ||
|
|
252ad6a6b9 | ||
|
|
6760b75774 | ||
|
|
7f5e82a844 | ||
|
|
7aa2baaa8a | ||
|
|
e543ccc2e1 | ||
|
|
8cde43eb5b | ||
|
|
460de7014e | ||
|
|
9b4eb10342 | ||
|
|
a340320e8f | ||
|
|
a959c0543c | ||
|
|
a585a13e97 | ||
|
|
435af2c36f | ||
|
|
732b7ed86c | ||
|
|
d0b3328f26 | ||
|
|
c3df0b0692 | ||
|
|
7247cc2d71 | ||
|
|
3f987f9958 | ||
|
|
6c743f858d | ||
|
|
3647bcbbf9 | ||
|
|
54003af07c | ||
|
|
f34157e11c | ||
|
|
8a491cc365 | ||
|
|
7d9dd7535b | ||
|
|
7c539f346b | ||
|
|
3315205d15 | ||
|
|
d882f2f856 | ||
|
|
65132eead2 | ||
|
|
e0b70f2b17 | ||
|
|
dedcb14386 | ||
|
|
cf46e6c6c9 | ||
|
|
77cdf2614e | ||
|
|
fd3c871585 | ||
|
|
2bf04b8be6 | ||
|
|
4ba62d7ee4 | ||
|
|
905bea0d59 | ||
|
|
4fa169556e | ||
|
|
85ca350591 | ||
|
|
d8503fbfe2 | ||
|
|
9a8deced9b | ||
|
|
1797707a9a | ||
|
|
7f73b895d1 | ||
|
|
679e15ed00 | ||
|
|
fb2a79985e | ||
|
|
b3b2881efb | ||
|
|
736a786e12 | ||
|
|
be00028c4a | ||
|
|
5069cf8638 | ||
|
|
4e78c07dac | ||
|
|
e5a469f7ea | ||
|
|
90d5ac4ffc | ||
|
|
f33a3b5521 | ||
|
|
62bff35fcd | ||
|
|
dcdaace08d | ||
|
|
1bc4e51c22 | ||
|
|
4653322fca | ||
|
|
13f039ae4c | ||
|
|
8833f7bfca | ||
|
|
70581c54ab | ||
|
|
69452344d8 | ||
|
|
8fdb395680 | ||
|
|
e77d3d1014 | ||
|
|
50c580cca2 | ||
|
|
47a3fd6836 | ||
|
|
82f6d7d3ca | ||
|
|
613220441f | ||
|
|
57937995e2 | ||
|
|
60ccf0fb53 | ||
|
|
287cc23ee7 | ||
|
|
56ffc495dd | ||
|
|
fbd3d8506f | ||
|
|
4483214de1 | ||
|
|
aa17203e07 | ||
|
|
57042c90bf | ||
|
|
6dab3a1cea | ||
|
|
7c7b36b402 | ||
|
|
fd3ed5d146 | ||
|
|
1137c88a02 | ||
|
|
b0ca07d801 | ||
|
|
1c9771b332 | ||
|
|
1ddaf9a662 | ||
|
|
2aeb094315 | ||
|
|
ed051c3543 | ||
|
|
60439d5be6 | ||
|
|
8928d35f17 | ||
|
|
bad763596c | ||
|
|
c0bc54a664 | ||
|
|
5342f164a2 | ||
|
|
fc8070025b | ||
|
|
3aa9088309 | ||
|
|
42614b8d8e | ||
|
|
b3811b8f4d | ||
|
|
cfa4577b75 | ||
|
|
2377eadcc0 | ||
|
|
b55b86cf56 | ||
|
|
71fd5fb1e0 | ||
|
|
9ddcba2763 | ||
|
|
a76c93c789 | ||
|
|
6889cd1e82 | ||
|
|
35a2f3bb7f | ||
|
|
e676616386 | ||
|
|
2209e5b963 | ||
|
|
82b770bdef | ||
|
|
398839d76c | ||
|
|
5df26bf83b | ||
|
|
c70679da54 | ||
|
|
e474a6fc91 | ||
|
|
564d724d5b | ||
|
|
a0089eb1be | ||
|
|
eb320abfed | ||
|
|
773812c3e1 | ||
|
|
9eefc07832 | ||
|
|
bc25f9c21b | ||
|
|
4cf99ab930 | ||
|
|
db8929d1a8 | ||
|
|
126e662d80 | ||
|
|
39aa5aa749 | ||
|
|
73af4317f6 | ||
|
|
4f76b7c85e | ||
|
|
c44c72cec0 | ||
|
|
e3f5bbfe0c | ||
|
|
25e4e39953 | ||
|
|
9ebe187029 | ||
|
|
8fe8bc1587 | ||
|
|
e23a0887ce | ||
|
|
8e659527f0 | ||
|
|
45e4bc5376 | ||
|
|
259b9f3d1f | ||
|
|
e691df9cb5 | ||
|
|
b7a04e17da | ||
|
|
f822d95d6a | ||
|
|
12266836eb | ||
|
|
3c1f870aac | ||
|
|
6de926ce7e | ||
|
|
9079196309 | ||
|
|
fb7caffdd5 | ||
|
|
9438a5b89a | ||
|
|
e9c0f6cc82 | ||
|
|
a18c45f0db | ||
|
|
669fbfb3d2 | ||
|
|
918370f743 | ||
|
|
d25ae09273 | ||
|
|
38b85f70ac | ||
|
|
cc4a7cc83d | ||
|
|
3f98349f94 | ||
|
|
ed7e98b6ea | ||
|
|
a935d296c9 | ||
|
|
3565741839 | ||
|
|
7178e5e4c9 | ||
|
|
4a5eaaf15e | ||
|
|
7939af4737 | ||
|
|
3586307ee7 | ||
|
|
2bc447fab0 | ||
|
|
f942ef9594 | ||
|
|
505704e8f3 | ||
|
|
c09faa3b09 | ||
|
|
2ab7aa5cea | ||
|
|
436fdfc470 | ||
|
|
86b67022ba | ||
|
|
7a53de4f2d | ||
|
|
ab640fb561 | ||
|
|
4d684d620e | ||
|
|
714c946f0f | ||
|
|
769cdb08fb | ||
|
|
1a0cc2db2a | ||
|
|
a86b844208 | ||
|
|
c8e85fae0b | ||
|
|
42210e7c89 | ||
|
|
ef662f9ceb | ||
|
|
8a2c725eda | ||
|
|
530183a297 | ||
|
|
b0fef766eb | ||
|
|
83f034e500 | ||
|
|
ac2444c258 | ||
|
|
665a53a713 | ||
|
|
8eb10b7b12 | ||
|
|
ffd311881a | ||
|
|
491d870cd2 | ||
|
|
e2535b2467 | ||
|
|
0111d1c2f5 | ||
|
|
86b0c4da82 | ||
|
|
82b0f67f11 | ||
|
|
f3c4669604 | ||
|
|
43b6ec7708 | ||
|
|
6317c46120 | ||
|
|
831c6096cb | ||
|
|
dd79d49533 | ||
|
|
648e700818 | ||
|
|
88629d6df1 | ||
|
|
2199a24dd7 | ||
|
|
d80b6faaad | ||
|
|
e0c0c918d0 | ||
|
|
9239d2b8b1 | ||
|
|
3bdafd6e36 | ||
|
|
aee971924f | ||
|
|
741b83bdf2 | ||
|
|
be8f9ecc86 | ||
|
|
084d61ffa1 | ||
|
|
99cd3bf1d9 | ||
|
|
8cd222b9fa | ||
|
|
1d66a9d14d | ||
|
|
109334a9bf | ||
|
|
003f33152d | ||
|
|
5c432a03db | ||
|
|
15393f3da1 | ||
|
|
135e0d7859 | ||
|
|
e20dedd0a5 | ||
|
|
359a5fd505 | ||
|
|
da00ad7539 | ||
|
|
23593b44fe | ||
|
|
d2b3edad57 | ||
|
|
adc21735fc | ||
|
|
34d6fcc552 | ||
|
|
c49779a293 | ||
|
|
6aaedfc500 | ||
|
|
d68bdf9dc6 | ||
|
|
76ad7d8bda | ||
|
|
bef6813d2c | ||
|
|
bade92f613 | ||
|
|
21841fe09f | ||
|
|
8676ba257c | ||
|
|
eaab798da2 | ||
|
|
5695f92127 | ||
|
|
36e56588cb | ||
|
|
b3bced9875 | ||
|
|
71efe876d3 | ||
|
|
3b69958427 | ||
|
|
b78e58cd2a | ||
|
|
14504073e0 | ||
|
|
2a5f6795d3 | ||
|
|
802d328f4a | ||
|
|
c37e6957f6 | ||
|
|
492ee27d8e | ||
|
|
80461755d1 | ||
|
|
b674cd0cb0 | ||
|
|
8f15480c28 | ||
|
|
4f1dc98ecc | ||
|
|
405003045c | ||
|
|
1d2a4c212d | ||
|
|
4b7b1c91ec | ||
|
|
286d2209cb | ||
|
|
1e6d8b51e4 | ||
|
|
73a4b893f7 | ||
|
|
d58f349e1f | ||
|
|
8a7dbdf4be | ||
|
|
284139e247 | ||
|
|
9a19711755 | ||
|
|
4cfc5a6ea6 | ||
|
|
82cfa9897c | ||
|
|
bd964854de | ||
|
|
f99421f493 | ||
|
|
c2b67429d3 | ||
|
|
98ec415e2b | ||
|
|
e7d69f4e5d | ||
|
|
f2ad93789f | ||
|
|
3bfa83220f | ||
|
|
7ef1963327 | ||
|
|
4410f0a544 | ||
|
|
ed0aa88841 | ||
|
|
7080189a65 | ||
|
|
7a27b65a8c | ||
|
|
57a558f258 | ||
|
|
475c32463a | ||
|
|
b4fb88c73c | ||
|
|
c7c0d5e100 | ||
|
|
21e01d1b77 | ||
|
|
df5af3efd9 | ||
|
|
16003a7f4a | ||
|
|
6c8fca8113 | ||
|
|
363df38b1f | ||
|
|
da9cb6054c | ||
|
|
fa14365d54 | ||
|
|
84b9dd7e88 | ||
|
|
97d0a74fef | ||
|
|
a895c28c4c | ||
|
|
2dc42e6a46 | ||
|
|
56621ca575 | ||
|
|
2ea3efed4b | ||
|
|
18fd63ab69 | ||
|
|
b917586fd8 | ||
|
|
55b1e41898 | ||
|
|
0ce9e4dfd3 | ||
|
|
b4edfe0bcb | ||
|
|
4467590a65 | ||
|
|
2be52871aa | ||
|
|
968f48c55a | ||
|
|
54d4d2cca2 | ||
|
|
07a84bc133 | ||
|
|
7fc149b882 | ||
|
|
e6b532c71e | ||
|
|
b0c1e4d754 | ||
|
|
b84c9c006e | ||
|
|
5d77dddaf6 | ||
|
|
77f030c3fe | ||
|
|
7a8a182d5a | ||
|
|
4a24d25f22 | ||
|
|
493ef9026e | ||
|
|
897c440f26 | ||
|
|
5b543ea93e | ||
|
|
0fa18e4199 | ||
|
|
c65f60ec10 | ||
|
|
e0c5573c8d | ||
|
|
3c3361c765 | ||
|
|
a184ac981c | ||
|
|
76aae38d3a | ||
|
|
f18353e5fc | ||
|
|
316f6f2850 | ||
|
|
70c0fc6dcf | ||
|
|
6156798e02 | ||
|
|
3f9fc513cf | ||
|
|
61c99b9b40 | ||
|
|
529ec8ddf2 | ||
|
|
b4f1676acf | ||
|
|
eeaa0e3f68 | ||
|
|
0367ef776e | ||
|
|
eff3df7115 | ||
|
|
a22ad54502 | ||
|
|
38e262eee0 | ||
|
|
d69d3e1ce7 | ||
|
|
7c0309189f | ||
|
|
1282a72acf | ||
|
|
746cd7cc28 | ||
|
|
46427ee156 | ||
|
|
87aaa7f3ff | ||
|
|
613b8d16ae | ||
|
|
7e8f9a2f0c | ||
|
|
65b663bfc6 | ||
|
|
ea6c2c6658 | ||
|
|
d978725c35 | ||
|
|
57dd03f40f | ||
|
|
796dd388f7 | ||
|
|
74b2b76beb | ||
|
|
2f1072812d | ||
|
|
861b47b772 | ||
|
|
b1201cdcef | ||
|
|
83d45a249a | ||
|
|
09d5ce35f3 | ||
|
|
787c4068f2 | ||
|
|
80f2689cc2 | ||
|
|
4a1bac3bdb | ||
|
|
84fe6605c2 | ||
|
|
fa2387ae00 | ||
|
|
3b9681618c | ||
|
|
afec2865c0 | ||
|
|
83acc741f5 | ||
|
|
2d669cbe3e | ||
|
|
656e8f8568 | ||
|
|
be6aca8e8e | ||
|
|
1a2f175989 | ||
|
|
77afb7465b | ||
|
|
f135bd2b4a | ||
|
|
acee24eaa7 | ||
|
|
b7c654399b | ||
|
|
1557fabf9e | ||
|
|
bafc3c8de8 | ||
|
|
73ec807dd3 | ||
|
|
f9dff0df85 | ||
|
|
5d52a289dc | ||
|
|
d3be3d4240 | ||
|
|
5aca835a4b | ||
|
|
d6d3363866 | ||
|
|
f08a662136 | ||
|
|
9c4077f32d | ||
|
|
317cb48dbe | ||
|
|
62cfecc456 | ||
|
|
f64d1bb127 | ||
|
|
af94e15ffb | ||
|
|
5e037a7209 | ||
|
|
adb24ef9ea | ||
|
|
9a8307001f | ||
|
|
34b7b3314c | ||
|
|
7d21a3d4c9 | ||
|
|
b7c24d1e1a | ||
|
|
95ae45cce8 | ||
|
|
1a370c12d9 | ||
|
|
990e35bdc2 | ||
|
|
2a9851544e | ||
|
|
aad7a6b706 | ||
|
|
574c2cc76a | ||
|
|
6d823e4e7c | ||
|
|
390d620664 | ||
|
|
6b2b5ac455 | ||
|
|
70f5bb1080 | ||
|
|
5f5dc911da | ||
|
|
e66863795c | ||
|
|
9b2e284ee3 | ||
|
|
d23a790ad2 | ||
|
|
cf1daa3ba5 | ||
|
|
2c6679fe06 | ||
|
|
f81b0ee925 | ||
|
|
6a4ac3525f | ||
|
|
9ba0da04c3 | ||
|
|
af4cd55390 | ||
|
|
880d205cbb | ||
|
|
e946fb8711 | ||
|
|
09bb1dab2b | ||
|
|
2896393c53 | ||
|
|
83cbac6270 | ||
|
|
8dea72de99 | ||
|
|
6f82e87574 | ||
|
|
f85d86f796 | ||
|
|
128b112af7 | ||
|
|
7daa2c2dba | ||
|
|
b5be6f441c | ||
|
|
07202c0518 | ||
|
|
bcb3c3f7fb | ||
|
|
cc61e2944c | ||
|
|
01c3a42eb2 | ||
|
|
a15b894ec1 | ||
|
|
014523731e | ||
|
|
f6574c6849 | ||
|
|
3239810a7f | ||
|
|
6368ccf5ac | ||
|
|
0b199b1f5d | ||
|
|
73150817ca | ||
|
|
f824ef0551 | ||
|
|
35b58a42b5 | ||
|
|
6a2bafb402 | ||
|
|
e70800543a | ||
|
|
1360994fc5 | ||
|
|
c4a7b97b63 | ||
|
|
7a356175e7 | ||
|
|
f38ae87c95 | ||
|
|
08b5475548 | ||
|
|
f00f6174ac | ||
|
|
6c574ac18e | ||
|
|
ea50afc165 | ||
|
|
3506db7c14 | ||
|
|
8eab620b65 | ||
|
|
af6e21e1fe | ||
|
|
b4b31794af | ||
|
|
5f5250fd2c | ||
|
|
4187f6a884 | ||
|
|
e5932ced27 | ||
|
|
a4f0a8f162 | ||
|
|
79ae64b562 | ||
|
|
9c397d8802 | ||
|
|
c89ad83ed5 | ||
|
|
bfb4de6b1a | ||
|
|
ca5846f1f6 | ||
|
|
8e55081ce1 | ||
|
|
e6101ed3ac | ||
|
|
cb01ff17a0 | ||
|
|
8a2c337170 | ||
|
|
84f35dad40 | ||
|
|
e9a123c16b | ||
|
|
36576903ea | ||
|
|
b035725344 | ||
|
|
72aa2dbe92 | ||
|
|
d2f07045f0 | ||
|
|
a7abab1236 | ||
|
|
cc3a2d8b85 | ||
|
|
79ceaca8cc | ||
|
|
45215ba504 | ||
|
|
ff636837cf | ||
|
|
3f303a718d | ||
|
|
03a609ea98 | ||
|
|
54d773a19a | ||
|
|
720b41193f | ||
|
|
e5ea0a096c | ||
|
|
2d427da80f | ||
|
|
4e69fffbef | ||
|
|
36380edce4 | ||
|
|
2b49304ecc | ||
|
|
09110ec0b0 | ||
|
|
564dcb8ebc | ||
|
|
617f316f37 | ||
|
|
405c3cf7e3 | ||
|
|
d68c357a86 | ||
|
|
f86e1fe97e | ||
|
|
476c450e6c | ||
|
|
1b180de468 | ||
|
|
d739bcbdb5 | ||
|
|
9b23731acc | ||
|
|
3821378dc1 | ||
|
|
6be7433567 | ||
|
|
cc7e502011 | ||
|
|
a918da46c2 | ||
|
|
80dfc6ba15 | ||
|
|
224720c32e | ||
|
|
9829a5d4ef | ||
|
|
2473c9a875 | ||
|
|
b047f7a2a8 | ||
|
|
b2fec70702 | ||
|
|
728dc58ac4 | ||
|
|
81fc4fca6f | ||
|
|
cc4e19cd2a | ||
|
|
ca9f788838 | ||
|
|
0e523feb09 | ||
|
|
c02bb9df7f | ||
|
|
5922e25b3d | ||
|
|
3b859734fd | ||
|
|
a74b2f2272 | ||
|
|
a3c50b2723 | ||
|
|
94009e6ed7 | ||
|
|
5aea213b8c | ||
|
|
0d51b45636 | ||
|
|
d74f2b8ba9 | ||
|
|
76dcd1a920 | ||
|
|
b5480beaf8 | ||
|
|
18e3012462 | ||
|
|
564b953860 | ||
|
|
964f00e563 | ||
|
|
46458b0f58 | ||
|
|
da6bdad9f0 | ||
|
|
121f28c535 | ||
|
|
91be5db424 | ||
|
|
7b6bd31475 | ||
|
|
51f89bdc1e | ||
|
|
5920096306 | ||
|
|
0d4688ce75 | ||
|
|
081129b639 | ||
|
|
5200897e1b | ||
|
|
be12d11027 | ||
|
|
1999041cdf | ||
|
|
51558fd17c | ||
|
|
3620cac421 | ||
|
|
24baf8cbeb | ||
|
|
cb1c00bf3c | ||
|
|
0edee6b4e0 | ||
|
|
0d60cd97a0 | ||
|
|
553e4d8c04 | ||
|
|
48fcfb0e00 | ||
|
|
33b2c6a660 | ||
|
|
ef6ea6b617 | ||
|
|
d79ee29b96 | ||
|
|
26c0c78660 | ||
|
|
bc3ef37dcf | ||
|
|
e3236e9d95 | ||
|
|
c817e17d8a | ||
|
|
8104aa3152 | ||
|
|
c3d9109211 | ||
|
|
45758612a4 | ||
|
|
049b6a9211 | ||
|
|
fd35c1cb18 | ||
|
|
28516a0389 | ||
|
|
dca18b9b97 | ||
|
|
0f87a61639 | ||
|
|
1a5497a5ae | ||
|
|
09e9d865c2 | ||
|
|
284601d6d2 | ||
|
|
3b8a7780ac | ||
|
|
a79da4cb2d | ||
|
|
9e84e0ecf0 | ||
|
|
32e4d5f7a1 | ||
|
|
4ae2d1230b | ||
|
|
5258e93972 | ||
|
|
c4cd0c44ce | ||
|
|
ac0d261e89 | ||
|
|
6f0f6296e4 | ||
|
|
9c9d3c8fdf | ||
|
|
f3d80995c5 | ||
|
|
7ccba63a85 | ||
|
|
042246be86 | ||
|
|
1a1900f213 | ||
|
|
2b0346fe84 | ||
|
|
6ca93a7297 | ||
|
|
d2e89d7b28 | ||
|
|
362139edd2 | ||
|
|
5a1d71a62c | ||
|
|
eef30348fd | ||
|
|
2f931b5bdb | ||
|
|
d8c6b8dddd | ||
|
|
c93c5e986a | ||
|
|
bb7360ac93 | ||
|
|
f7935deea0 | ||
|
|
b98fe329af | ||
|
|
2bd75952f0 | ||
|
|
f44017e5f4 | ||
|
|
621f39f8ca | ||
|
|
159ecc84c3 | ||
|
|
e5a1694c90 | ||
|
|
b37db23e8f | ||
|
|
105c29a67a | ||
|
|
a48eb0aaf4 | ||
|
|
7142f89211 | ||
|
|
f547e5ed1f | ||
|
|
9ed78de9e2 |
6
.env
6
.env
@@ -1,3 +1,4 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='production'
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
@@ -34,12 +35,13 @@ ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=false
|
||||
INVITE_STUDENTS_EMAIL_TO=''
|
||||
AI_TRANSLATIONS_BASE_URL=''
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=false
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=''
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
APP_ID='authoring'
|
||||
NODE_ENV='development'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
@@ -35,6 +36,7 @@ ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
@@ -42,7 +44,7 @@ HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
HOTJAR_DEBUG=true
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=false
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
APP_ID='authoring'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2001'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
@@ -31,9 +32,11 @@ ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
LIBRARY_SUPPORTED_BLOCKS="problem,video,html"
|
||||
|
||||
@@ -11,8 +11,9 @@ module.exports = createConfig(
|
||||
}],
|
||||
'template-curly-spacing': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
indent: ['error', 2],
|
||||
'no-restricted-exports': 'off',
|
||||
// There is no reason to disallow this syntax anymore; we don't use regenerator-runtime in new browsers
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
settings: {
|
||||
// Import URLs should be resolved using aliases
|
||||
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
30
.github/workflows/validate.yml
vendored
30
.github/workflows/validate.yml
vendored
@@ -9,14 +9,34 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [18, 20]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report-${{ matrix.node }}
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report-20
|
||||
# When we're only using Node 20, replace the line above with the following:
|
||||
# name: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"scss/at-rule-no-unknown": true,
|
||||
"scss/at-import-partial-extension": null,
|
||||
"scss/comment-no-empty": null,
|
||||
"import-notation": "string",
|
||||
"property-no-unknown": [true, {
|
||||
"ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"]
|
||||
}],
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The following users are the maintainers of all frontend-app-course-authoring files
|
||||
# The following users are the maintainers of all frontend-app-authoring files
|
||||
* @openedx/2u-tnl
|
||||
|
||||
5
Makefile
5
Makefile
@@ -35,13 +35,12 @@ pull_translations:
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-component-ai-translations/src/i18n/messages:frontend-component-ai-translations \
|
||||
translations/frontend-lib-content-components/src/i18n/messages:frontend-lib-content-components \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-course-authoring/src/i18n/messages:frontend-app-course-authoring
|
||||
|
||||
$(intl_imports) frontend-component-ai-translations frontend-lib-content-components frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
$(intl_imports) frontend-component-ai-translations frontend-platform paragon frontend-component-footer frontend-app-course-authoring
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -54,7 +53,7 @@ validate:
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run types
|
||||
npm run test
|
||||
npm run test:ci
|
||||
npm run build
|
||||
|
||||
.PHONY: validate.ci
|
||||
|
||||
120
README.rst
120
README.rst
@@ -1,5 +1,5 @@
|
||||
frontend-app-course-authoring
|
||||
#############################
|
||||
frontend-app-authoring
|
||||
######################
|
||||
|
||||
|license-badge| |status-badge| |codecov-badge|
|
||||
|
||||
@@ -7,9 +7,9 @@ frontend-app-course-authoring
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This is the Course Authoring micro-frontend, currently under development by `2U <https://2u.com>`_.
|
||||
This implements most of the frontend for **Open edX Studio**, allowing authors to create and edit courses, libraries, and their learning components.
|
||||
|
||||
Its purpose is to provide both a framework and UI for new or replacement React-based authoring features outside ``edx-platform``. You can find the current set described below.
|
||||
A few parts of Studio still default to the `"legacy" pages defined in edx-platform <https://github.com/openedx/edx-platform/tree/master/cms>`_, but those are rapidly being deprecated and replaced with the React- and Paragon-based pages defined here.
|
||||
|
||||
|
||||
Getting Started
|
||||
@@ -18,51 +18,87 @@ Getting Started
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
The `devstack`_ is currently recommended as a development environment for your
|
||||
new MFE. If you start it with ``make dev.up.lms`` that should give you
|
||||
everything you need as a companion to this frontend.
|
||||
|
||||
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||
|
||||
.. _Devstack: https://github.com/openedx/devstack
|
||||
`Tutor`_ is currently recommended as a development environment for the Authoring
|
||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
||||
make some changes in order to run it in development mode. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
||||
guide below.
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
All features that integrate into the edx-platform CMS require that the ``COURSE_AUTHORING_MICROFRONTEND_URL`` Django setting is set in the CMS environment and points to this MFE's deployment URL. This should be done automatically if you are using devstack or tutor-mfe.
|
||||
Cloning and Setup
|
||||
=================
|
||||
|
||||
Cloning and Startup
|
||||
===================
|
||||
1. Clone your new repo:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
1. Clone the repo:
|
||||
git clone https://github.com/openedx/frontend-app-authoring.git
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-course-authoring.git``
|
||||
2. Use node v20.x.
|
||||
|
||||
2. Use node v18.x.
|
||||
The current version of the micro-frontend build scripts supports node 20.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an ``.nvmrc`` file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm use`_.
|
||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
||||
|
||||
3. Install npm dependencies:
|
||||
4. Next, we need to tell Tutor that we're going to be running this repo in
|
||||
development mode, and it should be excluded from the ``mfe`` container that
|
||||
otherwise runs every MFE. Run this:
|
||||
|
||||
``cd frontend-app-course-authoring && npm install``
|
||||
.. code-block:: bash
|
||||
|
||||
tutor mounts add /path/to/frontend-app-authoring
|
||||
|
||||
4. Start the dev server:
|
||||
5. Start Tutor in development mode. This command will start the LMS and Studio,
|
||||
and other required MFEs like ``authn`` and ``account``, but will not start
|
||||
the Authoring MFE, which we're going to run on the host instead of in a
|
||||
container managed by Tutor. Run:
|
||||
|
||||
``npm start``
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev start lms cms mfe
|
||||
|
||||
The dev server is running at `http://localhost:2001 <http://localhost:2001>`_.
|
||||
or whatever port you setup.
|
||||
Startup
|
||||
=======
|
||||
|
||||
1. Install npm dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cd frontend-app-authoring && npm ci
|
||||
|
||||
2. Start the dev server:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm run dev
|
||||
|
||||
Then you can access the app at http://apps.local.openedx.io:2001/course-authoring/home
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
|
||||
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
|
||||
these commands to update your devstack's domain names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev stop
|
||||
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
|
||||
tutor dev launch -I --skip-build
|
||||
tutor dev stop authoring # We will run this MFE on the host
|
||||
|
||||
* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to
|
||||
using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix
|
||||
this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in
|
||||
[this forum post](https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2)
|
||||
|
||||
|
||||
Features
|
||||
@@ -145,10 +181,6 @@ Feature Description
|
||||
|
||||
When a corresponding waffle flag is set, upon editing a block in Studio, the view is rendered by this MFE instead of by the XBlock's authoring view. The user remains in Studio.
|
||||
|
||||
.. note::
|
||||
|
||||
The new editors themselves are currently implemented in a repository outside ``openedx``: `frontend-lib-content-components <https://github.com/edx/frontend-lib-content-components/>`_, a dependency of this MFE. This repository is slated to be moved to the ``openedx`` org, however.
|
||||
|
||||
Feature: New Proctoring Exams View
|
||||
==================================
|
||||
|
||||
@@ -264,6 +296,22 @@ In additional to the standard settings, the following local configuration items
|
||||
Tagging/Taxonomy functionality.
|
||||
|
||||
|
||||
Feature: Libraries V2/Legacy Tabs
|
||||
=================================
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
In additional to the standard settings, the following local configurations can be set to switch between different library modes:
|
||||
|
||||
* ``MEILISEARCH_ENABLED``: Studio setting which is enabled when the `meilisearch plugin`_ is installed.
|
||||
* ``edx-platform`` Waffle flags:
|
||||
|
||||
* ``contentstore.new_studio_mfe.disable_legacy_libraries``: this feature flag must be OFF to show legacy Libraries V1
|
||||
* ``contentstore.new_studio_mfe.disable_new_libraries``: this feature flag must be OFF to show Content Libraries V2
|
||||
|
||||
.. _meilisearch plugin: https://github.com/open-craft/tutor-contrib-meilisearch
|
||||
|
||||
Developing
|
||||
**********
|
||||
|
||||
@@ -296,8 +344,8 @@ The production build is created with ``npm run build``.
|
||||
:target: https://travis-ci.com/edx/frontend-app-course-authoring
|
||||
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-course-authoring/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/edx/frontend-app-course-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-course-authoring.svg
|
||||
:target: @edx/frontend-app-course-authoring
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-authoring.svg
|
||||
:target: @edx/frontend-app-authoring
|
||||
|
||||
Internationalization
|
||||
====================
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-course-authoring'
|
||||
description: "The frontend (MFE) for Open edX Course Authoring (aka Studio)"
|
||||
name: 'frontend-app-authoring'
|
||||
description: "The frontend (MFE) for Open edX Authoring (aka Studio)"
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-course-authoring"
|
||||
title: "Frontend app course authoring"
|
||||
- url: "https://github.com/openedx/frontend-app-authoring"
|
||||
title: "Frontend app authoring"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
|
||||
15761
package-lock.json
generated
15761
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
87
package.json
87
package.json
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@edx/frontend-app-course-authoring",
|
||||
"name": "@edx/frontend-app-authoring",
|
||||
"version": "0.1.0",
|
||||
"description": "Frontend application template",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openedx/frontend-app-course-authoring.git"
|
||||
"url": "git+https://github.com/openedx/frontend-app-authoring.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @edx/browserslist-config"
|
||||
@@ -13,12 +13,14 @@
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"stylelint": "stylelint \"plugins/**/*.scss\" \"src/**/*.scss\" \"scss/**/*.scss\" --config .stylelintrc.json",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"lint": "npm run stylelint && fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"lint:fix": "npm run stylelint -- --fix && fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"snapshot": "TZ=UTC fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"husky": {
|
||||
@@ -28,32 +30,30 @@
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-course-authoring#readme",
|
||||
"homepage": "https://github.com/openedx/frontend-app-authoring#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/openedx/frontend-app-course-authoring/issues"
|
||||
"url": "https://github.com/openedx/frontend-app-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
"@codemirror/lint": "^6.2.1",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-ai-translations": "^2.0.0",
|
||||
"@edx/frontend-component-footer": "^13.0.2",
|
||||
"@edx/frontend-component-header": "^5.1.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-component-footer": "^14.1.0",
|
||||
"@edx/frontend-component-header": "^5.6.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^2.1.7",
|
||||
"@edx/frontend-platform": "7.0.1",
|
||||
"@edx/frontend-platform": "^8.0.3",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@meilisearch/instant-meilisearch": "^0.17.0",
|
||||
"@openedx-plugins/course-app-calculator": "file:plugins/course-apps/calculator",
|
||||
"@openedx-plugins/course-app-edxnotes": "file:plugins/course-apps/edxnotes",
|
||||
"@openedx-plugins/course-app-learning_assistant": "file:plugins/course-apps/learning_assistant",
|
||||
@@ -64,62 +64,67 @@
|
||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/frontend-plugin-framework": "^1.1.0",
|
||||
"@openedx/paragon": "^22.2.1",
|
||||
"@openedx/frontend-build": "^14.0.14",
|
||||
"@openedx/frontend-plugin-framework": "^1.2.1",
|
||||
"@openedx/paragon": "^22.8.1",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.8.1",
|
||||
"@tinymce/tinymce-react": "^3.14.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
"email-validator": "2.0.4",
|
||||
"fast-xml-parser": "^4.0.10",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.2.6",
|
||||
"formik": "2.4.6",
|
||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"meilisearch": "^0.38.0",
|
||||
"moment": "2.29.4",
|
||||
"meilisearch": "^0.41.0",
|
||||
"moment": "2.30.1",
|
||||
"moment-shortformat": "^2.1.0",
|
||||
"npm": "^10.8.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-onclickoutside": "^6.13.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.16.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"react-router": "6.27.0",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"start": "^5.1.0",
|
||||
"tinymce": "^5.10.4",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"uuid": "^3.4.0",
|
||||
"xmlchecker": "^0.1.0",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/react-unit-test-utils": "^2.0.0",
|
||||
"@edx/reactifex": "^1.0.3",
|
||||
"@edx/stylelint-config-edx": "2.3.0",
|
||||
"@edx/react-unit-test-utils": "3.0.0",
|
||||
"@edx/stylelint-config-edx": "2.3.3",
|
||||
"@edx/typescript-config": "^1.0.1",
|
||||
"@openedx/frontend-build": "13.1.0",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"axios": "^0.28.0",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.8",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"ts-loader": "^9.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"decode-uri-component": ">=0.2.2"
|
||||
"redux-mock-store": "^1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Calculator configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"description": "edxnotes configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Learning Assistant configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
@@ -11,7 +11,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { camelCase } from 'lodash';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SelectableBox from 'CourseAuthoring/editors/sharedComponents/SelectableBox';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Live course configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-lib-content-components": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"@reduxjs/toolkit": "*",
|
||||
@@ -16,7 +15,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,176 @@
|
||||
import React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
import { useModel } from 'CourseAuthoring/generic/model-store';
|
||||
import {
|
||||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
|
||||
|
||||
import { RequestStatus } from 'CourseAuthoring/data/constants';
|
||||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
|
||||
import { useAppSetting } from 'CourseAuthoring/utils';
|
||||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
|
||||
import Loading from 'CourseAuthoring/generic/Loading';
|
||||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
|
||||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
|
||||
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
|
||||
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
|
||||
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const ORASettings = ({ intl, onClose }) => {
|
||||
const ORASettings = ({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const alertRef = useRef(null);
|
||||
const updateSettingsRequestStatus = useSelector(getSavingStatus);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const isMobile = useIsMobile();
|
||||
const modalVariant = isMobile ? 'dark' : 'default';
|
||||
const appId = 'ora_settings';
|
||||
const appInfo = useModel('courseApps', appId);
|
||||
|
||||
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
|
||||
'forceOnFlexiblePeerOpenassessments',
|
||||
);
|
||||
const initialFormValues = { enableFlexiblePeerGrade };
|
||||
|
||||
const [formValues, setFormValues] = useState(initialFormValues);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
|
||||
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
|
||||
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
|
||||
|
||||
const title = (
|
||||
<div>
|
||||
<p>{intl.formatMessage(messages.heading)}</p>
|
||||
<div className="pt-3">
|
||||
<Hyperlink
|
||||
className="text-primary-500 small"
|
||||
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{intl.formatMessage(messages.ORASettingsHelpLink)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const handleSubmit = async (event) => {
|
||||
let success = true;
|
||||
event.preventDefault();
|
||||
|
||||
success = success && await handleSettingsSave(formValues);
|
||||
await setSaveError(!success);
|
||||
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
|
||||
success = await dispatch(updateModel({
|
||||
modelType: 'courseApps',
|
||||
model: {
|
||||
id: appId, enabled: formValues.enableFlexiblePeerGrade,
|
||||
},
|
||||
}));
|
||||
}
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormValues({ enableFlexiblePeerGrade: e.target.checked });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
onClose();
|
||||
}
|
||||
}, [updateSettingsRequestStatus]);
|
||||
|
||||
const renderBody = () => {
|
||||
switch (loadingStatus) {
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return (
|
||||
<>
|
||||
{saveError && (
|
||||
<Alert variant="danger" icon={Info} ref={alertRef}>
|
||||
<Alert.Heading>
|
||||
{formatMessage(messages.errorSavingTitle)}
|
||||
</Alert.Heading>
|
||||
{formatMessage(messages.errorSavingMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
<FormSwitchGroup
|
||||
id="enable-flexible-peer-grade"
|
||||
name="enableFlexiblePeerGrade"
|
||||
label={(
|
||||
<div className="d-flex align-items-center">
|
||||
{formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||
{formValues.enableFlexiblePeerGrade && (
|
||||
<Badge className="ml-2" variant="success" data-testid="enable-badge">
|
||||
{formatMessage(messages.enabledBadgeLabel)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
helpText={(
|
||||
<div>
|
||||
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p>
|
||||
<span className="py-3">
|
||||
<Hyperlink
|
||||
className="text-primary-500 small"
|
||||
destination={appInfo.documentationLinks?.learnMoreConfiguration}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{formatMessage(messages.ORASettingsHelpLink)}
|
||||
</Hyperlink>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={handleChange}
|
||||
checked={formValues.enableFlexiblePeerGrade}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case RequestStatus.DENIED:
|
||||
return <PermissionDeniedAlert />;
|
||||
case RequestStatus.FAILED:
|
||||
return <ConnectionErrorAlert />;
|
||||
default:
|
||||
return <Loading />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppSettingsModal
|
||||
appId={appId}
|
||||
title={title}
|
||||
<ModalDialog
|
||||
title={formatMessage(messages.heading)}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
initialValues={{ enableFlexiblePeerGrade }}
|
||||
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
|
||||
onSettingsSave={handleSettingsSave}
|
||||
hideAppToggle
|
||||
size="lg"
|
||||
variant={modalVariant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenScroll
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
{({ values, handleChange, handleBlur }) => (
|
||||
<FormSwitchGroup
|
||||
id="enable-flexible-peer-grade"
|
||||
name="enableFlexiblePeerGrade"
|
||||
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
|
||||
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
checked={values.enableFlexiblePeerGrade}
|
||||
/>
|
||||
)}
|
||||
</AppSettingsModal>
|
||||
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{formatMessage(messages.heading)}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{renderBody()}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="p-4">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{formatMessage(messages.cancelLabel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: formatMessage(messages.saveLabel),
|
||||
pending: formatMessage(messages.pendingSaveLabel),
|
||||
}}
|
||||
description="Form save button"
|
||||
data-testid="submissionButton"
|
||||
disabled={submitButtonState === RequestStatus.IN_PROGRESS}
|
||||
state={submitButtonState}
|
||||
type="submit"
|
||||
/>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</Form>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
ORASettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ORASettings);
|
||||
export default ORASettings;
|
||||
|
||||
@@ -1,33 +1,152 @@
|
||||
import { shallow } from '@edx/react-unit-test-utils';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from 'CourseAuthoring/store';
|
||||
import { executeThunk } from 'CourseAuthoring/utils';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
|
||||
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
|
||||
import ORASettings from './Settings';
|
||||
import messages from './messages';
|
||||
import {
|
||||
courseId,
|
||||
inititalState,
|
||||
} from './factories/mockData';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
|
||||
injectIntl: (component) => component,
|
||||
intlShape: {},
|
||||
}));
|
||||
jest.mock('yup', () => ({
|
||||
boolean: jest.fn().mockReturnValue('Yub.boolean'),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/generic/model-store', () => ({
|
||||
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
|
||||
jest.mock('CourseAuthoring/utils', () => ({
|
||||
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
|
||||
}));
|
||||
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
|
||||
let axiosMock;
|
||||
let store;
|
||||
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
|
||||
|
||||
const props = {
|
||||
onClose: jest.fn().mockName('onClose'),
|
||||
intl: {
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
},
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const renderComponent = () => (
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<PagesAndResourcesProvider courseId={courseId}>
|
||||
<MemoryRouter initialEntries={[oraSettingsUrl]}>
|
||||
<Routes>
|
||||
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PagesAndResourcesProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
)
|
||||
);
|
||||
|
||||
const mockStore = async ({
|
||||
apiStatus,
|
||||
enabled,
|
||||
}) => {
|
||||
const settings = ['forceOnFlexiblePeerOpenassessments'];
|
||||
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
|
||||
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
|
||||
|
||||
axiosMock.onGet(fetchCourseAppsUrl).reply(
|
||||
200,
|
||||
[{
|
||||
allowed_operations: { enable: false, configure: true },
|
||||
description: 'setting',
|
||||
documentation_links: { learnMoreConfiguration: '' },
|
||||
enabled,
|
||||
id: 'ora_settings',
|
||||
name: 'Flexible Peer Grading for ORAs',
|
||||
}],
|
||||
);
|
||||
axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
|
||||
apiStatus,
|
||||
{ force_on_flexible_peer_openassessments: { value: enabled } },
|
||||
);
|
||||
|
||||
await executeThunk(fetchCourseApps(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
|
||||
};
|
||||
|
||||
describe('ORASettings', () => {
|
||||
it('should render', () => {
|
||||
const wrapper = shallow(<ORASettings {...props} />);
|
||||
expect(wrapper.snapshot).toMatchSnapshot();
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(inititalState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('Flexible peer grading configuration modal is visible', async () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays "Configure Flexible Peer Grading" heading', async () => {
|
||||
renderComponent();
|
||||
const headingElement = screen.getByText(messages.heading.defaultMessage);
|
||||
|
||||
expect(headingElement).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays loading component', () => {
|
||||
renderComponent();
|
||||
const loadingElement = screen.getByRole('status');
|
||||
|
||||
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays Connection Error Alert', async () => {
|
||||
await mockStore({ apiStatus: 404, enabled: true });
|
||||
renderComponent();
|
||||
const errorAlert = screen.getByRole('alert');
|
||||
|
||||
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays Permissions Error Alert', async () => {
|
||||
await mockStore({ apiStatus: 403, enabled: true });
|
||||
renderComponent();
|
||||
const errorAlert = screen.getByRole('alert');
|
||||
|
||||
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
|
||||
waitFor(() => {
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.getByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toHaveTextContent('Enabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: false });
|
||||
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.queryByTestId('enable-badge');
|
||||
|
||||
expect(label).toBeVisible();
|
||||
|
||||
expect(enableBadge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ORASettings should render 1`] = `
|
||||
<AppSettingsModal
|
||||
appId="ora_settings"
|
||||
hideAppToggle={true}
|
||||
initialValues={
|
||||
Object {
|
||||
"enableFlexiblePeerGrade": "abitrary value",
|
||||
}
|
||||
}
|
||||
onClose={[MockFunction onClose]}
|
||||
onSettingsSave={[Function]}
|
||||
title={
|
||||
<div>
|
||||
<p>
|
||||
Configure open response assessment
|
||||
</p>
|
||||
<div
|
||||
className="pt-3"
|
||||
>
|
||||
<withDeprecatedProps(Hyperlink)
|
||||
className="text-primary-500 small"
|
||||
destination="https://learnmore.test"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about open response assessment settings
|
||||
</withDeprecatedProps(Hyperlink)>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
validationSchema={
|
||||
Object {
|
||||
"enableFlexiblePeerGrade": "Yub.boolean",
|
||||
}
|
||||
}
|
||||
>
|
||||
[Function]
|
||||
</AppSettingsModal>
|
||||
`;
|
||||
32
plugins/course-apps/ora_settings/factories/mockData.js
Normal file
32
plugins/course-apps/ora_settings/factories/mockData.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export const courseId = 'course-v1:org+num+run';
|
||||
|
||||
export const inititalState = {
|
||||
courseDetail: {
|
||||
courseId,
|
||||
status: 'successful',
|
||||
},
|
||||
pagesAndResources: {
|
||||
courseAppIds: ['ora_settings'],
|
||||
loadingStatus: 'in-progress',
|
||||
savingStatus: '',
|
||||
courseAppsApiStatus: {},
|
||||
courseAppSettings: {},
|
||||
},
|
||||
models: {
|
||||
courseApps: {
|
||||
ora_settings: {
|
||||
id: 'ora_settings',
|
||||
name: 'Flexible Peer Grading',
|
||||
enabled: true,
|
||||
description: 'Enable flexible peer grading',
|
||||
allowedOperations: {
|
||||
enable: false,
|
||||
configure: true,
|
||||
},
|
||||
documentationLinks: {
|
||||
learnMoreConfiguration: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3,19 +3,51 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.pages-resources.ora.heading',
|
||||
defaultMessage: 'Configure open response assessment',
|
||||
defaultMessage: 'Configure Flexible Peer Grading',
|
||||
description: 'Title for the modal dialog header',
|
||||
},
|
||||
ORASettingsHelpLink: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
|
||||
defaultMessage: 'Learn more about open response assessment settings',
|
||||
description: 'Descriptive text for the hyperlink to the docs site',
|
||||
},
|
||||
enableFlexPeerGradeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
|
||||
defaultMessage: 'Flex Peer Grading',
|
||||
description: 'Label for form switch',
|
||||
},
|
||||
enableFlexPeerGradeHelp: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
|
||||
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
|
||||
description: 'Help text describing what happens when the switch is enabled',
|
||||
},
|
||||
enabledBadgeLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label',
|
||||
defaultMessage: 'Enabled',
|
||||
description: 'Label for badge that show users that a setting is enabled',
|
||||
},
|
||||
cancelLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Label for button that cancels user changes',
|
||||
},
|
||||
saveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label',
|
||||
defaultMessage: 'Save',
|
||||
description: 'Label for button that saves user changes',
|
||||
},
|
||||
pendingSaveLabel: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label',
|
||||
defaultMessage: 'Saving',
|
||||
description: 'Label for button that has pending api save calls',
|
||||
},
|
||||
errorSavingTitle: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title',
|
||||
defaultMessage: 'We couldn\'t apply your changes.',
|
||||
},
|
||||
errorSavingMessage: {
|
||||
id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message',
|
||||
defaultMessage: 'Please check your entries and try again.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Open Response Assessment configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
"react": "*",
|
||||
"react-redux": "*",
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
}
|
||||
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
const org = courseDetails?.org;
|
||||
const appInfo = useModel('courseApps', 'proctoring');
|
||||
const alertRef = React.createRef();
|
||||
const saveStatusAlertRef = React.createRef();
|
||||
@@ -146,9 +148,9 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
setSaveSuccess(true);
|
||||
setSaveError(false);
|
||||
setSubmissionInProgress(false);
|
||||
}).catch(() => {
|
||||
}).catch((error) => {
|
||||
setSaveSuccess(false);
|
||||
setSaveError(true);
|
||||
setSaveError(error);
|
||||
setSubmissionInProgress(false);
|
||||
});
|
||||
}
|
||||
@@ -458,21 +460,32 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
}
|
||||
|
||||
function renderSaveError() {
|
||||
return (
|
||||
<Alert
|
||||
variant="danger"
|
||||
data-testid="saveError"
|
||||
tabIndex="-1"
|
||||
ref={saveStatusAlertRef}
|
||||
onClose={() => setSaveError(false)}
|
||||
dismissible
|
||||
>
|
||||
let errorMessage = (
|
||||
<FormattedMessage
|
||||
id="authoring.proctoring.alert.error"
|
||||
defaultMessage={`
|
||||
We encountered a technical error while trying to save proctored exam settings.
|
||||
This might be a temporary issue, so please try again in a few minutes.
|
||||
If the problem persists, please go to the {support_link} for help.
|
||||
`}
|
||||
values={{
|
||||
support_link: (
|
||||
<Alert.Link href={getConfig().SUPPORT_URL}>
|
||||
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
|
||||
</Alert.Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (saveError?.response.status === 403) {
|
||||
errorMessage = (
|
||||
<FormattedMessage
|
||||
id="authoring.examsettings.alert.error"
|
||||
id="authoring.proctoring.alert.error.forbidden"
|
||||
defaultMessage={`
|
||||
We encountered a technical error while trying to save proctored exam settings.
|
||||
This might be a temporary issue, so please try again in a few minutes.
|
||||
If the problem persists, please go to the {support_link} for help.
|
||||
You do not have permission to edit proctored exam settings for this course.
|
||||
If you are a course team member and this problem persists,
|
||||
please go to the {support_link} for help.
|
||||
`}
|
||||
values={{
|
||||
support_link: (
|
||||
@@ -482,6 +495,19 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="danger"
|
||||
data-testid="saveError"
|
||||
tabIndex="-1"
|
||||
ref={saveStatusAlertRef}
|
||||
onClose={() => setSaveError(false)}
|
||||
dismissible
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -490,7 +516,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
Promise.all([
|
||||
StudioApiService.getProctoredExamSettingsData(courseId),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
|
||||
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders(org) : Promise.resolve(),
|
||||
])
|
||||
.then(
|
||||
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
|
||||
|
||||
@@ -15,8 +15,9 @@ import initializeStore from 'CourseAuthoring/store';
|
||||
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
|
||||
import ProctoredExamSettings from './Settings';
|
||||
|
||||
const courseId = 'course-v1%3AedX%2BDemoX%2BDemo_Course';
|
||||
const defaultProps = {
|
||||
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
|
||||
courseId,
|
||||
onClose: () => {},
|
||||
};
|
||||
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
|
||||
@@ -34,7 +35,7 @@ const intlWrapper = children => (
|
||||
let axiosMock;
|
||||
|
||||
describe('ProctoredExamSettings', () => {
|
||||
function setupApp(isAdmin = true) {
|
||||
function setupApp(isAdmin = true, org = undefined) {
|
||||
mergeConfig({
|
||||
EXAMS_BASE_URL: 'http://exams.testing.co',
|
||||
}, 'CourseAuthoringConfig');
|
||||
@@ -52,12 +53,18 @@ describe('ProctoredExamSettings', () => {
|
||||
courseApps: {
|
||||
proctoring: {},
|
||||
},
|
||||
courseDetails: {
|
||||
[courseId]: {
|
||||
start: Date(),
|
||||
},
|
||||
},
|
||||
...(org ? { courseDetails: { [courseId]: { org } } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers${org ? `?org=${org}` : ''}`,
|
||||
).reply(200, [
|
||||
{
|
||||
name: 'test_lti',
|
||||
@@ -103,9 +110,7 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
|
||||
expect(zendeskTicketInput.checked).toEqual(true);
|
||||
});
|
||||
@@ -115,9 +120,7 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
|
||||
});
|
||||
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
|
||||
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
|
||||
expect(zendeskTicketInput.checked).toEqual(true);
|
||||
});
|
||||
@@ -127,9 +130,7 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
|
||||
});
|
||||
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
|
||||
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
|
||||
expect(zendeskTicketInput.checked).toEqual(true);
|
||||
});
|
||||
@@ -176,9 +177,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
|
||||
expect(enabledProctoredExamCheck.checked).toEqual(true);
|
||||
await act(async () => {
|
||||
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
|
||||
});
|
||||
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
|
||||
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
|
||||
expect(enabledProctoredExamCheck.checked).toEqual(false);
|
||||
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
|
||||
@@ -193,9 +192,7 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('mockproc');
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||
@@ -237,13 +234,9 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
fireEvent.click(selectButton);
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
@@ -252,9 +245,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
fireEvent.click(errorLink);
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
@@ -265,18 +256,12 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
const selectElement = screen.getByDisplayValue('proctortrack');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: provider } });
|
||||
});
|
||||
fireEvent.change(selectElement, { target: { value: provider } });
|
||||
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
const proctoringForm = screen.getByTestId('proctoringForm');
|
||||
fireEvent.submit(proctoringForm);
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
@@ -286,9 +271,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
fireEvent.click(errorLink);
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
@@ -298,15 +281,11 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
fireEvent.click(enableProctoringElement);
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
fireEvent.click(selectButton);
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
@@ -320,24 +299,22 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
fireEvent.click(enableProctoringElement);
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
fireEvent.click(selectButton);
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
@@ -345,22 +322,20 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
});
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
fireEvent.click(selectButton);
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
||||
@@ -370,9 +345,7 @@ describe('ProctoredExamSettings', () => {
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -382,13 +355,9 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
@@ -399,12 +368,8 @@ describe('ProctoredExamSettings', () => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.submit(selectEscalationEmailElement);
|
||||
});
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
fireEvent.submit(selectEscalationEmailElement);
|
||||
// if the error appears, the form has been submitted
|
||||
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
|
||||
});
|
||||
@@ -458,6 +423,16 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Sends the org to the proctoring provider endpoint', async () => {
|
||||
const isAdmin = false;
|
||||
const org = 'test-org';
|
||||
setupApp(isAdmin, org);
|
||||
mockCourseData(mockGetFutureCourseData);
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const providerOption = screen.getByTestId('proctortrack');
|
||||
expect(providerOption.hasAttribute('disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
|
||||
const isAdmin = true;
|
||||
setupApp(isAdmin);
|
||||
@@ -628,9 +603,7 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
let submitButton = screen.getByTestId('submissionButton');
|
||||
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
submitButton = screen.getByTestId('submissionButton');
|
||||
expect(submitButton).toHaveAttribute('disabled');
|
||||
@@ -640,19 +613,13 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||
expect(escalationEmail.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
|
||||
});
|
||||
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
|
||||
expect(escalationEmail.value).toEqual('proctortrack@example.com');
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
@@ -664,11 +631,13 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
|
||||
@@ -678,9 +647,7 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
|
||||
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
@@ -691,32 +658,28 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to test_lti and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
|
||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||
expect(escalationEmail.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
||||
});
|
||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
||||
expect(escalationEmail.value).toEqual('test_lti@example.com');
|
||||
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
@@ -736,19 +699,19 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
// update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
@@ -766,11 +729,13 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it('Does not update exam service if lti is not enabled in studio', async () => {
|
||||
@@ -790,9 +755,7 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
// does not update exam service config
|
||||
expect(axiosMock.history.patch.length).toBe(0);
|
||||
// does update studio
|
||||
@@ -806,11 +769,13 @@ describe('ProctoredExamSettings', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it('Makes studio API call generated error', async () => {
|
||||
@@ -820,15 +785,15 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it('Makes exams API call generated error', async () => {
|
||||
@@ -838,15 +803,33 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
test('Exams API permission error', async () => {
|
||||
axiosMock.onPatch(
|
||||
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
|
||||
).reply(403, 'error');
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('You do not have permission to edit proctored exam settings for this course'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it('Manages focus correctly after different save statuses', async () => {
|
||||
@@ -857,30 +840,30 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
await waitFor(() => {
|
||||
const errorAlert = screen.getByTestId('saveError');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
// now make a call that will allow for a successful save
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, 'success');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(axiosMock.history.post.length).toBe(2);
|
||||
const successAlert = screen.getByTestId('saveSuccess');
|
||||
expect(successAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(successAlert);
|
||||
await waitFor(() => {
|
||||
const successAlert = screen.getByTestId('saveSuccess');
|
||||
expect(successAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(successAlert);
|
||||
});
|
||||
});
|
||||
|
||||
it('Include Zendesk ticket in post request if user is not an admin', async () => {
|
||||
@@ -891,13 +874,9 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the proctoring provider
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
fireEvent.click(submitButton);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
|
||||
proctored_exam_settings: {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'authoring.proctoring.alert.error': {
|
||||
id: 'authoring.proctoring.alert.error',
|
||||
defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.',
|
||||
description: 'Alert message for proctoring settings save error.',
|
||||
},
|
||||
'authoring.proctoring.alert.forbidden': {
|
||||
id: 'authoring.proctoring.alert.forbidden',
|
||||
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
|
||||
description: 'Alert message for proctoring settings permission error.',
|
||||
},
|
||||
'authoring.proctoring.no': {
|
||||
id: 'authoring.proctoring.no',
|
||||
defaultMessage: 'No',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Proctoring configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"classnames": "*",
|
||||
@@ -13,7 +13,7 @@
|
||||
"moment": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Progress configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
@@ -11,7 +11,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Teams configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
@@ -13,7 +13,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ const messages = defineMessages({
|
||||
},
|
||||
enablePublicWikiHelp: {
|
||||
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
|
||||
defaultMessage: `If enabled, edX users can view the course wiki even when
|
||||
they're not enrolled in the course.`,
|
||||
defaultMessage: `If enabled, any registered user can view the course wiki
|
||||
even if they are not enrolled in the course`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Wiki configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"prop-types": "*",
|
||||
@@ -11,7 +11,7 @@
|
||||
"yup": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Xpert Unit Summaries configuration for courses using it",
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-app-course-authoring": "*",
|
||||
"@edx/frontend-app-authoring": "*",
|
||||
"@edx/frontend-platform": "*",
|
||||
"@openedx/paragon": "*",
|
||||
"formik": "*",
|
||||
@@ -14,7 +14,7 @@
|
||||
"react-router-dom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edx/frontend-app-course-authoring": {
|
||||
"@edx/frontend-app-authoring": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,12 +137,12 @@ const ResetUnitsButton = ({
|
||||
|
||||
const getResetButtonState = () => {
|
||||
switch (resetStatusRequestStatus) {
|
||||
case RequestStatus.PENDING:
|
||||
return 'pending';
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return 'finish';
|
||||
default:
|
||||
return 'default';
|
||||
case RequestStatus.PENDING:
|
||||
return 'pending';
|
||||
case RequestStatus.SUCCESSFUL:
|
||||
return 'finish';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,7 +246,7 @@ const SettingsModal = ({
|
||||
success = success && await onSettingsSave(values);
|
||||
}
|
||||
setSaveError(!success);
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line no-unused-expressions
|
||||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
|
||||
};
|
||||
|
||||
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
|
||||
|
||||
@@ -19,15 +19,6 @@
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx/frontend-lib-content-components"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": false,
|
||||
"schedule": [
|
||||
"after 1am",
|
||||
"before 11pm"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,33 +11,11 @@ import { fetchCourseDetail } from './data/thunks';
|
||||
import { useModel } from './generic/model-store';
|
||||
import NotFoundAlert from './generic/NotFoundAlert';
|
||||
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
|
||||
import { fetchStudioHomeData } from './studio-home/data/thunks';
|
||||
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
|
||||
import { RequestStatus } from './data/constants';
|
||||
import Loading from './generic/Loading';
|
||||
|
||||
const AppHeader = ({
|
||||
courseNumber, courseOrg, courseTitle, courseId,
|
||||
}) => (
|
||||
<Header
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
/>
|
||||
);
|
||||
|
||||
AppHeader.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string,
|
||||
courseOrg: PropTypes.string,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AppHeader.defaultProps = {
|
||||
courseNumber: null,
|
||||
courseOrg: null,
|
||||
};
|
||||
|
||||
const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -45,6 +23,10 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
dispatch(fetchCourseDetail(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchStudioHomeData());
|
||||
}, []);
|
||||
|
||||
const courseDetail = useModel('courseDetails', courseId);
|
||||
|
||||
const courseNumber = courseDetail ? courseDetail.number : null;
|
||||
@@ -67,18 +49,18 @@ const CourseAuthoringPage = ({ courseId, children }) => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
|
||||
<div>
|
||||
{/* While V2 Editors are temporarily served from their own pages
|
||||
using url pattern containing /editor/,
|
||||
we shouldn't have the header and footer on these pages.
|
||||
This functionality will be removed in TNL-9591 */}
|
||||
{inProgress ? !isEditor && <Loading />
|
||||
: (!isEditor && (
|
||||
<AppHeader
|
||||
courseNumber={courseNumber}
|
||||
courseOrg={courseOrg}
|
||||
courseTitle={courseTitle}
|
||||
courseId={courseId}
|
||||
<Header
|
||||
number={courseNumber}
|
||||
org={courseOrg}
|
||||
title={courseTitle}
|
||||
contextId={courseId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -88,7 +88,7 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="editor/:blockType/:blockId?"
|
||||
element={<PageWrap><EditorContainer courseId={courseId} /></PageWrap>}
|
||||
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="settings/details"
|
||||
@@ -124,7 +124,7 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="certificates"
|
||||
element={<PageWrap><Certificates courseId={courseId} /></PageWrap>}
|
||||
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="textbooks"
|
||||
|
||||
@@ -21,9 +21,10 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the TinyMceWidget from frontend-lib-content-components
|
||||
jest.mock('@edx/frontend-lib-content-components', () => ({
|
||||
TinyMceWidget: () => <div>Widget</div>,
|
||||
// Mock the TinyMceWidget
|
||||
jest.mock('./editors/sharedComponents/TinyMceWidget', () => ({
|
||||
__esModule: true, // Required to mock a default export
|
||||
default: () => <div>Widget</div>,
|
||||
Footer: () => <div>Footer</div>,
|
||||
prepareEditorRef: jest.fn(() => ({
|
||||
refReady: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
pageTitle: {
|
||||
id: 'course-authoring.import.page.title',
|
||||
id: 'course-authoring.accessibility.page.title',
|
||||
defaultMessage: 'Studio Accessibility Policy| {siteName}',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
|
||||
import AlertProctoringError from '../generic/AlertProctoringError';
|
||||
import { useModel } from '../generic/model-store';
|
||||
|
||||
@@ -71,7 +71,7 @@ const SettingCard = ({
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.helpButtonText)}
|
||||
variant="primary"
|
||||
className=" ml-1 mr-2"
|
||||
className="flex-shrink-0 ml-1 mr-2"
|
||||
/>
|
||||
<ModalPopup
|
||||
hasArrow
|
||||
|
||||
@@ -79,3 +79,7 @@
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@@ -9,3 +9,7 @@
|
||||
.mw-300px {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.right-0 {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Helmet } from 'react-helmet';
|
||||
import PropTypes from 'prop-types';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import Loading from '../generic/Loading';
|
||||
import useCertificates from './hooks/useCertificates';
|
||||
|
||||
@@ -59,10 +59,10 @@ describe('HeaderButtons Component', () => {
|
||||
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[0]));
|
||||
|
||||
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
|
||||
await userEvent.click(dropdownButton);
|
||||
userEvent.click(dropdownButton);
|
||||
|
||||
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
||||
await userEvent.click(verifiedMode);
|
||||
userEvent.click(verifiedMode);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(previewLink).toHaveAttribute('href', expect.stringContaining(certificatesDataMock.courseModes[1]));
|
||||
@@ -78,7 +78,7 @@ describe('HeaderButtons Component', () => {
|
||||
const { getByRole, queryByRole } = renderComponent();
|
||||
|
||||
const activationButton = getByRole('button', { name: messages.headingActionsActivate.defaultMessage });
|
||||
await userEvent.click(activationButton);
|
||||
userEvent.click(activationButton);
|
||||
|
||||
axiosMock.onPost(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
@@ -110,7 +110,7 @@ describe('HeaderButtons Component', () => {
|
||||
const { getByRole, queryByRole } = renderComponent();
|
||||
|
||||
const deactivateButton = getByRole('button', { name: messages.headingActionsDeactivate.defaultMessage });
|
||||
await userEvent.click(deactivateButton);
|
||||
userEvent.click(deactivateButton);
|
||||
|
||||
axiosMock.onPost(
|
||||
getUpdateCertificateApiUrl(courseId, certificatesDataMock.certificates[0].id),
|
||||
|
||||
@@ -69,3 +69,8 @@ export const CLIPBOARD_STATUS = {
|
||||
};
|
||||
|
||||
export const STRUCTURAL_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course'];
|
||||
|
||||
export const REGEX_RULES = {
|
||||
specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/,
|
||||
noSpaceRule: /^\S*$/,
|
||||
};
|
||||
|
||||
@@ -5,24 +5,24 @@ import type {} from 'react-select/base';
|
||||
// and add our custom property 'myCustomProp' to it.
|
||||
|
||||
export interface TagTreeEntry {
|
||||
explicit: boolean;
|
||||
children: Record<string, TagTreeEntry>;
|
||||
canChangeObjecttag: boolean;
|
||||
canDeleteObjecttag: boolean;
|
||||
explicit: boolean;
|
||||
children: Record<string, TagTreeEntry>;
|
||||
canChangeObjecttag: boolean;
|
||||
canDeleteObjecttag: boolean;
|
||||
}
|
||||
|
||||
export interface TaxonomySelectProps {
|
||||
taxonomyId: number;
|
||||
searchTerm: string;
|
||||
appliedContentTagsTree: Record<string, TagTreeEntry>;
|
||||
stagedContentTagsTree: Record<string, TagTreeEntry>;
|
||||
checkedTags: string[];
|
||||
selectCancelRef: Ref,
|
||||
selectAddRef: Ref,
|
||||
selectInlineAddRef: Ref,
|
||||
handleCommitStagedTags: () => void;
|
||||
handleCancelStagedTags: () => void;
|
||||
handleSelectableBoxChange: React.ChangeEventHandler;
|
||||
taxonomyId: number;
|
||||
searchTerm: string;
|
||||
appliedContentTagsTree: Record<string, TagTreeEntry>;
|
||||
stagedContentTagsTree: Record<string, TagTreeEntry>;
|
||||
checkedTags: string[];
|
||||
selectCancelRef: Ref,
|
||||
selectAddRef: Ref,
|
||||
selectInlineAddRef: Ref,
|
||||
handleCommitStagedTags: () => void;
|
||||
handleCancelStagedTags: () => void;
|
||||
handleSelectableBoxChange: React.ChangeEventHandler;
|
||||
}
|
||||
|
||||
// Unfortunately the only way to specify the custom props we pass into React Select
|
||||
@@ -32,11 +32,8 @@ export interface TaxonomySelectProps {
|
||||
// we should change to using a 'react context' to share this data within <ContentTagsCollapsible>,
|
||||
// rather than using the custom <Select> Props (selectProps).
|
||||
declare module 'react-select/base' {
|
||||
export interface Props<
|
||||
Option,
|
||||
IsMulti extends boolean,
|
||||
Group extends GroupBase<Option>
|
||||
> extends TaxonomySelectProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export interface Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> extends TaxonomySelectProps {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
Icon,
|
||||
} from '@openedx/paragon';
|
||||
import { Tag, KeyboardArrowDown, KeyboardArrowUp } from '@openedx/paragon/icons';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import SelectableBox from '../editors/sharedComponents/SelectableBox';
|
||||
import messages from './messages';
|
||||
|
||||
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
|
||||
@@ -74,7 +75,7 @@ const CustomMenu = (props) => {
|
||||
<div className="d-flex flex-row justify-content-end">
|
||||
<div className="d-inline">
|
||||
<Button
|
||||
tabIndex="0"
|
||||
tabIndex={0}
|
||||
ref={selectCancelRef}
|
||||
variant="tertiary"
|
||||
className="tags-drawer-cancel-button"
|
||||
@@ -83,7 +84,7 @@ const CustomMenu = (props) => {
|
||||
{ intl.formatMessage(messages.collapsibleCancelStagedTagsButtonText) }
|
||||
</Button>
|
||||
<Button
|
||||
tabIndex="0"
|
||||
tabIndex={0}
|
||||
ref={selectAddRef}
|
||||
variant="tertiary"
|
||||
className="text-info-500 add-tags-button"
|
||||
@@ -139,7 +140,7 @@ const CustomIndicatorsContainer = (props) => {
|
||||
onClick={handleCommitStagedTags}
|
||||
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
|
||||
ref={selectInlineAddRef}
|
||||
tabIndex="0"
|
||||
tabIndex={0}
|
||||
onKeyDown={disableActionKeys} // To prevent navigating staged tags when button focused
|
||||
>
|
||||
{ intl.formatMessage(messages.collapsibleInlineAddStagedTagsButtonText) }
|
||||
@@ -240,7 +241,7 @@ const ContentTagsCollapsible = ({
|
||||
const selectCancelRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectInlineAddRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectInlineEditModeRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
const selectInlineEditModeRef = React.useRef(/** @type {HTMLButtonElement | null} */(null));
|
||||
const selectRef = React.useRef(/** @type {HTMLSelectElement | null} */(null));
|
||||
|
||||
const [selectMenuIsOpen, setSelectMenuIsOpen] = React.useState(false);
|
||||
@@ -392,16 +393,18 @@ const ContentTagsCollapsible = ({
|
||||
&& (
|
||||
<div className="mb-3" key={taxonomyId}>
|
||||
<p className="text-gray-500">{intl.formatMessage(messages.collapsibleNoTagsAddedText)}
|
||||
<Button
|
||||
tabIndex="0"
|
||||
size="inline"
|
||||
ref={selectInlineEditModeRef}
|
||||
variant="link"
|
||||
className="text-info-500 add-tags-button"
|
||||
onClick={toEditMode}
|
||||
>
|
||||
{ intl.formatMessage(messages.collapsibleAddStagedTagsButtonText) }
|
||||
</Button>
|
||||
{canTagObject && (
|
||||
<Button
|
||||
tabIndex={0}
|
||||
size="inline"
|
||||
ref={selectInlineEditModeRef}
|
||||
variant="link"
|
||||
className="text-info-500 add-tags-button"
|
||||
onClick={toEditMode}
|
||||
>
|
||||
{ intl.formatMessage(messages.collapsibleAddStagedTagsButtonText) }
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -417,7 +420,7 @@ const ContentTagsCollapsible = ({
|
||||
)}
|
||||
|
||||
<div className="d-flex taxonomy-tags-selector-menu">
|
||||
{isEditMode && canTagObject && (
|
||||
{isEditMode && (
|
||||
<Select
|
||||
onBlur={handleOnBlur}
|
||||
styles={{
|
||||
|
||||
@@ -280,6 +280,30 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
expect(data.toEditMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not render "add tags" button when expanded and not allowed to tag objects', async () => {
|
||||
await getComponent({
|
||||
...data,
|
||||
isEditMode: false,
|
||||
taxonomyAndTagsData: {
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
canTagObject: false,
|
||||
contentTags: [],
|
||||
},
|
||||
});
|
||||
|
||||
const expandToggle = screen.getByRole('button', {
|
||||
name: /taxonomy 1/i,
|
||||
});
|
||||
fireEvent.click(expandToggle);
|
||||
expect(screen.queryByText(/no tags added yet/i)).toBeInTheDocument();
|
||||
|
||||
const addTags = screen.queryByRole('button', {
|
||||
name: /add tags/i,
|
||||
});
|
||||
expect(addTags).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call `openCollapsible` when click in the collapsible', async () => {
|
||||
await getComponent({
|
||||
...data,
|
||||
@@ -396,7 +420,7 @@ describe('<ContentTagsCollapsible />', () => {
|
||||
expect(data.removeGlobalStagedContentTag).toHaveBeenCalledWith(taxonomyId, 'Tag 3');
|
||||
});
|
||||
|
||||
it('should call `addRemovedContentTag` when a feched tag is deleted', async () => {
|
||||
it('should call `addRemovedContentTag` when a fetched tag is deleted', async () => {
|
||||
await getComponent();
|
||||
|
||||
const tag = screen.getByText(/tag 2/i);
|
||||
|
||||
@@ -116,7 +116,7 @@ const useContentTagsCollapsibleHelper = (
|
||||
// State to keep track of the staged tags (and along with ancestors) that should be removed
|
||||
const [stagedTagsToRemove, setStagedTagsToRemove] = React.useState(/** @type string[] */([]));
|
||||
|
||||
// State to keep track of the global tags (stagged and feched) that should be removed
|
||||
// State to keep track of the global tags (staged and fetched) that should be removed
|
||||
const [globalTagsToRemove, setGlobalTagsToRemove] = React.useState(/** @type string[] */([]));
|
||||
|
||||
// Handles the removal of staged content tags based on what was removed
|
||||
@@ -140,7 +140,7 @@ const useContentTagsCollapsibleHelper = (
|
||||
// A new tag has been removed
|
||||
removeGlobalStagedContentTag(id, tag);
|
||||
} else if (contentTags.some(t => t.value === tag)) {
|
||||
// A feched tag has been removed
|
||||
// A fetched tag has been removed
|
||||
addRemovedContentTag(id, tag);
|
||||
}
|
||||
});
|
||||
@@ -157,7 +157,7 @@ const useContentTagsCollapsibleHelper = (
|
||||
explicitStaged.forEach((tag) => {
|
||||
if (globalStagedRemovedContentTags[id]
|
||||
&& globalStagedRemovedContentTags[id].includes(tag.value)) {
|
||||
// A feched tag that has been removed has been added again
|
||||
// A fetched tag that has been removed has been added again
|
||||
deleteRemovedContentTag(id, tag.value);
|
||||
} else {
|
||||
// New tag added
|
||||
@@ -298,7 +298,7 @@ const useContentTagsCollapsibleHelper = (
|
||||
traversal[tag].lineage = tagLineage;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
isExplicit ? add(value.join(',')) : remove(value.join(','));
|
||||
traversal = traversal[tag].children;
|
||||
});
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
// @ts-check
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Container,
|
||||
Spinner,
|
||||
Stack,
|
||||
Button,
|
||||
Toast,
|
||||
} from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import messages from './messages';
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import Loading from '../generic/Loading';
|
||||
import useContentTagsDrawerContext from './ContentTagsDrawerHelper';
|
||||
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
|
||||
|
||||
/**
|
||||
* Drawer with the functionality to show and manage tags in a certain content.
|
||||
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
|
||||
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
|
||||
* Functions to close the drawer are handled internally.
|
||||
* TODO: We can delete this method when is no longer used on edx-platform.
|
||||
* - If you want to use it as react component, you need to pass the content id and the close functions
|
||||
* through the component parameters.
|
||||
*/
|
||||
const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
const intl = useIntl();
|
||||
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
|
||||
const params = useParams();
|
||||
const contentId = id ?? params.contentId;
|
||||
|
||||
const context = useContentTagsDrawerContext(contentId);
|
||||
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
|
||||
|
||||
const {
|
||||
showToastAfterSave,
|
||||
toReadMode,
|
||||
commitGlobalStagedTagsStatus,
|
||||
isContentDataLoaded,
|
||||
contentName,
|
||||
isTaxonomyListLoaded,
|
||||
isContentTaxonomyTagsLoaded,
|
||||
tagsByTaxonomy,
|
||||
stagedContentTags,
|
||||
collapsibleStates,
|
||||
isEditMode,
|
||||
commitGlobalStagedTags,
|
||||
toEditMode,
|
||||
toastMessage,
|
||||
closeToast,
|
||||
setCollapsibleToInitalState,
|
||||
otherTaxonomies,
|
||||
} = context;
|
||||
|
||||
let onCloseDrawer = onClose;
|
||||
if (onCloseDrawer === undefined) {
|
||||
onCloseDrawer = () => {
|
||||
// "*" allows communication with any origin
|
||||
window.parent.postMessage('closeManageTagsDrawer', '*');
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (event) => {
|
||||
/* Close drawer when ESC-key is pressed and selectable dropdown box not open */
|
||||
const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
|
||||
if (event.key === 'Escape' && !selectableBoxOpen && !blockingSheet) {
|
||||
onCloseDrawer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, [blockingSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
/* istanbul ignore next */
|
||||
if (commitGlobalStagedTagsStatus === 'success') {
|
||||
showToastAfterSave();
|
||||
toReadMode();
|
||||
}
|
||||
}, [commitGlobalStagedTagsStatus]);
|
||||
|
||||
// First call of the initial collapsible states
|
||||
React.useEffect(() => {
|
||||
setCollapsibleToInitalState();
|
||||
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]);
|
||||
|
||||
return (
|
||||
<ContentTagsDrawerContext.Provider value={context}>
|
||||
<div id="content-tags-drawer" className="mt-1 tags-drawer d-flex flex-column justify-content-between min-vh-100 pt-3">
|
||||
<Container size="xl">
|
||||
{ isContentDataLoaded
|
||||
? <h2 className="h3 pl-2.5">{ contentName }</h2>
|
||||
: (
|
||||
<div className="d-flex justify-content-center align-items-center flex-column">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<hr />
|
||||
<Container>
|
||||
<p className="h4 text-gray-500 font-weight-bold">
|
||||
{intl.formatMessage(messages.headerSubtitle)}
|
||||
</p>
|
||||
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
|
||||
? tagsByTaxonomy.map((data) => (
|
||||
<div key={`taxonomy-tags-collapsible-${data.id}`}>
|
||||
<ContentTagsCollapsible
|
||||
contentId={contentId}
|
||||
taxonomyAndTagsData={data}
|
||||
stagedContentTags={stagedContentTags[data.id] || []}
|
||||
collapsibleState={collapsibleStates[data.id] || false}
|
||||
/>
|
||||
<hr />
|
||||
</div>
|
||||
))
|
||||
: <Loading />}
|
||||
{otherTaxonomies.length !== 0 && (
|
||||
<div>
|
||||
<p className="h4 text-gray-500 font-weight-bold">
|
||||
{intl.formatMessage(messages.otherTagsHeader)}
|
||||
</p>
|
||||
<p className="other-description text-gray-500">
|
||||
{intl.formatMessage(messages.otherTagsDescription)}
|
||||
</p>
|
||||
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
|
||||
otherTaxonomies.map((data) => (
|
||||
<div key={`taxonomy-tags-collapsible-${data.id}`}>
|
||||
<ContentTagsCollapsible
|
||||
contentId={contentId}
|
||||
taxonomyAndTagsData={data}
|
||||
stagedContentTags={stagedContentTags[data.id] || []}
|
||||
collapsibleState={collapsibleStates[data.id] || false}
|
||||
/>
|
||||
<hr />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
|
||||
<Container
|
||||
className="bg-white position-sticky p-3.5 box-shadow-up-2 tags-drawer-footer"
|
||||
>
|
||||
<div className="d-flex justify-content-end">
|
||||
{ commitGlobalStagedTagsStatus !== 'loading' ? (
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Button
|
||||
className="font-weight-bold tags-drawer-cancel-button"
|
||||
variant="tertiary"
|
||||
onClick={isEditMode
|
||||
? toReadMode
|
||||
: onCloseDrawer}
|
||||
>
|
||||
{ intl.formatMessage(isEditMode
|
||||
? messages.tagsDrawerCancelButtonText
|
||||
: messages.tagsDrawerCloseButtonText)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="dark"
|
||||
className="rounded-0"
|
||||
onClick={isEditMode
|
||||
? commitGlobalStagedTags
|
||||
: toEditMode}
|
||||
>
|
||||
{ intl.formatMessage(isEditMode
|
||||
? messages.tagsDrawerSaveButtonText
|
||||
: messages.tagsDrawerEditTagsButtonText)}
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
: (
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)}
|
||||
{/* istanbul ignore next */
|
||||
toastMessage && (
|
||||
<Toast
|
||||
show
|
||||
onClose={closeToast}
|
||||
>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</ContentTagsDrawerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
ContentTagsDrawer.propTypes = {
|
||||
id: PropTypes.string,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
ContentTagsDrawer.defaultProps = {
|
||||
id: undefined,
|
||||
onClose: undefined,
|
||||
};
|
||||
|
||||
export default ContentTagsDrawer;
|
||||
@@ -2,7 +2,7 @@
|
||||
min-width: max(500px, 33vw);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
@media only screen and (width <= 500px) {
|
||||
.pgn__sheet-component:has(#content-tags-drawer) {
|
||||
min-width: 100vw;
|
||||
}
|
||||
@@ -22,6 +22,11 @@
|
||||
.other-description {
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.enable-taxonomies-button:not([disabled]):hover {
|
||||
background-color: transparent;
|
||||
color: $info-900 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply styles to sheet only if it has a child with a .tags-drawer class
|
||||
|
||||
@@ -1,509 +1,119 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
initializeMocks,
|
||||
render,
|
||||
waitFor,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
|
||||
} from '../testUtils';
|
||||
import ContentTagsDrawer from './ContentTagsDrawer';
|
||||
import {
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
useTaxonomyTagsData,
|
||||
useContentTaxonomyTagsUpdater,
|
||||
} from './data/apiHooks';
|
||||
import { getTaxonomyListData } from '../taxonomy/data/api';
|
||||
import messages from './messages';
|
||||
import { ContentTagsDrawerSheetContext } from './common/context';
|
||||
import {
|
||||
mockContentData,
|
||||
mockContentTaxonomyTagsData,
|
||||
mockTaxonomyListData,
|
||||
mockTaxonomyTagsData,
|
||||
} from './data/api.mocks';
|
||||
import { getContentTaxonomyTagsApiUrl } from './data/api';
|
||||
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
|
||||
const path = '/content/:contentId/*';
|
||||
const mockOnClose = jest.fn();
|
||||
const mockMutate = jest.fn();
|
||||
const mockSetBlockingSheet = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
mockContentTaxonomyTagsData.applyMock();
|
||||
mockTaxonomyListData.applyMock();
|
||||
mockTaxonomyTagsData.applyMock();
|
||||
mockContentData.applyMock();
|
||||
|
||||
const {
|
||||
stagedTagsId,
|
||||
otherTagsId,
|
||||
languageWithTagsId,
|
||||
languageWithoutTagsId,
|
||||
largeTagsId,
|
||||
emptyTagsId,
|
||||
} = mockContentTaxonomyTagsData;
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
contentId,
|
||||
}),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
// FIXME: replace these mocks with API mocks
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsData: jest.fn(() => {}),
|
||||
useContentData: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
data: {},
|
||||
})),
|
||||
useContentTaxonomyTagsUpdater: jest.fn(() => ({
|
||||
isError: false,
|
||||
mutate: mockMutate,
|
||||
})),
|
||||
useTaxonomyTagsData: jest.fn(() => ({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
canAddTag: false,
|
||||
data: [],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../taxonomy/data/api', () => ({
|
||||
// By default, the mock taxonomy list will never load (promise never resolves):
|
||||
getTaxonomyListData: jest.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = (params) => (
|
||||
<ContentTagsDrawerSheetContext.Provider value={params}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ContentTagsDrawer {...params} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</ContentTagsDrawerSheetContext.Provider>
|
||||
const renderDrawer = (contentId, drawerParams = {}) => (
|
||||
render(
|
||||
<ContentTagsDrawerSheetContext.Provider value={drawerParams}>
|
||||
<ContentTagsDrawer {...drawerParams} />
|
||||
</ContentTagsDrawerSheetContext.Provider>,
|
||||
{ path, params: { contentId } },
|
||||
)
|
||||
);
|
||||
|
||||
describe('<ContentTagsDrawer />', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await queryClient.resetQueries();
|
||||
// By default, we mock the API call with a promise that never resolves.
|
||||
// You can override this in specific test.
|
||||
getTaxonomyListData.mockReturnValue(new Promise(() => {}));
|
||||
useContentTaxonomyTagsUpdater.mockReturnValue({
|
||||
isError: false,
|
||||
mutate: mockMutate,
|
||||
});
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
const setupMockDataForStagedTagsTesting = () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 1',
|
||||
canTagObject: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setupMockDataWithOtherTagsTestings = () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 2',
|
||||
taxonomyId: 1234,
|
||||
canTagObject: false,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 3',
|
||||
lineage: ['Tag 3'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 4',
|
||||
lineage: ['Tag 4'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 1',
|
||||
canTagObject: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setupLargeMockDataForStagedTagsTesting = () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 2',
|
||||
taxonomyId: 124,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 3',
|
||||
taxonomyId: 125,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1.1.1',
|
||||
lineage: ['Tag 1', 'Tag 1.1', 'Tag 1.1.1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '(B) Taxonomy 4',
|
||||
taxonomyId: 126,
|
||||
canTagObject: true,
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
name: '(A) Taxonomy 5',
|
||||
taxonomyId: 127,
|
||||
canTagObject: true,
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 1',
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 124,
|
||||
name: 'Taxonomy 2',
|
||||
description: 'This is a description 2',
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 125,
|
||||
name: 'Taxonomy 3',
|
||||
description: 'This is a description 3',
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 127,
|
||||
name: '(A) Taxonomy 5',
|
||||
description: 'This is a description 5',
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 126,
|
||||
name: '(B) Taxonomy 4',
|
||||
description: 'This is a description 4',
|
||||
canTagObject: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('should render page and page title correctly', () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Manage tags')).toBeInTheDocument();
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(screen.getByText('Manage tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows spinner before the content data query is complete', async () => {
|
||||
await act(async () => {
|
||||
const { getAllByRole } = render(<RootWrapper />);
|
||||
const spinner = getAllByRole('status')[0];
|
||||
renderDrawer(stagedTagsId);
|
||||
const spinner = screen.getAllByRole('status')[0];
|
||||
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
|
||||
});
|
||||
});
|
||||
|
||||
it('shows spinner before the taxonomy tags query is complete', async () => {
|
||||
await act(async () => {
|
||||
const { getAllByRole } = render(<RootWrapper />);
|
||||
const spinner = getAllByRole('status')[1];
|
||||
renderDrawer(stagedTagsId);
|
||||
const spinner = screen.getAllByRole('status')[1];
|
||||
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the content display name after the query is complete', async () => {
|
||||
useContentData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
displayName: 'Unit 1',
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
expect(getByText('Unit 1')).toBeInTheDocument();
|
||||
});
|
||||
it('shows the content display name after the query is complete in drawer variant', async () => {
|
||||
renderDrawer('test');
|
||||
expect(await screen.findByText('Loading...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Unit 1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Manage tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the content display name after the query is complete in component variant', async () => {
|
||||
renderDrawer('test', { variant: 'component' });
|
||||
expect(await screen.findByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Unit 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Manage tags')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows content using params', async () => {
|
||||
useContentData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
displayName: 'Unit 1',
|
||||
},
|
||||
});
|
||||
render(<RootWrapper id={contentId} />);
|
||||
expect(screen.getByText('Unit 1')).toBeInTheDocument();
|
||||
renderDrawer(undefined, { id: 'test' });
|
||||
expect(await screen.findByText('Loading...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Unit 1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Manage tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 2',
|
||||
taxonomyId: 124,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 3',
|
||||
lineage: ['Tag 3'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 1',
|
||||
canTagObject: false,
|
||||
}, {
|
||||
id: 124,
|
||||
name: 'Taxonomy 2',
|
||||
description: 'This is a description 2',
|
||||
canTagObject: false,
|
||||
}],
|
||||
});
|
||||
await act(async () => {
|
||||
const { container, getByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(getByText('Taxonomy 2')).toBeInTheDocument();
|
||||
const { container } = renderDrawer(largeTagsId);
|
||||
await waitFor(() => { expect(screen.getByText('Taxonomy 1')).toBeInTheDocument(); });
|
||||
expect(screen.getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Taxonomy 2')).toBeInTheDocument();
|
||||
const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip');
|
||||
expect(tagCountBadges[0].textContent).toBe('2');
|
||||
expect(tagCountBadges[1].textContent).toBe('1');
|
||||
expect(tagCountBadges[0].textContent).toBe('3');
|
||||
expect(tagCountBadges[1].textContent).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should be read only on first render', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
it('should be read only on first render on drawer variant', async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /close/i }));
|
||||
expect(screen.getByRole('button', { name: /edit tags/i }));
|
||||
|
||||
// Not show delete tag buttons
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
@@ -518,9 +128,26 @@ describe('<ContentTagsDrawer />', () => {
|
||||
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change to edit mode when click on `Edit tags`', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
it('should be read only on first render on component variant', async () => {
|
||||
renderDrawer(stagedTagsId, { variant: 'component' });
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /manage tags/i }));
|
||||
|
||||
// Not show delete tag buttons
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
|
||||
// Not show add a tag select
|
||||
expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
|
||||
|
||||
// Not show cancel button
|
||||
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
|
||||
|
||||
// Not show save button
|
||||
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change to edit mode when click on `Edit tags` on drawer variant', async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
const editTagsButton = screen.getByRole('button', {
|
||||
name: /edit tags/i,
|
||||
@@ -542,9 +169,31 @@ describe('<ContentTagsDrawer />', () => {
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change to read mode when click on `Cancel`', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
it('should change to edit mode when click on `Manage tags` on component variant', async () => {
|
||||
renderDrawer(stagedTagsId, { variant: 'component' });
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
const manageTagsButton = screen.getByRole('button', {
|
||||
name: /manage tags/i,
|
||||
});
|
||||
fireEvent.click(manageTagsButton);
|
||||
|
||||
// Show delete tag buttons
|
||||
expect(screen.getAllByRole('button', {
|
||||
name: /delete/i,
|
||||
}).length).toBe(2);
|
||||
|
||||
// Show add a tag select
|
||||
expect(screen.getByText(/add a tag/i)).toBeInTheDocument();
|
||||
|
||||
// Show cancel button
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
|
||||
// Show save button
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change to read mode when click on `Cancel` on drawer variant', async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
const editTagsButton = screen.getByRole('button', {
|
||||
name: /edit tags/i,
|
||||
@@ -569,21 +218,57 @@ describe('<ContentTagsDrawer />', () => {
|
||||
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows spinner when loading commit tags', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
useContentTaxonomyTagsUpdater.mockReturnValue({
|
||||
status: 'loading',
|
||||
isError: false,
|
||||
mutate: mockMutate,
|
||||
});
|
||||
render(<RootWrapper />);
|
||||
it('should change to read mode when click on `Cancel` on component variant', async () => {
|
||||
renderDrawer(stagedTagsId, { variant: 'component' });
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
const manageTagsButton = screen.getByRole('button', {
|
||||
name: /manage tags/i,
|
||||
});
|
||||
fireEvent.click(manageTagsButton);
|
||||
|
||||
const cancelButton = screen.getByRole('button', {
|
||||
name: /cancel/i,
|
||||
});
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Not show delete tag buttons
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
|
||||
// Not show add a tag select
|
||||
expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
|
||||
|
||||
// Not show cancel button
|
||||
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
|
||||
|
||||
// Not show save button
|
||||
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
variant: 'drawer',
|
||||
editButton: /edit tags/i,
|
||||
},
|
||||
{
|
||||
variant: 'component',
|
||||
editButton: /manage tags/i,
|
||||
},
|
||||
])(
|
||||
'should hide "$editButton" button on $variant variant if not allowed to tag object',
|
||||
async ({ variant, editButton }) => {
|
||||
renderDrawer(stagedTagsId, { variant, readOnly: true });
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('button', { name: editButton })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('should test adding a content tag to the staged tags for a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// To edit mode
|
||||
@@ -598,7 +283,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(screen.getAllByText('Tag 3').length).toBe(1);
|
||||
expect((await screen.findAllByText('Tag 3')).length).toBe(1);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = screen.getByText('Tag 3');
|
||||
@@ -609,8 +294,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should test removing a staged content from a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// To edit mode
|
||||
@@ -625,7 +309,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(screen.getAllByText('Tag 3').length).toBe(1);
|
||||
expect((await screen.findAllByText('Tag 3')).length).toBe(1);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = screen.getByText('Tag 3');
|
||||
@@ -640,11 +324,9 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should test clearing staged tags for a taxonomy', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
|
||||
const {
|
||||
container,
|
||||
} = render(<RootWrapper />);
|
||||
} = renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// To edit mode
|
||||
@@ -659,7 +341,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied)
|
||||
expect(screen.getAllByText('Tag 3').length).toBe(1);
|
||||
expect((await screen.findAllByText('Tag 3')).length).toBe(1);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = screen.getByText('Tag 3');
|
||||
@@ -678,8 +360,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should test adding global staged tags and cancel', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// To edit mode
|
||||
@@ -694,7 +375,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = screen.getByText(/tag 3/i);
|
||||
const tag3 = await screen.findByText(/tag 3/i);
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// Click "Add tags" to save to global staged tags
|
||||
@@ -710,9 +391,8 @@ describe('<ContentTagsDrawer />', () => {
|
||||
expect(screen.queryByText(/tag 3/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should test delete feched tags and cancel', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
it('should test delete fetched tags and cancel', async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// To edit mode
|
||||
@@ -722,7 +402,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.click(editTagsButton);
|
||||
|
||||
// Delete the tag
|
||||
const tag = screen.getByText(/tag 2/i);
|
||||
const tag = await screen.findByText(/tag 2/i);
|
||||
const deleteButton = within(tag).getByRole('button', {
|
||||
name: /delete/i,
|
||||
});
|
||||
@@ -738,8 +418,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should test delete global staged tags and cancel', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// To edit mode
|
||||
@@ -754,7 +433,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = screen.getByText(/tag 3/i);
|
||||
const tag3 = await screen.findByText(/tag 3/i);
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// Click "Add tags" to save to global staged tags
|
||||
@@ -779,9 +458,8 @@ describe('<ContentTagsDrawer />', () => {
|
||||
expect(screen.queryByText(/tag 3/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should test add removed feched tags and cancel', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
it('should test add removed fetched tags and cancel', async () => {
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// To edit mode
|
||||
@@ -791,7 +469,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.click(editTagsButton);
|
||||
|
||||
// Delete the tag
|
||||
const tag = screen.getByText(/tag 2/i);
|
||||
const tag = await screen.findByText(/tag 2/i);
|
||||
const deleteButton = within(tag).getByRole('button', {
|
||||
name: /delete/i,
|
||||
});
|
||||
@@ -805,7 +483,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Click to check Tag 2
|
||||
const tag2 = screen.getByText(/tag 2/i);
|
||||
const tag2 = await screen.findByText(/tag 2/i);
|
||||
fireEvent.click(tag2);
|
||||
|
||||
// Click "Add tags" to save to global staged tags
|
||||
@@ -822,8 +500,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should call onClose when cancel is clicked', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper onClose={mockOnClose} />);
|
||||
renderDrawer(stagedTagsId, { onClose: mockOnClose });
|
||||
|
||||
const cancelButton = await screen.findByRole('button', {
|
||||
name: /close/i,
|
||||
@@ -837,7 +514,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
it('should call closeManageTagsDrawer when Escape key is pressed and no selectable box is active', () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { container } = render(<RootWrapper />);
|
||||
const { container } = renderDrawer(stagedTagsId);
|
||||
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
@@ -849,7 +526,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should call `onClose` when Escape key is pressed and no selectable box is active', () => {
|
||||
const { container } = render(<RootWrapper onClose={mockOnClose} />);
|
||||
const { container } = renderDrawer(stagedTagsId, { onClose: mockOnClose });
|
||||
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
@@ -861,7 +538,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
it('should not call closeManageTagsDrawer when Escape key is pressed and a selectable box is active', () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { container } = render(<RootWrapper />);
|
||||
const { container } = renderDrawer(stagedTagsId);
|
||||
|
||||
// Simulate that the selectable box is open by adding an element with the data attribute
|
||||
const selectableBox = document.createElement('div');
|
||||
@@ -881,7 +558,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should not call `onClose` when Escape key is pressed and a selectable box is active', () => {
|
||||
const { container } = render(<RootWrapper onClose={mockOnClose} />);
|
||||
const { container } = renderDrawer(stagedTagsId, { onClose: mockOnClose });
|
||||
|
||||
// Simulate that the selectable box is open by adding an element with the data attribute
|
||||
const selectableBox = document.createElement('div');
|
||||
@@ -900,8 +577,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
|
||||
it('should not call closeManageTagsDrawer when Escape key is pressed and container is blocked', () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { container } = render(<RootWrapper blockingSheet />);
|
||||
const { container } = renderDrawer(stagedTagsId, { blockingSheet: true });
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
});
|
||||
@@ -912,7 +588,10 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should not call `onClose` when Escape key is pressed and container is blocked', () => {
|
||||
const { container } = render(<RootWrapper blockingSheet onClose={mockOnClose} />);
|
||||
const { container } = renderDrawer(stagedTagsId, {
|
||||
blockingSheet: true,
|
||||
onClose: mockOnClose,
|
||||
});
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
});
|
||||
@@ -921,8 +600,10 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should call `setBlockingSheet` on add a tag', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper blockingSheet setBlockingSheet={mockSetBlockingSheet} />);
|
||||
renderDrawer(stagedTagsId, {
|
||||
blockingSheet: true,
|
||||
setBlockingSheet: mockSetBlockingSheet,
|
||||
});
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
expect(mockSetBlockingSheet).toHaveBeenCalledWith(false);
|
||||
@@ -939,7 +620,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
fireEvent.mouseDown(addTagsButton);
|
||||
|
||||
// Click to check Tag 3
|
||||
const tag3 = screen.getByText(/tag 3/i);
|
||||
const tag3 = await screen.findByText(/tag 3/i);
|
||||
fireEvent.click(tag3);
|
||||
|
||||
// Click "Add tags" to save to global staged tags
|
||||
@@ -950,8 +631,10 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should call `setBlockingSheet` on delete a tag', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper blockingSheet setBlockingSheet={mockSetBlockingSheet} />);
|
||||
renderDrawer(stagedTagsId, {
|
||||
blockingSheet: true,
|
||||
setBlockingSheet: mockSetBlockingSheet,
|
||||
});
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
expect(mockSetBlockingSheet).toHaveBeenCalledWith(false);
|
||||
@@ -973,8 +656,10 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should call `updateTags` mutation on save', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
const { axiosMock } = initializeMocks();
|
||||
const url = getContentTaxonomyTagsApiUrl(stagedTagsId);
|
||||
axiosMock.onPut(url).reply(200);
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
const editTagsButton = screen.getByRole('button', {
|
||||
name: /edit tags/i,
|
||||
@@ -986,12 +671,11 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalled();
|
||||
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
|
||||
});
|
||||
|
||||
it('should taxonomies must be ordered', async () => {
|
||||
setupLargeMockDataForStagedTagsTesting();
|
||||
render(<RootWrapper />);
|
||||
renderDrawer(largeTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
// First, taxonomies with content sorted by count implicit
|
||||
@@ -1011,18 +695,14 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should not show "Other tags" section', async () => {
|
||||
setupMockDataForStagedTagsTesting();
|
||||
|
||||
render(<RootWrapper />);
|
||||
renderDrawer(stagedTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Other tags')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Other tags" section', async () => {
|
||||
setupMockDataWithOtherTagsTestings();
|
||||
|
||||
render(<RootWrapper />);
|
||||
renderDrawer(otherTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Other tags')).toBeInTheDocument();
|
||||
@@ -1032,8 +712,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
|
||||
it('should test delete "Other tags" and cancel', async () => {
|
||||
setupMockDataWithOtherTagsTestings();
|
||||
render(<RootWrapper />);
|
||||
renderDrawer(otherTagsId);
|
||||
expect(await screen.findByText('Taxonomy 2')).toBeInTheDocument();
|
||||
|
||||
// To edit mode
|
||||
@@ -1057,4 +736,25 @@ describe('<ContentTagsDrawer />', () => {
|
||||
|
||||
expect(screen.getByText(/tag 3/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Language Taxonomy', async () => {
|
||||
renderDrawer(languageWithTagsId);
|
||||
expect(await screen.findByText('Languages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide Language Taxonomy', async () => {
|
||||
renderDrawer(languageWithoutTagsId);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Languages')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty drawer message', async () => {
|
||||
renderDrawer(emptyTagsId);
|
||||
expect(await screen.findByText(/to use tags, please or contact your administrator\./i)).toBeInTheDocument();
|
||||
const enableButton = screen.getByRole('button', {
|
||||
name: /enable a taxonomy/i,
|
||||
});
|
||||
fireEvent.click(enableButton);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/taxonomies');
|
||||
});
|
||||
});
|
||||
|
||||
399
src/content-tags-drawer/ContentTagsDrawer.tsx
Normal file
399
src/content-tags-drawer/ContentTagsDrawer.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Spinner,
|
||||
Stack,
|
||||
Button,
|
||||
Toast,
|
||||
} from '@openedx/paragon';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import messages from './messages';
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import Loading from '../generic/Loading';
|
||||
import useContentTagsDrawerContext from './ContentTagsDrawerHelper';
|
||||
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
|
||||
|
||||
interface TaxonomyListProps {
|
||||
contentId: string;
|
||||
}
|
||||
|
||||
const TaxonomyList = ({ contentId }: TaxonomyListProps) => {
|
||||
const navigate = useNavigate();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isTaxonomyListLoaded,
|
||||
isContentTaxonomyTagsLoaded,
|
||||
tagsByTaxonomy,
|
||||
stagedContentTags,
|
||||
collapsibleStates,
|
||||
} = React.useContext(ContentTagsDrawerContext);
|
||||
|
||||
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
|
||||
if (tagsByTaxonomy.length !== 0) {
|
||||
return (
|
||||
<div>
|
||||
{ tagsByTaxonomy.map((data) => (
|
||||
<div key={data.id}>
|
||||
<ContentTagsCollapsible
|
||||
contentId={contentId}
|
||||
taxonomyAndTagsData={data}
|
||||
stagedContentTags={stagedContentTags[data.id] || []}
|
||||
collapsibleState={collapsibleStates[data.id] || false}
|
||||
/>
|
||||
<hr />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
{...messages.emptyDrawerContent}
|
||||
values={{
|
||||
link: (
|
||||
<Button
|
||||
tabIndex={0}
|
||||
size="inline"
|
||||
variant="link"
|
||||
className="text-info-500 p-0 enable-taxonomies-button"
|
||||
onClick={() => navigate('/taxonomies')}
|
||||
>
|
||||
{ intl.formatMessage(messages.emptyDrawerContentLink) }
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Loading />;
|
||||
};
|
||||
|
||||
const ContentTagsDrawerTitle = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
isContentDataLoaded,
|
||||
contentName,
|
||||
} = useContext(ContentTagsDrawerContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ isContentDataLoaded
|
||||
? <h2 className="h3 pl-2.5">{ contentName }</h2>
|
||||
: (
|
||||
<div className="d-flex justify-content-center align-items-center flex-column">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<hr />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContentTagsDrawerVariantFooterProps {
|
||||
onClose: () => void,
|
||||
readOnly: boolean,
|
||||
}
|
||||
|
||||
const ContentTagsDrawerVariantFooter = ({ onClose, readOnly }: ContentTagsDrawerVariantFooterProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
commitGlobalStagedTagsStatus,
|
||||
commitGlobalStagedTags,
|
||||
isEditMode,
|
||||
toReadMode,
|
||||
toEditMode,
|
||||
} = useContext(ContentTagsDrawerContext);
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="bg-white position-sticky p-3.5 box-shadow-up-2 tags-drawer-footer"
|
||||
>
|
||||
<div className="d-flex justify-content-end">
|
||||
{ commitGlobalStagedTagsStatus !== 'loading' ? (
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Button
|
||||
className="font-weight-bold tags-drawer-cancel-button"
|
||||
variant="tertiary"
|
||||
onClick={isEditMode
|
||||
? toReadMode
|
||||
: onClose}
|
||||
>
|
||||
{ intl.formatMessage(isEditMode
|
||||
? messages.tagsDrawerCancelButtonText
|
||||
: messages.tagsDrawerCloseButtonText)}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
className="rounded-0"
|
||||
onClick={isEditMode
|
||||
? commitGlobalStagedTags
|
||||
: toEditMode}
|
||||
>
|
||||
{ intl.formatMessage(isEditMode
|
||||
? messages.tagsDrawerSaveButtonText
|
||||
: messages.tagsDrawerEditTagsButtonText)}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
: (
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContentTagsComponentVariantFooterProps {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const ContentTagsComponentVariantFooter = ({ readOnly = false }: ContentTagsComponentVariantFooterProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
commitGlobalStagedTagsStatus,
|
||||
commitGlobalStagedTags,
|
||||
isEditMode,
|
||||
toReadMode,
|
||||
toEditMode,
|
||||
} = useContext(ContentTagsDrawerContext);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isEditMode ? (
|
||||
<div>
|
||||
{ commitGlobalStagedTagsStatus !== 'loading' ? (
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Button
|
||||
className="font-weight-bold tags-drawer-cancel-button"
|
||||
variant="tertiary"
|
||||
onClick={toReadMode}
|
||||
>
|
||||
{intl.formatMessage(messages.tagsDrawerCancelButtonText)}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-0"
|
||||
onClick={commitGlobalStagedTags}
|
||||
block
|
||||
>
|
||||
{intl.formatMessage(messages.tagsDrawerSaveButtonText)}
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<div className="d-flex justify-content-center">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingMessage)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !readOnly && (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={toEditMode}
|
||||
block
|
||||
>
|
||||
{intl.formatMessage(messages.manageTagsButton)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContentTagsDrawerProps {
|
||||
id?: string;
|
||||
onClose?: () => void;
|
||||
variant?: 'drawer' | 'component';
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawer with the functionality to show and manage tags in a certain content.
|
||||
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
|
||||
* - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters.
|
||||
* Functions to close the drawer are handled internally.
|
||||
* TODO: We can delete this method when is no longer used on edx-platform.
|
||||
* - If you want to use it as react component, you need to pass the content id and the close functions
|
||||
* through the component parameters.
|
||||
*/
|
||||
const ContentTagsDrawer = ({
|
||||
id,
|
||||
onClose,
|
||||
variant = 'drawer',
|
||||
readOnly = false,
|
||||
}: ContentTagsDrawerProps) => {
|
||||
const intl = useIntl();
|
||||
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
|
||||
const params = useParams();
|
||||
const contentId = id ?? params.contentId;
|
||||
|
||||
if (contentId === undefined) {
|
||||
throw new Error('Error: contentId cannot be null.');
|
||||
}
|
||||
|
||||
const context = useContentTagsDrawerContext(contentId, !readOnly);
|
||||
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
|
||||
|
||||
const {
|
||||
showToastAfterSave,
|
||||
toReadMode,
|
||||
commitGlobalStagedTagsStatus,
|
||||
isTaxonomyListLoaded,
|
||||
isContentTaxonomyTagsLoaded,
|
||||
stagedContentTags,
|
||||
collapsibleStates,
|
||||
toastMessage,
|
||||
closeToast,
|
||||
setCollapsibleToInitalState,
|
||||
otherTaxonomies,
|
||||
} = context;
|
||||
|
||||
let onCloseDrawer: () => void;
|
||||
if (variant === 'drawer') {
|
||||
if (onClose === undefined) {
|
||||
onCloseDrawer = () => {
|
||||
// "*" allows communication with any origin
|
||||
window.parent.postMessage('closeManageTagsDrawer', '*');
|
||||
};
|
||||
} else {
|
||||
onCloseDrawer = onClose;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (variant === 'drawer') {
|
||||
const handleEsc = (event) => {
|
||||
/* Close drawer when ESC-key is pressed and selectable dropdown box not open */
|
||||
const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
|
||||
if (event.key === 'Escape' && !selectableBoxOpen && !blockingSheet) {
|
||||
onCloseDrawer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}, [blockingSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
/* istanbul ignore next */
|
||||
if (commitGlobalStagedTagsStatus === 'success') {
|
||||
showToastAfterSave();
|
||||
toReadMode();
|
||||
}
|
||||
}, [commitGlobalStagedTagsStatus]);
|
||||
|
||||
// First call of the initial collapsible states
|
||||
React.useEffect(() => {
|
||||
setCollapsibleToInitalState();
|
||||
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
|
||||
switch (variant) {
|
||||
case 'drawer':
|
||||
return <ContentTagsDrawerVariantFooter onClose={onCloseDrawer} readOnly={readOnly} />;
|
||||
case 'component':
|
||||
return <ContentTagsComponentVariantFooter readOnly={readOnly} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<ContentTagsDrawerContext.Provider value={context}>
|
||||
<div
|
||||
id="content-tags-drawer"
|
||||
className={classNames(
|
||||
'mt-1 tags-drawer d-flex flex-column justify-content-between pt-3',
|
||||
{
|
||||
'min-vh-100': variant === 'drawer',
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Container
|
||||
size="xl"
|
||||
className={classNames(
|
||||
{
|
||||
'p-0': variant === 'component',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{variant === 'drawer' && (
|
||||
<ContentTagsDrawerTitle />
|
||||
)}
|
||||
<Container
|
||||
className={classNames(
|
||||
{
|
||||
'p-0': variant === 'component',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{variant === 'drawer' && (
|
||||
<p className="h4 text-gray-500 font-weight-bold">
|
||||
{intl.formatMessage(messages.headerSubtitle)}
|
||||
</p>
|
||||
)}
|
||||
<TaxonomyList contentId={contentId} />
|
||||
{otherTaxonomies.length !== 0 && (
|
||||
<div>
|
||||
<p className="h4 text-gray-500 font-weight-bold">
|
||||
{intl.formatMessage(messages.otherTagsHeader)}
|
||||
</p>
|
||||
<p className="other-description text-gray-500">
|
||||
{intl.formatMessage(messages.otherTagsDescription)}
|
||||
</p>
|
||||
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
|
||||
otherTaxonomies.map((data) => (
|
||||
<div key={data.id}>
|
||||
<ContentTagsCollapsible
|
||||
contentId={contentId}
|
||||
taxonomyAndTagsData={data}
|
||||
stagedContentTags={stagedContentTags[data.id] || []}
|
||||
collapsibleState={collapsibleStates[data.id] || false}
|
||||
/>
|
||||
<hr />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
{renderFooter()}
|
||||
{/* istanbul ignore next */
|
||||
toastMessage && (
|
||||
<Toast
|
||||
show
|
||||
onClose={closeToast}
|
||||
>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</ContentTagsDrawerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentTagsDrawer;
|
||||
@@ -4,7 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useContentData, useContentTaxonomyTagsData, useContentTaxonomyTagsUpdater } from './data/apiHooks';
|
||||
import { useTaxonomyList } from '../taxonomy/data/apiHooks';
|
||||
import { extractOrgFromContentId } from './utils';
|
||||
import { extractOrgFromContentId, languageExportId } from './utils';
|
||||
import messages from './messages';
|
||||
import { ContentTagsDrawerSheetContext } from './common/context';
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ContentTagsDrawerSheetContext } from './common/context';
|
||||
/**
|
||||
* Handles the context and all the underlying logic for the ContentTagsDrawer component
|
||||
* @param {string} contentId
|
||||
* @param {boolean} canTagObject
|
||||
* @returns {{
|
||||
* stagedContentTags: Record<number, StagedTagData[]>,
|
||||
* addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void,
|
||||
@@ -46,7 +47,7 @@ import { ContentTagsDrawerSheetContext } from './common/context';
|
||||
* otherTaxonomies: TagsInTaxonomy[],
|
||||
* }}
|
||||
*/
|
||||
const useContentTagsDrawerContext = (contentId) => {
|
||||
const useContentTagsDrawerContext = (contentId, canTagObject) => {
|
||||
const intl = useIntl();
|
||||
const org = extractOrgFromContentId(contentId);
|
||||
|
||||
@@ -58,9 +59,9 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
const [stagedContentTags, setStagedContentTags] = React.useState({});
|
||||
// When a staged tags on a taxonomy is commitet then is saved on this map.
|
||||
const [globalStagedContentTags, setGlobalStagedContentTags] = React.useState({});
|
||||
// This stores feched tags deleted by the user.
|
||||
// This stores fetched tags deleted by the user.
|
||||
const [globalStagedRemovedContentTags, setGlobalStagedRemovedContentTags] = React.useState({});
|
||||
// Merges feched tags, global staged tags and global removed staged tags
|
||||
// Merges fetched tags, global staged tags and global removed staged tags
|
||||
const [tagsByTaxonomy, setTagsByTaxonomy] = React.useState(/** @type TagsInTaxonomy[] */ ([]));
|
||||
// Other taxonomies that the user doesn't have permissions
|
||||
const [otherTaxonomies, setOtherTaxonomies] = React.useState(/** @type TagsInTaxonomy[] */ ([]));
|
||||
@@ -79,8 +80,8 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
} = useContentTaxonomyTagsData(contentId);
|
||||
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
|
||||
|
||||
// Tags feched from database
|
||||
const { fechedTaxonomies, fechedOtherTaxonomies } = React.useMemo(() => {
|
||||
// Tags fetched from database
|
||||
const { fetchedTaxonomies, fetchedOtherTaxonomies } = React.useMemo(() => {
|
||||
const sortTaxonomies = (taxonomiesList) => {
|
||||
const taxonomiesWithData = taxonomiesList.filter(
|
||||
(t) => t.contentTags.length !== 0,
|
||||
@@ -115,6 +116,7 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
// Initialize list of content tags in taxonomies to populate
|
||||
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
|
||||
...taxonomy,
|
||||
canTagObject: taxonomy.canTagObject && canTagObject,
|
||||
contentTags: /** @type {ContentTagData[]} */([]),
|
||||
}));
|
||||
|
||||
@@ -142,14 +144,20 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Language taxonomy if is empty
|
||||
const filteredTaxonomies = taxonomiesList.filter(
|
||||
(taxonomy) => taxonomy.exportId !== languageExportId
|
||||
|| taxonomy.contentTags.length !== 0,
|
||||
);
|
||||
|
||||
return {
|
||||
fechedTaxonomies: sortTaxonomies(taxonomiesList),
|
||||
fechedOtherTaxonomies: otherTaxonomiesList,
|
||||
fetchedTaxonomies: sortTaxonomies(filteredTaxonomies),
|
||||
fetchedOtherTaxonomies: otherTaxonomiesList,
|
||||
};
|
||||
}
|
||||
return {
|
||||
fechedTaxonomies: [],
|
||||
fechedOtherTaxonomies: [],
|
||||
fetchedTaxonomies: [],
|
||||
fetchedOtherTaxonomies: [],
|
||||
};
|
||||
}, [taxonomyListData, contentTaxonomyTagsData]);
|
||||
|
||||
@@ -224,28 +232,28 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
|
||||
const openAllCollapsible = React.useCallback(() => {
|
||||
const updatedState = {};
|
||||
fechedTaxonomies.forEach((taxonomy) => {
|
||||
fetchedTaxonomies.forEach((taxonomy) => {
|
||||
updatedState[taxonomy.id] = true;
|
||||
});
|
||||
fechedOtherTaxonomies.forEach((taxonomy) => {
|
||||
fetchedOtherTaxonomies.forEach((taxonomy) => {
|
||||
updatedState[taxonomy.id] = true;
|
||||
});
|
||||
setColapsibleStates(updatedState);
|
||||
}, [fechedTaxonomies, setColapsibleStates]);
|
||||
}, [fetchedTaxonomies, setColapsibleStates]);
|
||||
|
||||
// Set initial state of collapsible based on content tags
|
||||
const setCollapsibleToInitalState = React.useCallback(() => {
|
||||
const updatedState = {};
|
||||
fechedTaxonomies.forEach((taxonomy) => {
|
||||
fetchedTaxonomies.forEach((taxonomy) => {
|
||||
// Taxonomy with content tags must be open
|
||||
updatedState[taxonomy.id] = taxonomy.contentTags.length !== 0;
|
||||
});
|
||||
fechedOtherTaxonomies.forEach((taxonomy) => {
|
||||
fetchedOtherTaxonomies.forEach((taxonomy) => {
|
||||
// Taxonomy with content tags must be open
|
||||
updatedState[taxonomy.id] = taxonomy.contentTags.length !== 0;
|
||||
});
|
||||
setColapsibleStates(updatedState);
|
||||
}, [fechedTaxonomies, setColapsibleStates]);
|
||||
}, [fetchedTaxonomies, setColapsibleStates]);
|
||||
|
||||
// Changes the drawer mode to edit
|
||||
const toEditMode = React.useCallback(() => {
|
||||
@@ -325,7 +333,7 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
const closeToast = React.useCallback(() => setToastMessage(undefined), [setToastMessage]);
|
||||
|
||||
let contentName = '';
|
||||
if (isContentDataLoaded) {
|
||||
if (isContentDataLoaded && contentData) {
|
||||
if ('displayName' in contentData) {
|
||||
contentName = contentData.displayName;
|
||||
} else {
|
||||
@@ -333,14 +341,14 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Updates `tagsByTaxonomy` merged feched tags, global staged tags
|
||||
// Updates `tagsByTaxonomy` merged fetched tags, global staged tags
|
||||
// and global removed staged tags.
|
||||
React.useEffect(() => {
|
||||
const mergedTags = cloneDeep(fechedTaxonomies).reduce((acc, obj) => (
|
||||
const mergedTags = cloneDeep(fetchedTaxonomies).reduce((acc, obj) => (
|
||||
{ ...acc, [obj.id]: obj }
|
||||
), {});
|
||||
|
||||
const mergedOtherTaxonomies = cloneDeep(fechedOtherTaxonomies).reduce((acc, obj) => (
|
||||
const mergedOtherTaxonomies = cloneDeep(fetchedOtherTaxonomies).reduce((acc, obj) => (
|
||||
{ ...acc, [obj.id]: obj }
|
||||
), {});
|
||||
|
||||
@@ -349,10 +357,10 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
// TODO test this
|
||||
// Filter out applied tags that should become implicit because a child tag was committed
|
||||
const stagedLineages = globalStagedContentTags[taxonomyId].map((t) => t.lineage.slice(0, -1)).flat();
|
||||
const fechedTags = mergedTags[taxonomyId].contentTags.filter((t) => !stagedLineages.includes(t.value));
|
||||
const fetchedTags = mergedTags[taxonomyId].contentTags.filter((t) => !stagedLineages.includes(t.value));
|
||||
|
||||
mergedTags[taxonomyId].contentTags = [
|
||||
...fechedTags,
|
||||
...fetchedTags,
|
||||
...globalStagedContentTags[taxonomyId],
|
||||
];
|
||||
}
|
||||
@@ -371,8 +379,8 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
});
|
||||
|
||||
// It is constructed this way to maintain the order
|
||||
// of the list `fechedTaxonomies`
|
||||
const mergedTagsArray = fechedTaxonomies.map(obj => mergedTags[obj.id]);
|
||||
// of the list `fetchedTaxonomies`
|
||||
const mergedTagsArray = fetchedTaxonomies.map(obj => mergedTags[obj.id]);
|
||||
|
||||
setTagsByTaxonomy(mergedTagsArray);
|
||||
setOtherTaxonomies(Object.values(mergedOtherTaxonomies));
|
||||
@@ -402,8 +410,8 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
fechedTaxonomies,
|
||||
fechedOtherTaxonomies,
|
||||
fetchedTaxonomies,
|
||||
fetchedOtherTaxonomies,
|
||||
globalStagedContentTags,
|
||||
globalStagedRemovedContentTags,
|
||||
]);
|
||||
|
||||
@@ -12,6 +12,10 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => {
|
||||
blockingSheet, setBlockingSheet,
|
||||
}), [blockingSheet, setBlockingSheet]);
|
||||
|
||||
// ContentTagsDrawerSheet is only used when editing Courses/Course Units,
|
||||
// so we assume it's ok to edit the object tags too.
|
||||
const readOnly = false;
|
||||
|
||||
return (
|
||||
<ContentTagsDrawerSheetContext.Provider value={context}>
|
||||
<Sheet
|
||||
@@ -23,6 +27,7 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => {
|
||||
<ContentTagsDrawer
|
||||
id={id}
|
||||
onClose={onClose}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</Sheet>
|
||||
</ContentTagsDrawerSheetContext.Provider>
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
Spinner,
|
||||
Button,
|
||||
} from '@openedx/paragon';
|
||||
import { SelectableBox } from '@edx/frontend-lib-content-components';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { ArrowDropDown, ArrowDropUp, Add } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import messages from './messages';
|
||||
|
||||
import SelectableBox from '../editors/sharedComponents/SelectableBox';
|
||||
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
const HighlightedText = ({ text, highlight }) => {
|
||||
if (!highlight) {
|
||||
@@ -309,7 +309,7 @@ const ContentTagsDropDownSelector = ({
|
||||
? (
|
||||
<div>
|
||||
<Button
|
||||
tabIndex="0"
|
||||
tabIndex={0}
|
||||
variant="tertiary"
|
||||
iconBefore={Add}
|
||||
onClick={loadMoreTags}
|
||||
@@ -323,7 +323,9 @@ const ContentTagsDropDownSelector = ({
|
||||
|
||||
{ tagPages.data.length === 0 && !tagPages.isLoading && (
|
||||
<div className="d-flex justify-content-center muted-text">
|
||||
<FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
|
||||
{ searchTerm
|
||||
? <FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
|
||||
: <FormattedMessage {...messages.noTagsInTaxonomyMessage} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -282,4 +282,28 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render "noTagsInTaxonomy" message if taxonomy is empty', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: [],
|
||||
},
|
||||
});
|
||||
|
||||
const searchTerm = '';
|
||||
await act(async () => {
|
||||
const { getByText } = await getComponent({ ...data, searchTerm });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||
});
|
||||
|
||||
const message = 'No tags in this taxonomy yet';
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,10 +72,16 @@ export async function getContentTaxonomyTagsCount(contentId) {
|
||||
/**
|
||||
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @returns {Promise<import("./types.mjs").ContentData>}
|
||||
* @returns {Promise<import("./types.mjs").ContentData | null>}
|
||||
*/
|
||||
export async function getContentData(contentId) {
|
||||
let url;
|
||||
if (contentId.startsWith('lib-collection:')) {
|
||||
// This type of usage_key is not used to obtain collections
|
||||
// is only used in tagging.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contentId.startsWith('lb:')) {
|
||||
url = getLibraryContentDataApiUrl(contentId);
|
||||
} else if (contentId.startsWith('course-v1:')) {
|
||||
|
||||
378
src/content-tags-drawer/data/api.mocks.ts
Normal file
378
src/content-tags-drawer/data/api.mocks.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import * as api from './api';
|
||||
import * as taxonomyApi from '../../taxonomy/data/api';
|
||||
import { languageExportId } from '../utils';
|
||||
|
||||
/**
|
||||
* Mock for `getContentTaxonomyTagsData()`
|
||||
*/
|
||||
export async function mockContentTaxonomyTagsData(contentId: string): Promise<any> {
|
||||
const thisMock = mockContentTaxonomyTagsData;
|
||||
switch (contentId) {
|
||||
case thisMock.stagedTagsId: return thisMock.stagedTags;
|
||||
case thisMock.otherTagsId: return thisMock.otherTags;
|
||||
case thisMock.languageWithTagsId: return thisMock.languageWithTags;
|
||||
case thisMock.languageWithoutTagsId: return thisMock.languageWithoutTags;
|
||||
case thisMock.largeTagsId: return thisMock.largeTags;
|
||||
case thisMock.emptyTagsId: return thisMock.emptyTags;
|
||||
default: throw new Error(`No mock has been set up for contentId "${contentId}"`);
|
||||
}
|
||||
}
|
||||
mockContentTaxonomyTagsData.stagedTagsId = 'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@stagedTagsId';
|
||||
mockContentTaxonomyTagsData.stagedTags = {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockContentTaxonomyTagsData.otherTagsId = 'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@otherTagsId';
|
||||
mockContentTaxonomyTagsData.otherTags = {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 2',
|
||||
taxonomyId: 1234,
|
||||
canTagObject: false,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 3',
|
||||
lineage: ['Tag 3'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 4',
|
||||
lineage: ['Tag 4'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockContentTaxonomyTagsData.languageWithTagsId = 'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithTagsId';
|
||||
mockContentTaxonomyTagsData.languageWithTags = {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Languages',
|
||||
taxonomyId: 1234,
|
||||
exportId: languageExportId,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 12345,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockContentTaxonomyTagsData.languageWithoutTagsId = 'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithoutTagsId';
|
||||
mockContentTaxonomyTagsData.languageWithoutTags = {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Languages',
|
||||
taxonomyId: 1234,
|
||||
exportId: languageExportId,
|
||||
canTagObject: true,
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 12345,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockContentTaxonomyTagsData.largeTagsId = 'block-v1:LargeTagsOrg+STC1+2023_1+type@vertical+block@largeTagsId';
|
||||
mockContentTaxonomyTagsData.largeTags = {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 123,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 2',
|
||||
taxonomyId: 124,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 3',
|
||||
taxonomyId: 125,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1.1.1',
|
||||
lineage: ['Tag 1', 'Tag 1.1', 'Tag 1.1.1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '(B) Taxonomy 4',
|
||||
taxonomyId: 126,
|
||||
canTagObject: true,
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
name: '(A) Taxonomy 5',
|
||||
taxonomyId: 127,
|
||||
canTagObject: true,
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+type@vertical+block@emptyTagsId';
|
||||
mockContentTaxonomyTagsData.emptyTags = {
|
||||
taxonomies: [],
|
||||
};
|
||||
mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData);
|
||||
|
||||
/**
|
||||
* Mock for `getTaxonomyListData()`
|
||||
*/
|
||||
export async function mockTaxonomyListData(org: string): Promise<any> {
|
||||
const thisMock = mockTaxonomyListData;
|
||||
switch (org) {
|
||||
case thisMock.stagedTagsOrg: return thisMock.stagedTags;
|
||||
case thisMock.languageTagsOrg: return thisMock.languageTags;
|
||||
case thisMock.largeTagsOrg: return thisMock.largeTags;
|
||||
case thisMock.emptyTagsOrg: return thisMock.emptyTags;
|
||||
default: throw new Error(`No mock has been set up for org "${org}"`);
|
||||
}
|
||||
}
|
||||
mockTaxonomyListData.stagedTagsOrg = 'StagedTagsOrg';
|
||||
mockTaxonomyListData.stagedTags = {
|
||||
results: [
|
||||
{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 1',
|
||||
canTagObject: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockTaxonomyListData.languageTagsOrg = 'LanguageTagsOrg';
|
||||
mockTaxonomyListData.languageTags = {
|
||||
results: [
|
||||
{
|
||||
id: 1234,
|
||||
name: 'Languages',
|
||||
description: 'This is a description 1',
|
||||
exportId: languageExportId,
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 12345,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 2',
|
||||
canTagObject: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockTaxonomyListData.largeTagsOrg = 'LargeTagsOrg';
|
||||
mockTaxonomyListData.largeTags = {
|
||||
results: [
|
||||
{
|
||||
id: 123,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 1',
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 124,
|
||||
name: 'Taxonomy 2',
|
||||
description: 'This is a description 2',
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 125,
|
||||
name: 'Taxonomy 3',
|
||||
description: 'This is a description 3',
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 127,
|
||||
name: '(A) Taxonomy 5',
|
||||
description: 'This is a description 5',
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 126,
|
||||
name: '(B) Taxonomy 4',
|
||||
description: 'This is a description 4',
|
||||
canTagObject: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockTaxonomyListData.emptyTagsOrg = 'EmptyTagsOrg';
|
||||
mockTaxonomyListData.emptyTags = {
|
||||
results: [],
|
||||
};
|
||||
mockTaxonomyListData.applyMock = () => jest.spyOn(taxonomyApi, 'getTaxonomyListData').mockImplementation(mockTaxonomyListData);
|
||||
|
||||
/**
|
||||
* Mock for `getTaxonomyTagsData()`
|
||||
*/
|
||||
export async function mockTaxonomyTagsData(taxonomyId: number): Promise<any> {
|
||||
const thisMock = mockTaxonomyTagsData;
|
||||
switch (taxonomyId) {
|
||||
case thisMock.stagedTagsTaxonomy: return thisMock.stagedTags;
|
||||
case thisMock.languageTagsTaxonomy: return thisMock.languageTags;
|
||||
default: throw new Error(`No mock has been set up for taxonomyId "${taxonomyId}"`);
|
||||
}
|
||||
}
|
||||
mockTaxonomyTagsData.stagedTagsTaxonomy = 123;
|
||||
mockTaxonomyTagsData.stagedTags = {
|
||||
count: 3,
|
||||
currentPage: 1,
|
||||
next: null,
|
||||
numPages: 1,
|
||||
previous: null,
|
||||
start: 1,
|
||||
results: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
},
|
||||
{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockTaxonomyTagsData.languageTagsTaxonomy = 1234;
|
||||
mockTaxonomyTagsData.languageTags = {
|
||||
count: 1,
|
||||
currentPage: 1,
|
||||
next: null,
|
||||
numPages: 1,
|
||||
previous: null,
|
||||
start: 1,
|
||||
results: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
};
|
||||
mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mockImplementation(mockTaxonomyTagsData);
|
||||
|
||||
/**
|
||||
* Mock for `getContentData()`
|
||||
*/
|
||||
export async function mockContentData(): Promise<any> {
|
||||
return mockContentData.data;
|
||||
}
|
||||
mockContentData.data = {
|
||||
displayName: 'Unit 1',
|
||||
};
|
||||
mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData);
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
updateContentTaxonomyTags,
|
||||
getContentTaxonomyTagsCount,
|
||||
} from './api';
|
||||
import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
|
||||
import { getLibraryId } from '../../generic/key-utils';
|
||||
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
|
||||
@@ -146,6 +148,14 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
contentPattern = contentId.replace(/\+type@.*$/, '*');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
|
||||
if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:')) {
|
||||
// Obtain library id from contentId
|
||||
const libraryId = getLibraryId(contentId);
|
||||
// Invalidate component metadata to update tags count
|
||||
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
|
||||
// Invalidate content search to update tags count
|
||||
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
}
|
||||
},
|
||||
onSuccess: /* istanbul ignore next */ () => {
|
||||
/* istanbul ignore next */
|
||||
|
||||
@@ -25,6 +25,11 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
|
||||
defaultMessage: 'No tags found with the search term "{searchTerm}"',
|
||||
},
|
||||
noTagsInTaxonomyMessage: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-in-taxonomy',
|
||||
defaultMessage: 'No tags in this taxonomy yet',
|
||||
description: 'Message when the user uses the tags dropdown selector of an empty taxonomy',
|
||||
},
|
||||
taxonomyTagChecked: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-checked',
|
||||
defaultMessage: 'Checked',
|
||||
@@ -64,7 +69,7 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Add a tag',
|
||||
},
|
||||
collapsibleNoTagsAddedText: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.placeholder-text',
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.no-tags-added-text',
|
||||
defaultMessage: 'No tags added yet.',
|
||||
},
|
||||
collapsibleAddStagedTagsButtonText: {
|
||||
@@ -124,6 +129,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'These tags are already applied, but you can\'t add new ones as you don\'t have access to their taxonomies.',
|
||||
description: 'Description of "Other tags" subsection in tags drawer',
|
||||
},
|
||||
emptyDrawerContent: {
|
||||
id: 'course-authoring.content-tags-drawer.empty',
|
||||
defaultMessage: 'To use tags, please {link} or contact your administrator.',
|
||||
description: 'Message when there are no taxonomies.',
|
||||
},
|
||||
emptyDrawerContentLink: {
|
||||
id: 'course-authoring.content-tags-drawer.empty-link',
|
||||
defaultMessage: 'enable a taxonomy',
|
||||
description: 'Message of the link used in empty drawer message.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -15,7 +15,7 @@ const TagsSidebarHeader = () => {
|
||||
const {
|
||||
data: contentTagsCount,
|
||||
isSuccess: isContentTagsCountLoaded,
|
||||
} = useContentTagsCount(contentId || '');
|
||||
} = useContentTagsCount(contentId);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
|
||||
export const languageExportId = 'languages-v1';
|
||||
|
||||
@@ -79,10 +79,9 @@ const ChecklistItemComment = ({
|
||||
<ul className="assignment-list">
|
||||
{gradedAssignmentsOutsideDateRange.map(assignment => (
|
||||
<li className="assignment-list-item" key={assignment.id}>
|
||||
<Hyperlink
|
||||
content={assignment.displayName}
|
||||
destination={`${outlineUrl}#${assignment.id}`}
|
||||
/>
|
||||
<Hyperlink destination={`${outlineUrl}#${assignment.id}`}>
|
||||
{assignment.displayName}
|
||||
</Hyperlink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -2,30 +2,30 @@ import * as healthValidators from './courseChecklistValidators';
|
||||
|
||||
const getValidatedValue = (data, id) => {
|
||||
switch (id) {
|
||||
case 'welcomeMessage':
|
||||
return healthValidators.hasWelcomeMessage(data.updates);
|
||||
case 'gradingPolicy':
|
||||
return healthValidators.hasGradingPolicy(data.grades);
|
||||
case 'certificate':
|
||||
return healthValidators.hasCertificate(data.certificates);
|
||||
case 'courseDates':
|
||||
return healthValidators.hasDates(data.dates);
|
||||
case 'assignmentDeadlines':
|
||||
return healthValidators.hasAssignmentDeadlines(data.assignments, data.dates);
|
||||
case 'videoDuration':
|
||||
return healthValidators.hasShortVideoDuration(data.videos);
|
||||
case 'mobileFriendlyVideo':
|
||||
return healthValidators.hasMobileFriendlyVideos(data.videos);
|
||||
case 'diverseSequences':
|
||||
return healthValidators.hasDiverseSequences(data.subsections);
|
||||
case 'weeklyHighlights':
|
||||
return healthValidators.hasWeeklyHighlights(data.sections);
|
||||
case 'unitDepth':
|
||||
return healthValidators.hasShortUnitDepth(data.units);
|
||||
case 'proctoringEmail':
|
||||
return healthValidators.hasProctoringEscalationEmail(data.proctoring);
|
||||
default:
|
||||
throw new Error(`Unknown validator ${id}.`);
|
||||
case 'welcomeMessage':
|
||||
return healthValidators.hasWelcomeMessage(data.updates);
|
||||
case 'gradingPolicy':
|
||||
return healthValidators.hasGradingPolicy(data.grades);
|
||||
case 'certificate':
|
||||
return healthValidators.hasCertificate(data.certificates);
|
||||
case 'courseDates':
|
||||
return healthValidators.hasDates(data.dates);
|
||||
case 'assignmentDeadlines':
|
||||
return healthValidators.hasAssignmentDeadlines(data.assignments, data.dates);
|
||||
case 'videoDuration':
|
||||
return healthValidators.hasShortVideoDuration(data.videos);
|
||||
case 'mobileFriendlyVideo':
|
||||
return healthValidators.hasMobileFriendlyVideos(data.videos);
|
||||
case 'diverseSequences':
|
||||
return healthValidators.hasDiverseSequences(data.subsections);
|
||||
case 'weeklyHighlights':
|
||||
return healthValidators.hasWeeklyHighlights(data.sections);
|
||||
case 'unitDepth':
|
||||
return healthValidators.hasShortUnitDepth(data.units);
|
||||
case 'proctoringEmail':
|
||||
return healthValidators.hasProctoringEscalationEmail(data.proctoring);
|
||||
default:
|
||||
throw new Error(`Unknown validator ${id}.`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Layout,
|
||||
Row,
|
||||
TransitionReplace,
|
||||
Toast,
|
||||
} from '@openedx/paragon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
@@ -52,9 +54,11 @@ import {
|
||||
} from '../generic/drag-helper/utils';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { getTagsExportFile } from './data/api';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
courseName,
|
||||
@@ -100,7 +104,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleNewUnitSubmit,
|
||||
getUnitUrl,
|
||||
handleVideoSharingOptionChange,
|
||||
handleCopyToClipboardClick,
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
@@ -117,6 +120,24 @@ const CourseOutline = ({ courseId }) => {
|
||||
errors,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
// Use `setToastMessage` to show the toast.
|
||||
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for the course data to load before exporting tags.
|
||||
if (courseId && courseName && location.hash === '#export-tags') {
|
||||
setToastMessage(intl.formatMessage(messages.exportTagsCreatingToastMessage));
|
||||
getTagsExportFile(courseId, courseName).then(() => {
|
||||
setToastMessage(intl.formatMessage(messages.exportTagsSuccessToastMessage));
|
||||
}).catch(() => {
|
||||
setToastMessage(intl.formatMessage(messages.exportTagsErrorToastMessage));
|
||||
});
|
||||
|
||||
// Delete `#export-tags` from location
|
||||
window.location.href = '#';
|
||||
}
|
||||
}, [location, courseId, courseName]);
|
||||
|
||||
const [sections, setSections] = useState(sectionsList);
|
||||
|
||||
const restoreSectionList = () => {
|
||||
@@ -375,7 +396,6 @@ const CourseOutline = ({ courseId }) => {
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
onOrderChange={updateUnitOrderByIndex}
|
||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||
discussionsSettings={discussionsSettings}
|
||||
/>
|
||||
))}
|
||||
@@ -438,6 +458,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
onConfigureSubmit={handleConfigureItemSubmit}
|
||||
currentItemData={currentItemData}
|
||||
enableProctoredExams={enableProctoredExams}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
/>
|
||||
<DeleteModal
|
||||
category={deleteCategory}
|
||||
@@ -457,6 +478,15 @@ const CourseOutline = ({ courseId }) => {
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
</div>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
show
|
||||
onClose={/* istanbul ignore next */ () => setToastMessage(null)}
|
||||
data-testid="taxonomy-toast"
|
||||
>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
act, render, waitFor, fireEvent, within,
|
||||
act, render, waitFor, fireEvent, within, screen,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
@@ -10,6 +10,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { closestCorners } from '@dnd-kit/core';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
getCourseBestPracticesApiUrl,
|
||||
getCourseLaunchApiUrl,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
getCourseBlockApiUrl,
|
||||
getCourseItemApiUrl,
|
||||
getXBlockBaseApiUrl,
|
||||
exportTags,
|
||||
} from './data/api';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import {
|
||||
@@ -74,9 +76,7 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../help-urls/hooks', () => ({
|
||||
@@ -135,6 +135,10 @@ describe('<CourseOutline />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
useLocation.mockReturnValue({
|
||||
pathname: mockPathname,
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
@@ -222,7 +226,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check video sharing option shows error on failure', async () => {
|
||||
const { findByLabelText, queryByRole } = render(<RootWrapper />);
|
||||
render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseBlockApiUrl(courseId), {
|
||||
@@ -231,7 +235,7 @@ describe('<CourseOutline />', () => {
|
||||
},
|
||||
})
|
||||
.reply(500);
|
||||
const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
|
||||
const optionDropdown = await screen.findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage);
|
||||
await act(
|
||||
async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }),
|
||||
);
|
||||
@@ -243,8 +247,10 @@ describe('<CourseOutline />', () => {
|
||||
},
|
||||
}));
|
||||
|
||||
const alertElement = queryByRole('alert');
|
||||
expect(alertElement).toHaveTextContent(
|
||||
const alertElements = screen.queryAllByRole('alert');
|
||||
expect(alertElements.find(
|
||||
(el) => el.classList.contains('alert-content'),
|
||||
)).toHaveTextContent(
|
||||
pageAlertMessages.alertFailedGeneric.defaultMessage,
|
||||
);
|
||||
});
|
||||
@@ -507,9 +513,10 @@ describe('<CourseOutline />', () => {
|
||||
notificationDismissUrl: '/some/url',
|
||||
});
|
||||
|
||||
const { findByRole } = render(<RootWrapper />);
|
||||
expect(await findByRole('alert')).toBeInTheDocument();
|
||||
const dismissBtn = await findByRole('button', { name: 'Dismiss' });
|
||||
render(<RootWrapper />);
|
||||
const alert = await screen.findByText(pageAlertMessages.configurationErrorTitle.defaultMessage);
|
||||
expect(alert).toBeInTheDocument();
|
||||
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
axiosMock
|
||||
.onDelete('/some/url')
|
||||
.reply(204);
|
||||
@@ -1405,6 +1412,7 @@ describe('<CourseOutline />', () => {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
visible_to_staff_only: isVisibleToStaffOnly,
|
||||
discussion_enabled: false,
|
||||
group_access: newGroupAccess,
|
||||
},
|
||||
})
|
||||
@@ -1423,6 +1431,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// after configuraiton response
|
||||
unit.visibilityState = 'staff_only';
|
||||
unit.discussion_enabled = false;
|
||||
unit.userPartitionInfo = {
|
||||
selectablePartitions: [
|
||||
{
|
||||
@@ -1465,6 +1474,11 @@ describe('<CourseOutline />', () => {
|
||||
)).toBeInTheDocument();
|
||||
let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||
await act(async () => fireEvent.click(visibilityCheckbox));
|
||||
let discussionCheckbox = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.discussionEnabledCheckbox.defaultMessage,
|
||||
);
|
||||
expect(discussionCheckbox).toBeChecked();
|
||||
await act(async () => fireEvent.click(discussionCheckbox));
|
||||
|
||||
let groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||
fireEvent.change(groupeType, { target: { value: '0' } });
|
||||
@@ -1481,6 +1495,10 @@ describe('<CourseOutline />', () => {
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||
expect(visibilityCheckbox).toBeChecked();
|
||||
discussionCheckbox = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.discussionEnabledCheckbox.defaultMessage,
|
||||
);
|
||||
expect(discussionCheckbox).not.toBeChecked();
|
||||
|
||||
groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||
expect(groupeType).toHaveValue('0');
|
||||
@@ -2145,10 +2163,10 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit copy & paste option works correctly', async () => {
|
||||
const { findAllByTestId, queryByTestId, findAllByRole } = render(<RootWrapper />);
|
||||
render(<RootWrapper />);
|
||||
// get first section -> first subsection -> first unit element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
@@ -2164,9 +2182,6 @@ describe('<CourseOutline />', () => {
|
||||
.onPost(getClipboardUrl(), {
|
||||
usage_key: unit.id,
|
||||
}).reply(200, clipboardUnit);
|
||||
// check that initialUserClipboard state is empty
|
||||
const { initialUserClipboard } = store.getState().courseOutline;
|
||||
expect(initialUserClipboard).toBeUndefined();
|
||||
|
||||
// find menu button and click on it to open menu
|
||||
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
|
||||
@@ -2176,9 +2191,6 @@ describe('<CourseOutline />', () => {
|
||||
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
|
||||
await act(async () => fireEvent.click(copyButton));
|
||||
|
||||
// check that initialUserClipboard state is updated
|
||||
expect(store.getState().generic.clipboardData).toEqual(clipboardUnit);
|
||||
|
||||
[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
// find clipboard content label
|
||||
const clipboardLabel = await within(subsectionElement).findByText(
|
||||
@@ -2187,7 +2199,7 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.mouseOver(clipboardLabel));
|
||||
|
||||
// find clipboard content popover link
|
||||
const popoverContent = queryByTestId('popover-content');
|
||||
const popoverContent = screen.queryByTestId('popover-content');
|
||||
expect(popoverContent.tagName).toBe('A');
|
||||
expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`);
|
||||
|
||||
@@ -2218,8 +2230,10 @@ describe('<CourseOutline />', () => {
|
||||
errorFiles: ['error.css'],
|
||||
});
|
||||
|
||||
let alerts = await screen.findAllByRole('alert');
|
||||
// Exclude processing notification toast
|
||||
alerts = alerts.filter((el) => !el.classList.contains('toast-container'));
|
||||
// 3 alerts should be present
|
||||
const alerts = await findAllByRole('alert');
|
||||
expect(alerts.length).toEqual(3);
|
||||
|
||||
// check alerts for errorFiles
|
||||
@@ -2237,4 +2251,38 @@ describe('<CourseOutline />', () => {
|
||||
// check pasteFileNotices in store
|
||||
expect(store.getState().courseOutline.pasteFileNotices).toEqual({});
|
||||
});
|
||||
|
||||
it('should show toats on export tags', async () => {
|
||||
const expectedResponse = 'this is a test';
|
||||
axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
.reply(200, expectedResponse);
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
});
|
||||
window.URL.createObjectURL = jest.fn().mockReturnValue('http://example.com/archivo');
|
||||
window.URL.revokeObjectURL = jest.fn();
|
||||
render(<RootWrapper />);
|
||||
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();
|
||||
|
||||
const expectedRequest = axiosMock.history.get.filter(request => request.url === exportTags(courseId));
|
||||
expect(expectedRequest.length).toBe(1);
|
||||
|
||||
expect(await screen.findByText('Course tags exported successfully')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show toast on export tags error', async () => {
|
||||
axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
.reply(404);
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('An error has occurred creating the file')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ const CardHeader = ({
|
||||
{(isVertical || isSequential) && (
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
|
||||
)}
|
||||
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && contentTagCount > 0 && (
|
||||
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && (
|
||||
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
|
||||
)}
|
||||
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||
|
||||
@@ -58,7 +58,7 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
menuCopy: {
|
||||
id: 'course-authoring.course-outline.card.menu.delete',
|
||||
id: 'course-authoring.course-outline.card.menu.copy',
|
||||
defaultMessage: 'Copy to clipboard',
|
||||
},
|
||||
menuProctoringLinkText: {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein
|
||||
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
|
||||
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||
|
||||
/**
|
||||
* @typedef {Object} courseOutline
|
||||
@@ -310,7 +310,7 @@ export async function configureCourseSubsection(
|
||||
* @param {object} groupAccess
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess) {
|
||||
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(unitId), {
|
||||
publish: 'republish',
|
||||
@@ -318,6 +318,7 @@ export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAcc
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
group_access: groupAccess,
|
||||
discussion_enabled: discussionEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -458,3 +459,33 @@ export async function dismissNotification(url) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the file of the exported tags
|
||||
* @param {string} courseId The ID of the content
|
||||
* @returns void
|
||||
*/
|
||||
export async function getTagsExportFile(courseId, courseName) {
|
||||
// Gets exported tags and builds the blob to download CSV file.
|
||||
// This can be done with this code:
|
||||
// `window.location.href = exportTags(contentId);`
|
||||
// but it is done in this way so we know when the operation ends to close the toast.
|
||||
const response = await getAuthenticatedHttpClient().get(exportTags(courseId), {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (response.status !== 200) {
|
||||
throw response.statusText;
|
||||
}
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${courseName}.csv`;
|
||||
a.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { updateClipboardData } from '../../generic/data/slice';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
@@ -57,7 +56,10 @@ import {
|
||||
const getErrorDetails = (error, dismissible = true) => {
|
||||
const errorInfo = { dismissible };
|
||||
if (error.response?.data) {
|
||||
errorInfo.data = JSON.stringify(error.response.data);
|
||||
const { data } = error.response;
|
||||
if ((typeof data === 'string' && !data.includes('</html>')) || typeof data === 'object') {
|
||||
errorInfo.data = JSON.stringify(data);
|
||||
}
|
||||
errorInfo.status = error.response.status;
|
||||
errorInfo.type = API_ERROR_TYPES.serverError;
|
||||
} else if (error.request) {
|
||||
@@ -85,7 +87,6 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
},
|
||||
} = outlineIndex;
|
||||
dispatch(fetchOutlineIndexSuccess(outlineIndex));
|
||||
dispatch(updateClipboardData(outlineIndex.initialUserClipboard));
|
||||
dispatch(updateStatusBar({
|
||||
courseReleaseDate,
|
||||
highlightsEnabledForMessaging,
|
||||
@@ -334,11 +335,11 @@ export function configureCourseSubsectionQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess) {
|
||||
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess),
|
||||
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess, discussionEnabled),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,15 +3,11 @@ import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import EnableHighlightsModal from './EnableHighlightsModal';
|
||||
import messages from './messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
@@ -56,7 +52,6 @@ describe('<EnableHighlightsModal />', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('renders EnableHighlightsModal component correctly', () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ const HighlightsModal = ({
|
||||
onClose={onClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header className="highlights-modal__header">
|
||||
<ModalDialog.Title>
|
||||
|
||||
@@ -5,16 +5,12 @@ import {
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import HighlightsModal from './HighlightsModal';
|
||||
import messages from './messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
@@ -68,7 +64,6 @@ describe('<HighlightsModal />', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentItemMock);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { copyToClipboard } from '../generic/data/thunks';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
@@ -72,6 +71,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
mfeProctoredExamSettingsUrl,
|
||||
advanceSettingsUrl,
|
||||
} = useSelector(getOutlineIndexData);
|
||||
|
||||
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
|
||||
const statusBarData = useSelector(getStatusBarData);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
@@ -95,10 +95,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||
|
||||
const handleCopyToClipboardClick = (usageKey) => {
|
||||
dispatch(copyToClipboard(usageKey));
|
||||
};
|
||||
|
||||
const handlePasteClipboardClick = (parentLocator, sectionId) => {
|
||||
dispatch(pasteClipboardContent(parentLocator, sectionId));
|
||||
};
|
||||
@@ -183,17 +179,17 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const handleConfigureItemSubmit = (...arg) => {
|
||||
switch (currentItem.category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(configureCourseSectionQuery(currentSection.id, ...arg));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(configureCourseSubsectionQuery(currentItem.id, currentSection.id, ...arg));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(configureCourseUnitQuery(currentItem.id, currentSection.id, ...arg));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(configureCourseSectionQuery(currentSection.id, ...arg));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(configureCourseSubsectionQuery(currentItem.id, currentSection.id, ...arg));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(configureCourseUnitQuery(currentItem.id, currentSection.id, ...arg));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
handleConfigureModalClose();
|
||||
};
|
||||
@@ -204,21 +200,21 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const handleDeleteItemSubmit = () => {
|
||||
switch (currentItem.category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(deleteCourseSectionQuery(currentItem.id));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(deleteCourseUnitQuery(
|
||||
currentItem.id,
|
||||
currentSubsection.id,
|
||||
currentSection.id,
|
||||
));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(deleteCourseSectionQuery(currentItem.id));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(deleteCourseUnitQuery(
|
||||
currentItem.id,
|
||||
currentSubsection.id,
|
||||
currentSection.id,
|
||||
));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
closeDeleteModal();
|
||||
};
|
||||
@@ -339,7 +335,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
openUnitPage,
|
||||
handleNewUnitSubmit,
|
||||
handleVideoSharingOptionChange,
|
||||
handleCopyToClipboardClick,
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
|
||||
@@ -29,6 +29,21 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.section-list.button.new-section',
|
||||
defaultMessage: 'New section',
|
||||
},
|
||||
exportTagsCreatingToastMessage: {
|
||||
id: 'course-authoring.course-outline.export-tags.toast.creating.message',
|
||||
defaultMessage: 'Please wait. Creating export file for course tags...',
|
||||
description: 'In progress message in toast when exporting tags of a course',
|
||||
},
|
||||
exportTagsSuccessToastMessage: {
|
||||
id: 'course-authoring.course-outline.export-tags.toast.success.message',
|
||||
defaultMessage: 'Course tags exported successfully',
|
||||
description: 'Success message in toast when exporting tags of a course',
|
||||
},
|
||||
exportTagsErrorToastMessage: {
|
||||
id: 'course-authoring.course-outline.export-tags.toast.error.message',
|
||||
defaultMessage: 'An error has occurred creating the file',
|
||||
description: 'Error message in toast when exporting tags of a course',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ErrorAlert } from '@edx/frontend-lib-content-components';
|
||||
import {
|
||||
Campaign as CampaignIcon,
|
||||
InfoOutline as InfoOutlineIcon,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import AlertMessage from '../../generic/alert-message';
|
||||
import AlertProctoringError from '../../generic/AlertProctoringError';
|
||||
@@ -41,8 +42,11 @@ const PageAlerts = ({
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
|
||||
const discussionAlertDismissKey = `discussionAlertDismissed-${courseId}`;
|
||||
const [showConfigAlert, setShowConfigAlert] = useState(true);
|
||||
const [showDiscussionAlert, setShowDiscussionAlert] = useState(true);
|
||||
const [showDiscussionAlert, setShowDiscussionAlert] = useState(
|
||||
localStorage.getItem(discussionAlertDismissKey) === null,
|
||||
);
|
||||
const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices);
|
||||
|
||||
const getAssetsUrl = () => {
|
||||
@@ -83,6 +87,7 @@ const PageAlerts = ({
|
||||
|
||||
const onDismiss = () => {
|
||||
setShowDiscussionAlert(false);
|
||||
localStorage.setItem(discussionAlertDismissKey, 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -336,31 +341,30 @@ const PageAlerts = ({
|
||||
};
|
||||
|
||||
const renderApiErrors = () => {
|
||||
const errorList = Object.entries(errors).filter(obj => obj[1] !== null).map(([k, v]) => {
|
||||
let errorList = Object.entries(errors).filter(obj => obj[1] !== null).map(([k, v]) => {
|
||||
switch (v.type) {
|
||||
case API_ERROR_TYPES.serverError:
|
||||
return {
|
||||
key: k,
|
||||
desc: v.data,
|
||||
title: intl.formatMessage(messages.serverErrorAlert, {
|
||||
status: v.status,
|
||||
}),
|
||||
dismissible: v.dismissible,
|
||||
};
|
||||
case API_ERROR_TYPES.networkError:
|
||||
return {
|
||||
key: k,
|
||||
title: intl.formatMessage(messages.networkErrorAlert),
|
||||
dismissible: v.dismissible,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
key: k,
|
||||
desc: v.data,
|
||||
dismissible: v.dismissible,
|
||||
};
|
||||
case API_ERROR_TYPES.serverError:
|
||||
return {
|
||||
key: k,
|
||||
desc: v.data || intl.formatMessage(messages.serverErrorAlertBody),
|
||||
title: intl.formatMessage(messages.serverErrorAlert),
|
||||
dismissible: v.dismissible,
|
||||
};
|
||||
case API_ERROR_TYPES.networkError:
|
||||
return {
|
||||
key: k,
|
||||
title: intl.formatMessage(messages.networkErrorAlert),
|
||||
dismissible: v.dismissible,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
key: k,
|
||||
title: v.data,
|
||||
dismissible: v.dismissible,
|
||||
};
|
||||
}
|
||||
});
|
||||
errorList = uniqBy(errorList, 'title');
|
||||
if (!errorList?.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -373,10 +377,7 @@ const PageAlerts = ({
|
||||
key={msgObj.key}
|
||||
dismissError={() => dispatch(dismissError(msgObj.key))}
|
||||
>
|
||||
{msgObj.title
|
||||
&& (
|
||||
<Alert.Heading>{msgObj.title}</Alert.Heading>
|
||||
)}
|
||||
<Alert.Heading>{msgObj.title}</Alert.Heading>
|
||||
{msgObj.desc && <Truncate lines={2}>{msgObj.desc}</Truncate>}
|
||||
</ErrorAlert>
|
||||
) : (
|
||||
@@ -385,10 +386,7 @@ const PageAlerts = ({
|
||||
icon={ErrorIcon}
|
||||
key={msgObj.key}
|
||||
>
|
||||
{msgObj.title
|
||||
&& (
|
||||
<Alert.Heading>{msgObj.title}</Alert.Heading>
|
||||
)}
|
||||
<Alert.Heading>{msgObj.title}</Alert.Heading>
|
||||
{msgObj.desc && <Truncate lines={2}>{msgObj.desc}</Truncate>}
|
||||
</Alert>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { act, render, fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
|
||||
@@ -84,7 +90,7 @@ describe('<PageAlerts />', () => {
|
||||
});
|
||||
|
||||
it('renders discussion alerts', async () => {
|
||||
const { queryByText } = renderComponent({
|
||||
renderComponent({
|
||||
...pageAlertsData,
|
||||
discussionsSettings: {
|
||||
providerType: 'openedx',
|
||||
@@ -93,14 +99,21 @@ describe('<PageAlerts />', () => {
|
||||
discussionsIncontextLearnmoreUrl: 'some-learn-more-url',
|
||||
});
|
||||
|
||||
expect(queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument();
|
||||
const learnMoreBtn = queryByText(messages.discussionNotificationLearnMore.defaultMessage);
|
||||
expect(screen.queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument();
|
||||
const learnMoreBtn = screen.queryByText(messages.discussionNotificationLearnMore.defaultMessage);
|
||||
expect(learnMoreBtn).toBeInTheDocument();
|
||||
expect(learnMoreBtn).toHaveAttribute('href', 'some-learn-more-url');
|
||||
|
||||
const feedbackLink = queryByText(messages.discussionNotificationFeedback.defaultMessage);
|
||||
expect(feedbackLink).toBeInTheDocument();
|
||||
expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url');
|
||||
const dismissBtn = screen.queryByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
const discussionAlertDismissKey = `discussionAlertDismissed-${pageAlertsData.courseId}`;
|
||||
expect(localStorage.getItem(discussionAlertDismissKey)).toBe('true');
|
||||
|
||||
await waitFor(() => {
|
||||
const feedbackLink = screen.queryByText(messages.discussionNotificationFeedback.defaultMessage);
|
||||
expect(feedbackLink).toBeInTheDocument();
|
||||
expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders deprecation warning alerts', async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ const messages = defineMessages({
|
||||
description: 'Learn more link in upgraded discussion notification alert',
|
||||
},
|
||||
discussionNotificationFeedback: {
|
||||
id: 'course-authoring.course-outline.page-alerts.discussionNotificationLearnMore',
|
||||
id: 'course-authoring.course-outline.page-alerts.discussionNotificationFeedback',
|
||||
defaultMessage: 'Share feedback',
|
||||
description: 'Share feedback link in upgraded discussion notification alert',
|
||||
},
|
||||
@@ -108,7 +108,12 @@ const messages = defineMessages({
|
||||
},
|
||||
serverErrorAlert: {
|
||||
id: 'course-authoring.course-outline.page-alert.server-error.title',
|
||||
defaultMessage: 'Request failed with status: {status}',
|
||||
defaultMessage: 'The Studio servers encountered an error',
|
||||
description: 'Generic server error alert title.',
|
||||
},
|
||||
serverErrorAlertBody: {
|
||||
id: 'course-authoring.course-outline.page-alert.server-error.body',
|
||||
defaultMessage: ' An error occurred in Studio and the page could not be loaded. Please try again in a few moments. We\'ve logged the error and our staff is currently working to resolve this error as soon as possible.',
|
||||
description: 'Generic server error alert title.',
|
||||
},
|
||||
networkErrorAlert: {
|
||||
|
||||
@@ -30,6 +30,7 @@ const PublishModal = ({
|
||||
onClose={onClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header className="publish-modal__header">
|
||||
<ModalDialog.Title>
|
||||
|
||||
@@ -3,16 +3,12 @@ import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import PublishModal from './PublishModal';
|
||||
import messages from './messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
@@ -100,7 +96,6 @@ describe('<PublishModal />', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentItemMock);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,15 +6,11 @@ import {
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
@@ -125,7 +121,6 @@ describe('<SectionCard />', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render SectionCard component correctly', () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Video Sharing',
|
||||
},
|
||||
videoSharingLink: {
|
||||
id: 'course-authoring.course-outline.status-bar.video-sharing.title',
|
||||
id: 'course-authoring.course-outline.status-bar.video-sharing.link',
|
||||
defaultMessage: 'Learn more',
|
||||
},
|
||||
videoSharingPerVideoText: {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
|
||||
import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import { useClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
@@ -49,7 +49,7 @@ const SubsectionCard = ({
|
||||
const isScrolledToElement = locatorId === subsection.id;
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'subsection';
|
||||
const { sharedClipboardData, showPasteUnit } = useCopyToClipboard();
|
||||
const { sharedClipboardData, showPasteUnit } = useClipboard();
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -233,7 +233,7 @@ const SubsectionCard = ({
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
{enableCopyPasteUnits && showPasteUnit && (
|
||||
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||
<PasteComponent
|
||||
className="mt-4"
|
||||
text={intl.formatMessage(messages.pasteButton)}
|
||||
|
||||
@@ -5,16 +5,12 @@ import {
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import SubsectionCard from './SubsectionCard';
|
||||
import cardHeaderMessages from '../card-header/messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
@@ -121,7 +117,6 @@ describe('<SubsectionCard />', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render SubsectionCard component correctly', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ const messages = defineMessages({
|
||||
defaultMessage: 'New unit',
|
||||
},
|
||||
pasteButton: {
|
||||
id: 'course-authoring.course-outline.subsection.button.new-unit',
|
||||
id: 'course-authoring.course-outline.subsection.button.paste-unit',
|
||||
defaultMessage: 'Paste unit',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import TitleLink from '../card-header/TitleLink';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
|
||||
const UnitCard = ({
|
||||
unit,
|
||||
@@ -30,7 +31,6 @@ const UnitCard = ({
|
||||
onDuplicateSubmit,
|
||||
getTitleLink,
|
||||
onOrderChange,
|
||||
onCopyToClipboardClick,
|
||||
discussionsSettings,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
@@ -41,6 +41,8 @@ const UnitCard = ({
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'unit';
|
||||
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
const {
|
||||
id,
|
||||
category,
|
||||
@@ -98,7 +100,7 @@ const UnitCard = ({
|
||||
};
|
||||
|
||||
const handleCopyClick = () => {
|
||||
onCopyToClipboardClick(unit.id);
|
||||
copyToClipboard(id);
|
||||
};
|
||||
|
||||
const titleComponent = (
|
||||
@@ -241,7 +243,6 @@ UnitCard.propTypes = {
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
||||
onCopyToClipboardClick: PropTypes.func.isRequired,
|
||||
discussionsSettings: PropTypes.shape({
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act, render, fireEvent, within,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import UnitCard from './UnitCard';
|
||||
import cardMessages from '../card-header/messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const section = {
|
||||
@@ -52,6 +47,13 @@ const unit = {
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -66,7 +68,6 @@ const renderComponent = (props) => render(
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onCopyToClipboardClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
@@ -92,7 +93,6 @@ describe('<UnitCard />', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render UnitCard component correctly', async () => {
|
||||
|
||||
@@ -19,20 +19,20 @@ const getItemStatus = ({
|
||||
hasChanges,
|
||||
}) => {
|
||||
switch (true) {
|
||||
case visibilityState === VisibilityTypes.STAFF_ONLY:
|
||||
return ITEM_BADGE_STATUS.staffOnly;
|
||||
case visibilityState === VisibilityTypes.GATED:
|
||||
return ITEM_BADGE_STATUS.gated;
|
||||
case visibilityState === VisibilityTypes.LIVE:
|
||||
return ITEM_BADGE_STATUS.live;
|
||||
case visibilityState === VisibilityTypes.UNSCHEDULED:
|
||||
return ITEM_BADGE_STATUS.unscheduled;
|
||||
case published && !hasChanges:
|
||||
return ITEM_BADGE_STATUS.publishedNotLive;
|
||||
case published && hasChanges:
|
||||
return ITEM_BADGE_STATUS.unpublishedChanges;
|
||||
default:
|
||||
return ITEM_BADGE_STATUS.draft;
|
||||
case visibilityState === VisibilityTypes.STAFF_ONLY:
|
||||
return ITEM_BADGE_STATUS.staffOnly;
|
||||
case visibilityState === VisibilityTypes.GATED:
|
||||
return ITEM_BADGE_STATUS.gated;
|
||||
case visibilityState === VisibilityTypes.LIVE:
|
||||
return ITEM_BADGE_STATUS.live;
|
||||
case visibilityState === VisibilityTypes.UNSCHEDULED:
|
||||
return ITEM_BADGE_STATUS.unscheduled;
|
||||
case published && !hasChanges:
|
||||
return ITEM_BADGE_STATUS.publishedNotLive;
|
||||
case published && hasChanges:
|
||||
return ITEM_BADGE_STATUS.unpublishedChanges;
|
||||
default:
|
||||
return ITEM_BADGE_STATUS.draft;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,41 +46,41 @@ const getItemStatus = ({
|
||||
*/
|
||||
const getItemStatusBadgeContent = (status, messages, intl) => {
|
||||
switch (status) {
|
||||
case ITEM_BADGE_STATUS.gated:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeGated),
|
||||
badgeIcon: LockIcon,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.live:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeLive),
|
||||
badgeIcon: CheckCircleIcon,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.publishedNotLive:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgePublishedNotLive),
|
||||
badgeIcon: null,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.staffOnly:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeStaffOnly),
|
||||
badgeIcon: LockIcon,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.unpublishedChanges:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeUnpublishedChanges),
|
||||
badgeIcon: DraftIcon,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.draft:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeDraft),
|
||||
badgeIcon: DraftIcon,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
badgeTitle: '',
|
||||
badgeIcon: null,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.gated:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeGated),
|
||||
badgeIcon: LockIcon,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.live:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeLive),
|
||||
badgeIcon: CheckCircleIcon,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.publishedNotLive:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgePublishedNotLive),
|
||||
badgeIcon: null,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.staffOnly:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeStaffOnly),
|
||||
badgeIcon: LockIcon,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.unpublishedChanges:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeUnpublishedChanges),
|
||||
badgeIcon: DraftIcon,
|
||||
};
|
||||
case ITEM_BADGE_STATUS.draft:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeDraft),
|
||||
badgeIcon: DraftIcon,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
badgeTitle: '',
|
||||
badgeIcon: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,36 +93,36 @@ const getItemStatusBadgeContent = (status, messages, intl) => {
|
||||
*/
|
||||
const getItemStatusBorder = (status) => {
|
||||
switch (status) {
|
||||
case ITEM_BADGE_STATUS.live:
|
||||
return {
|
||||
borderLeft: '5px solid #00688D',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.publishedNotLive:
|
||||
return {
|
||||
borderLeft: '5px solid #0D7D4D',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.gated:
|
||||
return {
|
||||
borderLeft: '5px solid #000000',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.staffOnly:
|
||||
return {
|
||||
borderLeft: '5px solid #000000',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.unpublishedChanges:
|
||||
return {
|
||||
borderLeft: '5px solid #F0CC00',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.draft:
|
||||
return {
|
||||
borderLeft: '5px solid #F0CC00',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.unscheduled:
|
||||
return {
|
||||
borderLeft: '5px solid #ccc',
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
case ITEM_BADGE_STATUS.live:
|
||||
return {
|
||||
borderLeft: '5px solid #00688D',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.publishedNotLive:
|
||||
return {
|
||||
borderLeft: '5px solid #0D7D4D',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.gated:
|
||||
return {
|
||||
borderLeft: '5px solid #000000',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.staffOnly:
|
||||
return {
|
||||
borderLeft: '5px solid #000000',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.unpublishedChanges:
|
||||
return {
|
||||
borderLeft: '5px solid #F0CC00',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.draft:
|
||||
return {
|
||||
borderLeft: '5px solid #F0CC00',
|
||||
};
|
||||
case ITEM_BADGE_STATUS.unscheduled:
|
||||
return {
|
||||
borderLeft: '5px solid #ccc',
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -195,14 +195,14 @@ const scrollToElement = (target, alignWithTop = false) => {
|
||||
*/
|
||||
const getVideoSharingOptionText = (id, messages, intl) => {
|
||||
switch (id) {
|
||||
case VIDEO_SHARING_OPTIONS.perVideo:
|
||||
return intl.formatMessage(messages.videoSharingPerVideoText);
|
||||
case VIDEO_SHARING_OPTIONS.allOn:
|
||||
return intl.formatMessage(messages.videoSharingAllOnText);
|
||||
case VIDEO_SHARING_OPTIONS.allOff:
|
||||
return intl.formatMessage(messages.videoSharingAllOffText);
|
||||
default:
|
||||
return '';
|
||||
case VIDEO_SHARING_OPTIONS.perVideo:
|
||||
return intl.formatMessage(messages.videoSharingPerVideoText);
|
||||
case VIDEO_SHARING_OPTIONS.allOn:
|
||||
return intl.formatMessage(messages.videoSharingAllOnText);
|
||||
case VIDEO_SHARING_OPTIONS.allOff:
|
||||
return intl.formatMessage(messages.videoSharingAllOffText);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
export const hasWelcomeMessage = (updates) => updates.hasUpdate;
|
||||
|
||||
export const hasGradingPolicy = (grades) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const { hasGradingPolicy, sumOfWeights } = grades;
|
||||
|
||||
return hasGradingPolicy && parseFloat(sumOfWeights.toPrecision(2), 10) === 1.0;
|
||||
};
|
||||
|
||||
export const hasCertificate = (certificates) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const { isActivated, hasCertificate } = certificates;
|
||||
|
||||
return isActivated && hasCertificate;
|
||||
|
||||
@@ -20,30 +20,30 @@ const getChecklistValidatedValue = (data, id) => {
|
||||
} = data;
|
||||
|
||||
switch (id) {
|
||||
case 'welcomeMessage':
|
||||
return healthValidators.hasWelcomeMessage(updates);
|
||||
case 'gradingPolicy':
|
||||
return healthValidators.hasGradingPolicy(grades);
|
||||
case 'certificate':
|
||||
return healthValidators.hasCertificate(certificates);
|
||||
case 'courseDates':
|
||||
return healthValidators.hasDates(dates);
|
||||
case 'assignmentDeadlines':
|
||||
return healthValidators.hasAssignmentDeadlines(assignments, dates);
|
||||
case 'videoDuration':
|
||||
return healthValidators.hasShortVideoDuration(videos);
|
||||
case 'mobileFriendlyVideo':
|
||||
return healthValidators.hasMobileFriendlyVideos(videos);
|
||||
case 'diverseSequences':
|
||||
return healthValidators.hasDiverseSequences(subsections);
|
||||
case 'weeklyHighlights':
|
||||
return healthValidators.hasWeeklyHighlights(sections);
|
||||
case 'unitDepth':
|
||||
return healthValidators.hasShortUnitDepth(units);
|
||||
case 'proctoringEmail':
|
||||
return healthValidators.hasProctoringEscalationEmail(proctoring);
|
||||
default:
|
||||
throw new Error(`Unknown validator ${id}.`);
|
||||
case 'welcomeMessage':
|
||||
return healthValidators.hasWelcomeMessage(updates);
|
||||
case 'gradingPolicy':
|
||||
return healthValidators.hasGradingPolicy(grades);
|
||||
case 'certificate':
|
||||
return healthValidators.hasCertificate(certificates);
|
||||
case 'courseDates':
|
||||
return healthValidators.hasDates(dates);
|
||||
case 'assignmentDeadlines':
|
||||
return healthValidators.hasAssignmentDeadlines(assignments, dates);
|
||||
case 'videoDuration':
|
||||
return healthValidators.hasShortVideoDuration(videos);
|
||||
case 'mobileFriendlyVideo':
|
||||
return healthValidators.hasMobileFriendlyVideos(videos);
|
||||
case 'diverseSequences':
|
||||
return healthValidators.hasDiverseSequences(subsections);
|
||||
case 'weeklyHighlights':
|
||||
return healthValidators.hasWeeklyHighlights(sections);
|
||||
case 'unitDepth':
|
||||
return healthValidators.hasShortUnitDepth(units);
|
||||
case 'proctoringEmail':
|
||||
return healthValidators.hasProctoringEscalationEmail(proctoring);
|
||||
default:
|
||||
throw new Error(`Unknown validator ${id}.`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,15 +3,11 @@ import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import XBlockStatus from './XBlockStatus';
|
||||
import messages from './messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
@@ -73,7 +69,6 @@ describe('<XBlockStatus /> for Instructor paced Section', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render XBlockStatus with explanatoryMessage', () => {
|
||||
@@ -141,7 +136,6 @@ describe('<XBlockStatus /> for self paced Section', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('renders XBlockStatus with grading type, due weeks etc.', () => {
|
||||
@@ -245,7 +239,6 @@ describe('<XBlockStatus /> for Instructor paced Subsection', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('renders XBlockStatus with release status, grading type, due date etc.', () => {
|
||||
@@ -375,7 +368,6 @@ describe('<XBlockStatus /> for self paced Subsection', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('renders XBlockStatus with grading type, due weeks etc.', () => {
|
||||
@@ -456,7 +448,6 @@ describe('<XBlockStatus /> for unit', () => {
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('renders XBlockStatus with status messages', () => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { updateSavingStatus } from '../generic/data/slice';
|
||||
import {
|
||||
getSavingStatus,
|
||||
getRedirectUrlObj,
|
||||
getCourseRerunData,
|
||||
getCourseData,
|
||||
} from '../generic/data/selectors';
|
||||
@@ -17,11 +15,9 @@ import { fetchStudioHomeData } from '../studio-home/data/thunks';
|
||||
const useCourseRerun = (courseId) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const courseData = useSelector(getCourseData);
|
||||
const courseRerunData = useSelector(getCourseRerunData);
|
||||
const redirectUrlObj = useSelector(getRedirectUrlObj);
|
||||
|
||||
const {
|
||||
displayName = '',
|
||||
@@ -46,10 +42,6 @@ const useCourseRerun = (courseId) => {
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
dispatch(updateSavingStatus({ status: '' }));
|
||||
const { url } = redirectUrlObj;
|
||||
if (url) {
|
||||
navigate('/home');
|
||||
}
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Add admin access',
|
||||
},
|
||||
removeButton: {
|
||||
id: 'course-authoring.course-team.member.button.remove',
|
||||
id: 'course-authoring.course-team.member.button.remove-admin-access',
|
||||
defaultMessage: 'Remove admin access',
|
||||
},
|
||||
deleteUserButton: {
|
||||
|
||||
@@ -19,33 +19,33 @@ import messages from './info-modal/messages';
|
||||
|
||||
const getInfoModalSettings = (modalType, currentEmail, errorMessage, courseName, intl) => {
|
||||
switch (modalType) {
|
||||
case MODAL_TYPES.delete:
|
||||
return {
|
||||
title: intl.formatMessage(messages.deleteModalTitle),
|
||||
message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }),
|
||||
variant: '',
|
||||
closeButtonText: intl.formatMessage(messages.deleteModalCancelButton),
|
||||
submitButtonText: intl.formatMessage(messages.deleteModalDeleteButton),
|
||||
closeButtonVariant: 'tertiary',
|
||||
};
|
||||
case MODAL_TYPES.error:
|
||||
return {
|
||||
title: intl.formatMessage(messages.errorModalTitle),
|
||||
message: errorMessage,
|
||||
variant: 'danger',
|
||||
closeButtonText: intl.formatMessage(messages.errorModalOkButton),
|
||||
closeButtonVariant: 'primary',
|
||||
};
|
||||
case MODAL_TYPES.warning:
|
||||
return {
|
||||
title: intl.formatMessage(messages.warningModalTitle),
|
||||
message: intl.formatMessage(messages.warningModalMessage, { email: currentEmail, courseName }),
|
||||
variant: 'warning',
|
||||
closeButtonText: intl.formatMessage(messages.warningModalReturnButton),
|
||||
mainButtonVariant: 'primary',
|
||||
};
|
||||
default:
|
||||
return '';
|
||||
case MODAL_TYPES.delete:
|
||||
return {
|
||||
title: intl.formatMessage(messages.deleteModalTitle),
|
||||
message: intl.formatMessage(messages.deleteModalMessage, { email: currentEmail, courseName }),
|
||||
variant: '',
|
||||
closeButtonText: intl.formatMessage(messages.deleteModalCancelButton),
|
||||
submitButtonText: intl.formatMessage(messages.deleteModalDeleteButton),
|
||||
closeButtonVariant: 'tertiary',
|
||||
};
|
||||
case MODAL_TYPES.error:
|
||||
return {
|
||||
title: intl.formatMessage(messages.errorModalTitle),
|
||||
message: errorMessage,
|
||||
variant: 'danger',
|
||||
closeButtonText: intl.formatMessage(messages.errorModalOkButton),
|
||||
closeButtonVariant: 'primary',
|
||||
};
|
||||
case MODAL_TYPES.warning:
|
||||
return {
|
||||
title: intl.formatMessage(messages.warningModalTitle),
|
||||
message: intl.formatMessage(messages.warningModalMessage, { email: currentEmail, courseName }),
|
||||
variant: 'warning',
|
||||
closeButtonText: intl.formatMessage(messages.warningModalReturnButton),
|
||||
mainButtonVariant: 'primary',
|
||||
};
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Warning as WarningIcon } from '@openedx/paragon/icons';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { DraggableList } from '@edx/frontend-lib-content-components';
|
||||
|
||||
import DraggableList from '../editors/sharedComponents/DraggableList';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
@@ -185,7 +185,9 @@ const CourseUnit = ({ courseId }) => {
|
||||
{showPasteXBlock && canPasteComponent && (
|
||||
<PasteComponent
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={handleCreateNewCourseXBlock}
|
||||
onClick={
|
||||
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
|
||||
}
|
||||
text={intl.formatMessage(messages.pasteButtonText)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
act, render, waitFor, fireEvent, within,
|
||||
act, render, waitFor, fireEvent, within, screen,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -38,10 +39,7 @@ import {
|
||||
courseVerticalChildrenMock,
|
||||
clipboardMockResponse,
|
||||
} from './__mocks__';
|
||||
import {
|
||||
clipboardUnit,
|
||||
clipboardXBlock,
|
||||
} from '../__mocks__';
|
||||
import { clipboardUnit, clipboardXBlock } from '../__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import deleteModalMessages from '../generic/delete-modal/messages';
|
||||
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
|
||||
@@ -54,6 +52,7 @@ import { extractCourseUnitId } from './sidebar/utils';
|
||||
import CourseUnit from './CourseUnit';
|
||||
|
||||
import configureModalMessages from '../generic/configure-modal/messages';
|
||||
import { getClipboardUrl } from '../generic/data/api';
|
||||
import courseXBlockMessages from './course-xblock/messages';
|
||||
import addComponentMessages from './add-component/messages';
|
||||
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
|
||||
@@ -63,6 +62,7 @@ import { RequestStatus } from '../data/constants';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
let queryClient;
|
||||
const courseId = '123';
|
||||
const blockId = '567890';
|
||||
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
|
||||
@@ -80,31 +80,6 @@ jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockedUsedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(({ queryKey }) => {
|
||||
if (queryKey[0] === 'contentTaxonomyTags') {
|
||||
return {
|
||||
data: {
|
||||
taxonomies: [],
|
||||
},
|
||||
isSuccess: true,
|
||||
};
|
||||
} if (queryKey[0] === 'contentTagsCount') {
|
||||
return {
|
||||
data: 17,
|
||||
isSuccess: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {},
|
||||
isSuccess: true,
|
||||
};
|
||||
}),
|
||||
useQueryClient: jest.fn(() => ({
|
||||
setQueryData: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
@@ -115,7 +90,9 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseUnit courseId={courseId} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CourseUnit courseId={courseId} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -132,7 +109,17 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
global.localStorage.clear();
|
||||
store = initializeStore();
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardUnit);
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
.reply(200, courseUnitIndexMock);
|
||||
@@ -147,7 +134,7 @@ describe('<CourseUnit />', () => {
|
||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||
axiosMock
|
||||
.onGet(getContentTaxonomyTagsApiUrl(blockId))
|
||||
.reply(200, {});
|
||||
.reply(200, { taxonomies: [] });
|
||||
axiosMock
|
||||
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
|
||||
.reply(200, 17);
|
||||
@@ -525,17 +512,19 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
it('should display a warning alert for unpublished course unit version', async () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
render(<RootWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
const unpublishedAlert = getByRole('alert', { class: 'course-unit-unpublished-alert' });
|
||||
const unpublishedAlert = screen.getAllByRole('alert').find(
|
||||
(el) => el.classList.contains('alert-content'),
|
||||
);
|
||||
expect(unpublishedAlert).toHaveTextContent(messages.alertUnpublishedVersion.defaultMessage);
|
||||
expect(unpublishedAlert).toHaveClass('alert-warning');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display an unpublished alert for a course unit with explicit staff lock and unpublished status', async () => {
|
||||
const { queryByRole } = render(<RootWrapper />);
|
||||
render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
@@ -547,8 +536,10 @@ describe('<CourseUnit />', () => {
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
const unpublishedAlert = queryByRole('alert', { class: 'course-unit-unpublished-alert' });
|
||||
expect(unpublishedAlert).toBeNull();
|
||||
const alert = screen.queryAllByRole('alert').find(
|
||||
(el) => el.classList.contains('alert-content'),
|
||||
);
|
||||
expect(alert).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1083,19 +1074,16 @@ describe('<CourseUnit />', () => {
|
||||
queryByTestId, getByRole, getAllByLabelText, getByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
await waitFor(() => {
|
||||
const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
|
||||
userEvent.click(xblockActionBtn);
|
||||
userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
|
||||
|
||||
@@ -1137,11 +1125,8 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
@@ -1193,11 +1178,8 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardXBlock,
|
||||
});
|
||||
.onGet(getClipboardUrl())
|
||||
.reply(200, clipboardXBlock);
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseUnitApiUrl(courseId))
|
||||
@@ -1224,13 +1206,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1284,13 +1259,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1345,13 +1313,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
@@ -1406,13 +1367,6 @@ describe('<CourseUnit />', () => {
|
||||
enable_copy_paste_units: true,
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseSectionVerticalApiUrl(blockId))
|
||||
.reply(200, {
|
||||
...courseSectionVerticalMock,
|
||||
user_clipboard: clipboardUnit,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
|
||||
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { getCourseSectionVertical } from '../data/selectors';
|
||||
import { COMPONENT_TYPES } from '../constants';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import ComponentModalView from './add-component-modals/ComponentModalView';
|
||||
import AddComponentButton from './add-component-btn';
|
||||
import messages from './messages';
|
||||
@@ -20,41 +20,41 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
|
||||
|
||||
const handleCreateNewXBlock = (type, moduleName) => {
|
||||
switch (type) {
|
||||
case COMPONENT_TYPES.discussion:
|
||||
case COMPONENT_TYPES.dragAndDrop:
|
||||
handleCreateNewCourseXBlock({ type, parentLocator: blockId });
|
||||
break;
|
||||
case COMPONENT_TYPES.problem:
|
||||
case COMPONENT_TYPES.video:
|
||||
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
|
||||
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
|
||||
});
|
||||
break;
|
||||
// TODO: The library functional will be a bit different of current legacy (CMS)
|
||||
// behaviour and this ticket is on hold (blocked by other development team).
|
||||
case COMPONENT_TYPES.library:
|
||||
handleCreateNewCourseXBlock({ type, category: 'library_content', parentLocator: blockId });
|
||||
break;
|
||||
case COMPONENT_TYPES.advanced:
|
||||
handleCreateNewCourseXBlock({
|
||||
type: moduleName, category: moduleName, parentLocator: blockId,
|
||||
});
|
||||
break;
|
||||
case COMPONENT_TYPES.openassessment:
|
||||
handleCreateNewCourseXBlock({
|
||||
boilerplate: moduleName, category: type, parentLocator: blockId,
|
||||
});
|
||||
break;
|
||||
case COMPONENT_TYPES.html:
|
||||
handleCreateNewCourseXBlock({
|
||||
type,
|
||||
boilerplate: moduleName,
|
||||
parentLocator: blockId,
|
||||
}, ({ courseKey, locator }) => {
|
||||
navigate(`/course/${courseKey}/editor/html/${locator}`);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
case COMPONENT_TYPES.discussion:
|
||||
case COMPONENT_TYPES.dragAndDrop:
|
||||
handleCreateNewCourseXBlock({ type, parentLocator: blockId });
|
||||
break;
|
||||
case COMPONENT_TYPES.problem:
|
||||
case COMPONENT_TYPES.video:
|
||||
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
|
||||
navigate(`/course/${courseKey}/editor/${type}/${locator}`);
|
||||
});
|
||||
break;
|
||||
// TODO: The library functional will be a bit different of current legacy (CMS)
|
||||
// behaviour and this ticket is on hold (blocked by other development team).
|
||||
case COMPONENT_TYPES.library:
|
||||
handleCreateNewCourseXBlock({ type, category: 'library_content', parentLocator: blockId });
|
||||
break;
|
||||
case COMPONENT_TYPES.advanced:
|
||||
handleCreateNewCourseXBlock({
|
||||
type: moduleName, category: moduleName, parentLocator: blockId,
|
||||
});
|
||||
break;
|
||||
case COMPONENT_TYPES.openassessment:
|
||||
handleCreateNewCourseXBlock({
|
||||
boilerplate: moduleName, category: type, parentLocator: blockId,
|
||||
});
|
||||
break;
|
||||
case COMPONENT_TYPES.html:
|
||||
handleCreateNewCourseXBlock({
|
||||
type,
|
||||
boilerplate: moduleName,
|
||||
parentLocator: blockId,
|
||||
}, ({ courseKey, locator }) => {
|
||||
navigate(`/course/${courseKey}/editor/html/${locator}`);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,37 +75,37 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case COMPONENT_TYPES.advanced:
|
||||
modalParams = {
|
||||
open: openAdvanced,
|
||||
close: closeAdvanced,
|
||||
isOpen: isOpenAdvanced,
|
||||
};
|
||||
break;
|
||||
case COMPONENT_TYPES.html:
|
||||
modalParams = {
|
||||
open: openHtml,
|
||||
close: closeHtml,
|
||||
isOpen: isOpenHtml,
|
||||
};
|
||||
break;
|
||||
case COMPONENT_TYPES.openassessment:
|
||||
modalParams = {
|
||||
open: openOpenAssessment,
|
||||
close: closeOpenAssessment,
|
||||
isOpen: isOpenOpenAssessment,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
return (
|
||||
<li key={type}>
|
||||
<AddComponentButton
|
||||
onClick={() => handleCreateNewXBlock(type)}
|
||||
displayName={displayName}
|
||||
type={type}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
case COMPONENT_TYPES.advanced:
|
||||
modalParams = {
|
||||
open: openAdvanced,
|
||||
close: closeAdvanced,
|
||||
isOpen: isOpenAdvanced,
|
||||
};
|
||||
break;
|
||||
case COMPONENT_TYPES.html:
|
||||
modalParams = {
|
||||
open: openHtml,
|
||||
close: closeHtml,
|
||||
isOpen: isOpenHtml,
|
||||
};
|
||||
break;
|
||||
case COMPONENT_TYPES.openassessment:
|
||||
modalParams = {
|
||||
open: openOpenAssessment,
|
||||
close: closeOpenAssessment,
|
||||
isOpen: isOpenOpenAssessment,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
return (
|
||||
<li key={type}>
|
||||
<AddComponentButton
|
||||
onClick={() => handleCreateNewXBlock(type)}
|
||||
displayName={displayName}
|
||||
type={type}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user