Compare commits
777 Commits
open-relea
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1c35f134c | ||
|
|
41af76f027 | ||
|
|
57a3b0963c | ||
|
|
92dcdd0a98 | ||
|
|
29f0cefc1e | ||
|
|
794a1d57b9 | ||
|
|
fe46e8a1a6 | ||
|
|
a525d3c22e | ||
|
|
81a2c3c0d2 | ||
|
|
0018eafdcc | ||
|
|
e7db2ef753 | ||
|
|
2e986d9b74 | ||
|
|
7c85195a27 | ||
|
|
b686acf5f5 | ||
|
|
166deeafbd | ||
|
|
f33fe7d0e5 | ||
|
|
e2896dbf94 | ||
|
|
f07d266a43 | ||
|
|
69cdc5f191 | ||
|
|
4f51f71acc | ||
|
|
c70eca1fde | ||
|
|
39dc5bbbd2 | ||
|
|
7fa61f3714 | ||
|
|
8ce7c1599d | ||
|
|
03bdcff331 | ||
|
|
d73d840e93 | ||
|
|
d23b5f53df | ||
|
|
da98bfa021 | ||
|
|
c37640aa69 | ||
|
|
ea35227389 | ||
|
|
c35bf95c1c | ||
|
|
bd26928154 | ||
|
|
cdc8efe17b | ||
|
|
8b6535ea58 | ||
|
|
4c9498971a | ||
|
|
a6b6a3f940 | ||
|
|
cab1a24e10 | ||
|
|
f6b7782d24 | ||
|
|
c7bbe8d0d1 | ||
|
|
20fd7ea13b | ||
|
|
d55d38ec12 | ||
|
|
e77c6ee74a | ||
|
|
8cb30bedd8 | ||
|
|
0ab3f5f669 | ||
|
|
9eaab9c2e5 | ||
|
|
ac28626b3c | ||
|
|
3f90fea26c | ||
|
|
a0466852d6 | ||
|
|
9fad507ada | ||
|
|
73351fa8e8 | ||
|
|
c8528a7874 | ||
|
|
124909ab74 | ||
|
|
77f66f3afb | ||
|
|
102288407f | ||
|
|
dd7c35497e | ||
|
|
8331d37b7f | ||
|
|
1d12506b01 | ||
|
|
c22c4ec5a6 | ||
|
|
69fc4be952 | ||
|
|
9d946eacd8 | ||
|
|
0af0935e86 | ||
|
|
fd33842109 | ||
|
|
13b5f3bc12 | ||
|
|
502ad904ea | ||
|
|
594ae27c0e | ||
|
|
0924cb1ba3 | ||
|
|
90ee5800b4 | ||
|
|
6be87b4a82 | ||
|
|
2137e985b3 | ||
|
|
ca93f890e1 | ||
|
|
6a57622a3c | ||
|
|
3d490c3879 | ||
|
|
1e09c83300 | ||
|
|
b660903836 | ||
|
|
3d2b8416f9 | ||
|
|
4afd07201b | ||
|
|
94ead51915 | ||
|
|
587e3f2647 | ||
|
|
3394656ed2 | ||
|
|
66eda1a58d | ||
|
|
c1ccc8c201 | ||
|
|
4ae65cfcee | ||
|
|
ad7e2035bc | ||
|
|
d5d67dbe14 | ||
|
|
3423e5efea | ||
|
|
f3d30925a8 | ||
|
|
1ec2c3b262 | ||
|
|
260df228fb | ||
|
|
6b740a89c6 | ||
|
|
03f8fdbdc3 | ||
|
|
0b86166a57 | ||
|
|
46eefa7592 | ||
|
|
036b4be854 | ||
|
|
eae0bfdca2 | ||
|
|
05f5903cbc | ||
|
|
725ae950f4 | ||
|
|
38c4f3bad3 | ||
|
|
46acf2a5a4 | ||
|
|
e8aafef127 | ||
|
|
cf451770ed | ||
|
|
d9c3975f26 | ||
|
|
5f42857332 | ||
|
|
f6babc2db9 | ||
|
|
67a053f3e0 | ||
|
|
845ab30af5 | ||
|
|
3244ecf70b | ||
|
|
a1390ebf36 | ||
|
|
89f9d9511f | ||
|
|
bc66c74a33 | ||
|
|
d760be1a53 | ||
|
|
929a34a0f6 | ||
|
|
55fc919c6f | ||
|
|
102a93486e | ||
|
|
981ba84163 | ||
|
|
8aa918bfb9 | ||
|
|
4ca2ab9f4e | ||
|
|
c01f1854ee | ||
|
|
987484d205 | ||
|
|
f86496468e | ||
|
|
e916ba29b9 | ||
|
|
6410ce1d8f | ||
|
|
d0eebfa0ea | ||
|
|
77daf2fbad | ||
|
|
354426037e | ||
|
|
b9af9ed700 | ||
|
|
2342eaae82 | ||
|
|
a1484264fb | ||
|
|
cff4a76b0c | ||
|
|
3e2e8095b4 | ||
|
|
ebd63a13a9 | ||
|
|
fd0d08daa1 | ||
|
|
6ae4c2d68b | ||
|
|
95331d1b10 | ||
|
|
7c63b66d8e | ||
|
|
810f506e52 | ||
|
|
33fd669c8f | ||
|
|
5c5204fb17 | ||
|
|
95db89a9dd | ||
|
|
81a878a658 | ||
|
|
b502de846a | ||
|
|
39f0123820 | ||
|
|
7ee70193c0 | ||
|
|
0e1574dba7 | ||
|
|
0d45ae6599 | ||
|
|
78246cf26b | ||
|
|
ca193563ec | ||
|
|
d0efd35e66 | ||
|
|
375b704eef | ||
|
|
ae121358db | ||
|
|
92b7c58af7 | ||
|
|
a4097fe6fc | ||
|
|
397f688300 | ||
|
|
8bd4b1b9a8 | ||
|
|
54d029c181 | ||
|
|
e6a4636147 | ||
|
|
efb4162926 | ||
|
|
6061232e10 | ||
|
|
ba6b8c8f9b | ||
|
|
9c16ba0075 | ||
|
|
02b987909b | ||
|
|
1bcc54bb05 | ||
|
|
5a5b0b905b | ||
|
|
44ed49c7d2 | ||
|
|
386baa3840 | ||
|
|
f1a56ad6bc | ||
|
|
465bb9f7a0 | ||
|
|
f9b7525d44 | ||
|
|
c7e82295c2 | ||
|
|
e02cf28b54 | ||
|
|
18c51e8e73 | ||
|
|
88b444e796 | ||
|
|
71635b33b6 | ||
|
|
515890d5ef | ||
|
|
c2960e1232 | ||
|
|
17e12f7f87 | ||
|
|
ffc39868e9 | ||
|
|
9ba01af816 | ||
|
|
fc222cc76c | ||
|
|
99a39568de | ||
|
|
9ce3cbfddd | ||
|
|
55a72b3f6e | ||
|
|
597004c82e | ||
|
|
3458b6f410 | ||
|
|
b70b4a796f | ||
|
|
d76945d2f2 | ||
|
|
7ee34f7d9a | ||
|
|
96c619cab8 | ||
|
|
9eda5e588c | ||
|
|
3aff0e03e0 | ||
|
|
852358f243 | ||
|
|
5ffd17db4c | ||
|
|
6f3c9616d6 | ||
|
|
5f4812ed47 | ||
|
|
d694b5c428 | ||
|
|
6b73130e9b | ||
|
|
d608f3947e | ||
|
|
db83838d5e | ||
|
|
004cdf35f1 | ||
|
|
6cb015e49d | ||
|
|
c5ae2c40d7 | ||
|
|
46edd0cb70 | ||
|
|
2c6b5c34a9 | ||
|
|
b000d2e048 | ||
|
|
437fba16fe | ||
|
|
c4038b4085 | ||
|
|
496ff21015 | ||
|
|
23e16ac6e0 | ||
|
|
d8c5762ee2 | ||
|
|
4d2d520234 | ||
|
|
b094722772 | ||
|
|
a9f061f10c | ||
|
|
4eb5d4effc | ||
|
|
925bcce81c | ||
|
|
f44e03c7c8 | ||
|
|
999988dd88 | ||
|
|
7ec1226965 | ||
|
|
5b00275371 | ||
|
|
30c1158775 | ||
|
|
c196b60b39 | ||
|
|
3022a267b1 | ||
|
|
19aacdfaf9 | ||
|
|
530cca7c59 | ||
|
|
e66d77cdd1 | ||
|
|
30333edfa2 | ||
|
|
538d85b5dd | ||
|
|
8066ae58f1 | ||
|
|
8ce7362fd8 | ||
|
|
9c6f247f0a | ||
|
|
085bc19313 | ||
|
|
ee7c769c2f | ||
|
|
a37be2dac1 | ||
|
|
d0fdc2ac5d | ||
|
|
8a4eecf6c2 | ||
|
|
e2e4003b44 | ||
|
|
434f114220 | ||
|
|
c31e1648bf | ||
|
|
379d2df2fc | ||
|
|
5933ae3034 | ||
|
|
d1269197ac | ||
|
|
12438e79f5 | ||
|
|
57317a4bb6 | ||
|
|
3bbc068a73 | ||
|
|
4179aa798a | ||
|
|
a1877bb499 | ||
|
|
cd44f15634 | ||
|
|
83242b1042 | ||
|
|
116c30663b | ||
|
|
0ed92cc7e8 | ||
|
|
3ec887089d | ||
|
|
b46165ede0 | ||
|
|
752d760030 | ||
|
|
3e2c4dc760 | ||
|
|
4136a63e92 | ||
|
|
ca8c9ef4fe | ||
|
|
14f1cfd0e1 | ||
|
|
f81ec9544a | ||
|
|
11d7d4beb4 | ||
|
|
70fc2f9642 | ||
|
|
f1d3c88f76 | ||
|
|
c5c05c86f7 | ||
|
|
b845b34b02 | ||
|
|
27c666cc27 | ||
|
|
dcdd024809 | ||
|
|
dab4b1dc92 | ||
|
|
c40bcd49ea | ||
|
|
8e3de876d0 | ||
|
|
aff083335c | ||
|
|
18ea60b470 | ||
|
|
9260b4834c | ||
|
|
d7a5b222ee | ||
|
|
17ce32b80c | ||
|
|
d4693a2c2e | ||
|
|
a446d5d3fc | ||
|
|
8f4139ad87 | ||
|
|
87e591cd71 | ||
|
|
90c02279e3 | ||
|
|
600a3de7b6 | ||
|
|
4390ca5435 | ||
|
|
5903ad2ae8 | ||
|
|
cc50ad9744 | ||
|
|
4e69301a4e | ||
|
|
3a877eb55c | ||
|
|
673639caee | ||
|
|
474f7c018d | ||
|
|
46398e7edc | ||
|
|
dfb06268ab | ||
|
|
b0301d5028 | ||
|
|
89d848e408 | ||
|
|
56ba73e455 | ||
|
|
c3d30227a3 | ||
|
|
f5bedd7708 | ||
|
|
218c796500 | ||
|
|
604845f4a3 | ||
|
|
751cdb1076 | ||
|
|
0ebc4eb3cd | ||
|
|
8a954c19e2 | ||
|
|
18b999b9a8 | ||
|
|
b155d04814 | ||
|
|
6f715e598f | ||
|
|
ab8d6e7913 | ||
|
|
d2bb164fab | ||
|
|
5aacaf44a3 | ||
|
|
4502dbe413 | ||
|
|
f5e2fa7448 | ||
|
|
5954b3122f | ||
|
|
2bf71cce10 | ||
|
|
8fd0320c88 | ||
|
|
da15a70942 | ||
|
|
52f4c51362 | ||
|
|
a0c2fb2686 | ||
|
|
223f792e2b | ||
|
|
175d9cf5cb | ||
|
|
94ef29938f | ||
|
|
4e38c9e9a6 | ||
|
|
81a1ec7e1e | ||
|
|
90da11782f | ||
|
|
82c9e07f0f | ||
|
|
16fb3a1bb4 | ||
|
|
e623f03c4b | ||
|
|
b2173afb9c | ||
|
|
c787a77f9e | ||
|
|
075587c1f0 | ||
|
|
a0b0b1f8d4 | ||
|
|
16f20cad66 | ||
|
|
40c67995a4 | ||
|
|
0fc68f7d93 | ||
|
|
90d3668128 | ||
|
|
1440c8239c | ||
|
|
bb29f9624e | ||
|
|
746b0fed4b | ||
|
|
02d14b95a7 | ||
|
|
484fc95ea0 | ||
|
|
bdb851eb8b | ||
|
|
b984296550 | ||
|
|
39fca96523 | ||
|
|
d1e817d4ba | ||
|
|
b35042ca97 | ||
|
|
88e9eb3fdf | ||
|
|
4409be4cc6 | ||
|
|
e314de2042 | ||
|
|
fc329fe9c2 | ||
|
|
aea3a7c830 | ||
|
|
35b5459dec | ||
|
|
2b82ac9faf | ||
|
|
24ad8851e6 | ||
|
|
b62645349e | ||
|
|
d2c160b771 | ||
|
|
6e39072334 | ||
|
|
245bf9aa6b | ||
|
|
b8a4163629 | ||
|
|
a5730b625a | ||
|
|
f03ac8b6d9 | ||
|
|
9e72f1c9e9 | ||
|
|
9693d938c6 | ||
|
|
f45bb43064 | ||
|
|
32f9804e4a | ||
|
|
feef40f6d0 | ||
|
|
a266e3dca9 | ||
|
|
6cc50a983e | ||
|
|
16caae04af | ||
|
|
2452d09913 | ||
|
|
b7f2a1f689 | ||
|
|
2416782eca | ||
|
|
5d0a428bdf | ||
|
|
fe4909ce07 | ||
|
|
9d61e836a5 | ||
|
|
891e28ec59 | ||
|
|
5ec4893f47 | ||
|
|
c5c0cf5392 | ||
|
|
350c2080ec | ||
|
|
44b718ad02 | ||
|
|
97333ada47 | ||
|
|
3467534bc7 | ||
|
|
5f43f945bb | ||
|
|
0ee0f41f3e | ||
|
|
35b66ae38b | ||
|
|
361e14c980 | ||
|
|
8e967fa3bf | ||
|
|
67feee5e0b | ||
|
|
837ac4e635 | ||
|
|
5c6ddd2888 | ||
|
|
7ef1b5b92d | ||
|
|
672d39f99c | ||
|
|
08a0d8e30b | ||
|
|
208c7a1ada | ||
|
|
afe2a754bd | ||
|
|
594ffe4aa9 | ||
|
|
8ecdedcdc8 | ||
|
|
11e144dec0 | ||
|
|
c71e586e64 | ||
|
|
d0d2aeed71 | ||
|
|
737833cdeb | ||
|
|
c00ebc9f64 | ||
|
|
41ee7e9538 | ||
|
|
b7181d5643 | ||
|
|
50e0235a6a | ||
|
|
5166546048 | ||
|
|
49d3acd1a1 | ||
|
|
cdf799d515 | ||
|
|
ed26467f0d | ||
|
|
fea3991915 | ||
|
|
30ca5f577e | ||
|
|
4047c3c923 | ||
|
|
3d32c8624d | ||
|
|
0c70e31655 | ||
|
|
10e1a451f7 | ||
|
|
7f2d3700d7 | ||
|
|
eb88d9b46d | ||
|
|
26dbac709f | ||
|
|
d6592a77f9 | ||
|
|
91d0feaed9 | ||
|
|
237599c830 | ||
|
|
56d0ad3db9 | ||
|
|
c0b504763e | ||
|
|
4aa44b89a3 | ||
|
|
be661b8a27 | ||
|
|
3c75052d8b | ||
|
|
229a674469 | ||
|
|
2c75c7f790 | ||
|
|
d595986d0e | ||
|
|
43d8784014 | ||
|
|
f15b71d20c | ||
|
|
aae79c45bb | ||
|
|
5233c6aa59 | ||
|
|
d59c9e38bd | ||
|
|
eb35897bf7 | ||
|
|
c28b8c6840 | ||
|
|
1698720aad | ||
|
|
57a2c7bcb6 | ||
|
|
f6f67a1a80 | ||
|
|
aec7e1281f | ||
|
|
cdf0ee5e4e | ||
|
|
f0858b7381 | ||
|
|
5f49bedaa8 | ||
|
|
a94f472459 | ||
|
|
2fc2d86a44 | ||
|
|
899f88ca1e | ||
|
|
399dd04df6 | ||
|
|
46dd8739ab | ||
|
|
5de18e0dba | ||
|
|
ca3bc9151c | ||
|
|
9cccf09394 | ||
|
|
ced29278d5 | ||
|
|
bc8197a9af | ||
|
|
f86677171f | ||
|
|
966b59b70f | ||
|
|
0e6aff7c6f | ||
|
|
13dbb732e3 | ||
|
|
b80748aa4c | ||
|
|
f098fe1a3a | ||
|
|
a989fabb92 | ||
|
|
1257e81781 | ||
|
|
b08890b794 | ||
|
|
7f1c7b86ef | ||
|
|
a5fd3a7f7e | ||
|
|
4513cc8834 | ||
|
|
40103a2386 | ||
|
|
b6c18bb439 | ||
|
|
ee51939f2d | ||
|
|
fc127ccd98 | ||
|
|
22faebac50 | ||
|
|
5ce3995f5b | ||
|
|
e181269703 | ||
|
|
5d212ec6b5 | ||
|
|
363096c4f0 | ||
|
|
85c5902559 | ||
|
|
fca6da2df7 | ||
|
|
7a74e5d29b | ||
|
|
4b94344dd9 | ||
|
|
5245254de5 | ||
|
|
afda76ff11 | ||
|
|
da52ddd35a | ||
|
|
bb2aef3878 | ||
|
|
4d6f76d9b3 | ||
|
|
869e083a2a | ||
|
|
ee876b5c84 | ||
|
|
a2b25449de | ||
|
|
16218252f1 | ||
|
|
757e446be7 | ||
|
|
db0f8f80bc | ||
|
|
49bc817f2d | ||
|
|
c31beeef96 | ||
|
|
09970d7935 | ||
|
|
d15b0baf74 | ||
|
|
b9802a130e | ||
|
|
7c0ea75e21 | ||
|
|
e1b02de7de | ||
|
|
6c4dbc5db0 | ||
|
|
d180626122 | ||
|
|
a9518b7388 | ||
|
|
51b18e9c52 | ||
|
|
3d98558bf6 | ||
|
|
e7e7f518bf | ||
|
|
cb06c8778a | ||
|
|
ab5c205a7f | ||
|
|
2f85902d2c | ||
|
|
b75e78bdda | ||
|
|
182a0251a4 | ||
|
|
89881c64a6 | ||
|
|
7321e2a159 | ||
|
|
9b45aa3bc9 | ||
|
|
dfadac08d3 | ||
|
|
2e87f0bd9f | ||
|
|
7f8086545c | ||
|
|
de6e3c2010 | ||
|
|
eb6d0125c6 | ||
|
|
e8f754c10b | ||
|
|
a72bbf2f58 | ||
|
|
c465f51e66 | ||
|
|
599e658742 | ||
|
|
929a669cad | ||
|
|
503b8b5176 | ||
|
|
d2aa727c12 | ||
|
|
d66dcecd2f | ||
|
|
80d0c44b40 | ||
|
|
0e5cd30d01 | ||
|
|
56b9fe3998 | ||
|
|
a22f1298eb | ||
|
|
b7bd6a2846 | ||
|
|
a52ab171de | ||
|
|
13a7508f26 | ||
|
|
a499aa4cc5 | ||
|
|
e7207878d4 | ||
|
|
47d64c1cf5 | ||
|
|
c75dc86263 | ||
|
|
105a8d1a3c | ||
|
|
c74cf52e38 | ||
|
|
5c9b448b14 | ||
|
|
4e95495dcc | ||
|
|
82f86f5fbe | ||
|
|
f737d6e158 | ||
|
|
f9feb94668 | ||
|
|
112afa7e51 | ||
|
|
1694ea38ab | ||
|
|
0f3b7caa0f | ||
|
|
a85e1e1e15 | ||
|
|
55d00027a9 | ||
|
|
bf012619a6 | ||
|
|
f47795bf40 | ||
|
|
848e7a2f85 | ||
|
|
ecc6138833 | ||
|
|
d6600eb876 | ||
|
|
bc237de755 | ||
|
|
89abb51734 | ||
|
|
be8570edac | ||
|
|
09cce6802d | ||
|
|
c164dd7dcf | ||
|
|
00435fb27d | ||
|
|
0ebaa0b991 | ||
|
|
0d7b529233 | ||
|
|
2c2f7e8e98 | ||
|
|
768b8a2417 | ||
|
|
192714629c | ||
|
|
f2f761e8db | ||
|
|
410aa14b28 | ||
|
|
e857293414 | ||
|
|
492f911930 | ||
|
|
0002d84a6c | ||
|
|
9b49b38496 | ||
|
|
7cf9294e09 | ||
|
|
129f73aa4f | ||
|
|
7bdb93784b | ||
|
|
b32c001fec | ||
|
|
5f134d7b99 | ||
|
|
81b0823632 | ||
|
|
95e3af7487 | ||
|
|
77047bab2a | ||
|
|
c1cf6d65de | ||
|
|
f626fc2f89 | ||
|
|
f56b1b4530 | ||
|
|
1c423c4b6c | ||
|
|
7715b143d2 | ||
|
|
b200417903 | ||
|
|
138d80d57d | ||
|
|
07a33447ad | ||
|
|
4d11d28f96 | ||
|
|
e61dc12eab | ||
|
|
2c268d906c | ||
|
|
0acaebe067 | ||
|
|
9ea109f705 | ||
|
|
be2964562f | ||
|
|
6d7bf1b878 | ||
|
|
3a7c963f3c | ||
|
|
3728928a6d | ||
|
|
596eeee59d | ||
|
|
ae2f8a384f | ||
|
|
ffe0989969 | ||
|
|
53eeb26e28 | ||
|
|
0325d2a7f6 | ||
|
|
b83d568dfb | ||
|
|
2f839b3362 | ||
|
|
bfc655d6ab | ||
|
|
bd63ea9484 | ||
|
|
6c825625fb | ||
|
|
be59c2da90 | ||
|
|
8a4e6730a8 | ||
|
|
6053475c6f | ||
|
|
ed1cfc28aa | ||
|
|
1637aea0fa | ||
|
|
3c5aa05b48 | ||
|
|
5a1dc5a992 | ||
|
|
ccbf8201a0 | ||
|
|
34be9f681a | ||
|
|
cfca229500 | ||
|
|
d2396a8162 | ||
|
|
b630514906 | ||
|
|
f8dbbf7757 | ||
|
|
8eb26db72d | ||
|
|
499cbeaa62 | ||
|
|
4b103161b9 | ||
|
|
fb8e4829c3 | ||
|
|
4a81220aa3 | ||
|
|
85a0d751c0 | ||
|
|
859195be94 | ||
|
|
8499002072 | ||
|
|
6e85a2e1d9 | ||
|
|
e4b4197a9a | ||
|
|
60f34bf2ae | ||
|
|
63afc7d7be | ||
|
|
0aec952fd2 | ||
|
|
f8b8374058 | ||
|
|
d62bb11a9e | ||
|
|
af39ecbc5f | ||
|
|
6c2330fc6c | ||
|
|
109da5fa38 | ||
|
|
81ac063cf8 | ||
|
|
50f2442512 | ||
|
|
be28f398d6 | ||
|
|
cd47862c1d | ||
|
|
847e3e05b0 | ||
|
|
e347b3fb23 | ||
|
|
a6a1d94d92 | ||
|
|
e54de547f6 | ||
|
|
574cfb91c2 | ||
|
|
5bdf0a4978 | ||
|
|
cd8dee1731 | ||
|
|
0e7d2c048d | ||
|
|
79c4a14f6f | ||
|
|
de8977347a | ||
|
|
783f78a9ef | ||
|
|
c1fa6efb30 | ||
|
|
b3cd370d8e | ||
|
|
8cb416d142 | ||
|
|
dbd4faf558 | ||
|
|
92c8f17e2a | ||
|
|
c858966035 | ||
|
|
547d55a31f | ||
|
|
f78420511e | ||
|
|
e3ad6e6e54 | ||
|
|
c6ffc51a5d | ||
|
|
9b28469afd | ||
|
|
2bb1388a45 | ||
|
|
bf0d1379f6 | ||
|
|
0d9bc5988d | ||
|
|
3a2075de9c | ||
|
|
ef6239a807 | ||
|
|
8cb9df6d89 | ||
|
|
fa856b54bf | ||
|
|
e9f6acff27 | ||
|
|
dc26da9cc1 | ||
|
|
9dd05761fd | ||
|
|
0d5b86df57 | ||
|
|
d8eb6e9da5 | ||
|
|
d280062fe3 | ||
|
|
f0366a98f4 | ||
|
|
9d0577fd93 | ||
|
|
dba70b557c | ||
|
|
f6840bc202 | ||
|
|
1af5f0d179 | ||
|
|
b3a91470f8 | ||
|
|
9b18588e26 | ||
|
|
3d2e7dc8a4 | ||
|
|
41f1552f9b | ||
|
|
ffa8a36ce6 | ||
|
|
3505e645e3 | ||
|
|
88db6084df | ||
|
|
27c8ab28f3 | ||
|
|
e01b595a0c | ||
|
|
0d351f5173 | ||
|
|
03dbfb5670 | ||
|
|
1e17fc9e32 | ||
|
|
da912f1e35 | ||
|
|
fb0f832832 | ||
|
|
870dd631bb | ||
|
|
5b9f251e93 | ||
|
|
98c14bbd3b | ||
|
|
00ce8b927f | ||
|
|
ab215cd909 | ||
|
|
51e7773207 | ||
|
|
6f5a1a8aa9 | ||
|
|
deb48fb9d2 | ||
|
|
20b451afb6 | ||
|
|
ad6f812974 | ||
|
|
9573516b37 | ||
|
|
f0d6a92ab2 | ||
|
|
dc4d4031e9 | ||
|
|
84a5c7aaf1 | ||
|
|
6ad666342d | ||
|
|
1252498872 | ||
|
|
f9d04e4dd4 | ||
|
|
c96b9bb77d | ||
|
|
f3c672c5ae | ||
|
|
63c396c03a | ||
|
|
0981857062 | ||
|
|
0527c73529 | ||
|
|
5c5dbc369b | ||
|
|
196719963f | ||
|
|
92ee4dfbb9 | ||
|
|
9d0b3524cb | ||
|
|
c39fd332b6 | ||
|
|
04d515f554 | ||
|
|
f425e9b94f | ||
|
|
7ec147fe6f | ||
|
|
7a8ae85b72 | ||
|
|
0f8f5a1e9a | ||
|
|
757a9ac033 | ||
|
|
9efc8d1290 | ||
|
|
5f53270148 | ||
|
|
9716495951 | ||
|
|
f9b29948e7 | ||
|
|
bededb3912 | ||
|
|
bb6390f9ae | ||
|
|
d2300d2dfd | ||
|
|
a501407907 | ||
|
|
88e63cd390 | ||
|
|
eaebe6980b | ||
|
|
d6efba63ca | ||
|
|
257e425fd9 | ||
|
|
02e3364874 | ||
|
|
9ae74708fb | ||
|
|
e946e377c6 | ||
|
|
5dbe649b2c | ||
|
|
2792902975 | ||
|
|
6f643070ea | ||
|
|
ce946f56b2 | ||
|
|
de1e67f68d | ||
|
|
79001bccd8 | ||
|
|
963884cc4c | ||
|
|
e43c1bcc9e | ||
|
|
32cc2c7835 | ||
|
|
5c39c279f3 | ||
|
|
12bca9b771 | ||
|
|
5e10c2bc18 | ||
|
|
f2fe22b8f7 | ||
|
|
d5601a21fd | ||
|
|
f3bd7a8589 | ||
|
|
7643bbd6ba | ||
|
|
fd1044b531 | ||
|
|
f25e5db422 | ||
|
|
0d569a060b | ||
|
|
350016cbe0 | ||
|
|
1619dade50 | ||
|
|
dafc34f535 | ||
|
|
9bbab2620c | ||
|
|
81bef65cc2 | ||
|
|
a18daecf8a | ||
|
|
449e4c0253 | ||
|
|
a66ba187ae | ||
|
|
2d710f7060 | ||
|
|
e109e5018e | ||
|
|
1444831833 | ||
|
|
4b4f29ae19 | ||
|
|
cbc4123e78 | ||
|
|
2c896f77d4 | ||
|
|
112ddf80e6 | ||
|
|
6f2a69acc1 | ||
|
|
d2c83b82f7 | ||
|
|
03501a8125 | ||
|
|
6e17214476 | ||
|
|
2c6cec7f8c | ||
|
|
f76797cade | ||
|
|
59325bd412 | ||
|
|
65a6bc5002 | ||
|
|
eff28d8b47 | ||
|
|
dfb13c4286 | ||
|
|
aada46f6eb | ||
|
|
9e967ba1ea |
20
.env
20
.env
@@ -1,12 +1,10 @@
|
|||||||
ACCESS_TOKEN_COOKIE_NAME=''
|
ACCESS_TOKEN_COOKIE_NAME=''
|
||||||
|
ACCOUNT_PROFILE_URL=''
|
||||||
BASE_URL=''
|
BASE_URL=''
|
||||||
COACHING_ENABLED=''
|
|
||||||
CREDENTIALS_BASE_URL=''
|
CREDENTIALS_BASE_URL=''
|
||||||
CSRF_TOKEN_API_PATH=''
|
CSRF_TOKEN_API_PATH=''
|
||||||
DEMOGRAPHICS_BASE_URL=''
|
|
||||||
DISCOVERY_API_BASE_URL=''
|
DISCOVERY_API_BASE_URL=''
|
||||||
ECOMMERCE_BASE_URL=''
|
ECOMMERCE_BASE_URL=''
|
||||||
ENABLE_DEMOGRAPHICS_COLLECTION=''
|
|
||||||
FAVICON_URL=''
|
FAVICON_URL=''
|
||||||
LANGUAGE_PREFERENCE_COOKIE_NAME=''
|
LANGUAGE_PREFERENCE_COOKIE_NAME=''
|
||||||
LMS_BASE_URL=''
|
LMS_BASE_URL=''
|
||||||
@@ -14,9 +12,11 @@ LOGIN_URL=''
|
|||||||
LOGO_TRADEMARK_URL=''
|
LOGO_TRADEMARK_URL=''
|
||||||
LOGO_URL=''
|
LOGO_URL=''
|
||||||
LOGO_WHITE_URL=''
|
LOGO_WHITE_URL=''
|
||||||
|
SHOW_PUSH_CHANNEL=''
|
||||||
|
SHOW_EMAIL_CHANNEL=''
|
||||||
LOGOUT_URL=''
|
LOGOUT_URL=''
|
||||||
MARKETING_SITE_BASE_URL=''
|
MARKETING_SITE_BASE_URL=''
|
||||||
NODE_ENV=''
|
NODE_ENV='production'
|
||||||
ORDER_HISTORY_URL=''
|
ORDER_HISTORY_URL=''
|
||||||
PUBLISHER_BASE_URL=''
|
PUBLISHER_BASE_URL=''
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||||
@@ -25,3 +25,15 @@ SITE_NAME=''
|
|||||||
STUDIO_BASE_URL=''
|
STUDIO_BASE_URL=''
|
||||||
SUPPORT_URL=''
|
SUPPORT_URL=''
|
||||||
USER_INFO_COOKIE_NAME=''
|
USER_INFO_COOKIE_NAME=''
|
||||||
|
ENABLE_COPPA_COMPLIANCE=''
|
||||||
|
ENABLE_ACCOUNT_DELETION=''
|
||||||
|
ENABLE_DOB_UPDATE=''
|
||||||
|
MARKETING_EMAILS_OPT_IN=''
|
||||||
|
APP_ID=
|
||||||
|
MFE_CONFIG_API_URL=
|
||||||
|
PASSWORD_RESET_SUPPORT_LINK=''
|
||||||
|
LEARNER_FEEDBACK_URL=''
|
||||||
|
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||||
|
# Fallback in local style files
|
||||||
|
PARAGON_THEME_URLS={}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
|
ACCOUNT_PROFILE_URL='http://localhost:1995'
|
||||||
BASE_URL='localhost:1997'
|
BASE_URL='localhost:1997'
|
||||||
COACHING_ENABLED=''
|
|
||||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||||
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
|
|
||||||
DISCOVERY_API_BASE_URL=''
|
DISCOVERY_API_BASE_URL=''
|
||||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||||
ENABLE_DEMOGRAPHICS_COLLECTION=''
|
|
||||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||||
LMS_BASE_URL='http://localhost:18000'
|
LMS_BASE_URL='http://localhost:18000'
|
||||||
@@ -26,3 +24,17 @@ SITE_NAME=localhost
|
|||||||
STUDIO_BASE_URL=''
|
STUDIO_BASE_URL=''
|
||||||
SUPPORT_URL='http://localhost:18000/support'
|
SUPPORT_URL='http://localhost:18000/support'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
|
ENABLE_COPPA_COMPLIANCE=''
|
||||||
|
ENABLE_ACCOUNT_DELETION=''
|
||||||
|
ENABLE_DOB_UPDATE=''
|
||||||
|
MARKETING_EMAILS_OPT_IN=''
|
||||||
|
SHOW_PUSH_CHANNEL='true'
|
||||||
|
SHOW_EMAIL_CHANNEL='true'
|
||||||
|
APP_ID=
|
||||||
|
MFE_CONFIG_API_URL=
|
||||||
|
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
|
||||||
|
LEARNER_FEEDBACK_URL=''
|
||||||
|
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||||
|
# Fallback in local style files
|
||||||
|
PARAGON_THEME_URLS={}
|
||||||
|
|||||||
15
.env.test
15
.env.test
@@ -1,12 +1,9 @@
|
|||||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||||
BASE_URL='localhost:1997'
|
BASE_URL='localhost:1997'
|
||||||
COACHING_ENABLED=''
|
|
||||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||||
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
|
|
||||||
DISCOVERY_API_BASE_URL=''
|
DISCOVERY_API_BASE_URL=''
|
||||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||||
ENABLE_DEMOGRAPHICS_COLLECTION=''
|
|
||||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||||
LMS_BASE_URL='http://localhost:18000'
|
LMS_BASE_URL='http://localhost:18000'
|
||||||
@@ -25,3 +22,15 @@ SITE_NAME=localhost
|
|||||||
STUDIO_BASE_URL=''
|
STUDIO_BASE_URL=''
|
||||||
SUPPORT_URL='http://localhost:18000/support'
|
SUPPORT_URL='http://localhost:18000/support'
|
||||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||||
|
ENABLE_COPPA_COMPLIANCE=''
|
||||||
|
ENABLE_ACCOUNT_DELETION=''
|
||||||
|
SHOW_PUSH_CHANNEL=''
|
||||||
|
SHOW_EMAIL_CHANNEL=''
|
||||||
|
ENABLE_DOB_UPDATE=''
|
||||||
|
MARKETING_EMAILS_OPT_IN=''
|
||||||
|
APP_ID=
|
||||||
|
MFE_CONFIG_API_URL=
|
||||||
|
LEARNER_FEEDBACK_URL=''
|
||||||
|
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
|
||||||
|
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
|
||||||
|
PARAGON_THEME_URLS={}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ dist/
|
|||||||
node_modules/
|
node_modules/
|
||||||
__mocks__/
|
__mocks__/
|
||||||
__snapshots__/
|
__snapshots__/
|
||||||
|
src/i18n/messages/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
const { createConfig } = require('@edx/frontend-build');
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
const { createConfig } = require('@openedx/frontend-build');
|
||||||
|
|
||||||
module.exports = createConfig('eslint');
|
module.exports = createConfig('eslint');
|
||||||
|
|||||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1 +0,0 @@
|
|||||||
* @edx/community-engineering
|
|
||||||
24
.github/pull_request_template.md
vendored
Normal file
24
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
### Description
|
||||||
|
|
||||||
|
Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues.
|
||||||
|
|
||||||
|
#### How Has This Been Tested?
|
||||||
|
|
||||||
|
Please describe in detail how you tested your changes.
|
||||||
|
|
||||||
|
#### Screenshots/sandbox (optional):
|
||||||
|
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.**
|
||||||
|
|
||||||
|
|Before|After|
|
||||||
|
|-------|-----|
|
||||||
|
| | |
|
||||||
|
|
||||||
|
#### Merge Checklist
|
||||||
|
|
||||||
|
* [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable.
|
||||||
|
* [ ] Is there adequate test coverage for your changes?
|
||||||
|
|
||||||
|
#### Post-merge Checklist
|
||||||
|
|
||||||
|
* [ ] Deploy the changes to prod after verifying on stage or ask **@jacobo-dominguez-wgu** to do it.
|
||||||
|
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
|
||||||
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Run the workflow that adds new tickets that are either:
|
||||||
|
# - labelled "DEPR"
|
||||||
|
# - title starts with "[DEPR]"
|
||||||
|
# - body starts with "Proposal Date" (this is the first template field)
|
||||||
|
# to the org-wide DEPR project board
|
||||||
|
|
||||||
|
name: Add newly created DEPR issues to the DEPR project board
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
routeissue:
|
||||||
|
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
|
||||||
|
secrets:
|
||||||
|
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||||
|
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||||
|
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# This workflow runs when a comment is made on the ticket
|
||||||
|
# If the comment starts with "label: " it tries to apply
|
||||||
|
# the label indicated in rest of comment.
|
||||||
|
# If the comment starts with "remove label: ", it tries
|
||||||
|
# to remove the indicated label.
|
||||||
|
# Note: Labels are allowed to have spaces and this script does
|
||||||
|
# not parse spaces (as often a space is legitimate), so the command
|
||||||
|
# "label: really long lots of words label" will apply the
|
||||||
|
# label "really long lots of words label"
|
||||||
|
|
||||||
|
name: Allows for the adding and removing of labels via comment
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
add_remove_labels:
|
||||||
|
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||||
|
|
||||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -1,4 +1,3 @@
|
|||||||
---
|
|
||||||
name: ci
|
name: ci
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,27 +5,25 @@ on:
|
|||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version:
|
|
||||||
- 12
|
|
||||||
npm-test:
|
npm-test:
|
||||||
- i18n_extract
|
- i18n_extract
|
||||||
- is-es5
|
|
||||||
- lint
|
- lint
|
||||||
- test
|
- test
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version-file: '.nvmrc'
|
||||||
- run: npm install -g npm@6
|
|
||||||
- run: make requirements
|
- run: make requirements
|
||||||
- run: make test NPM_TESTS=build
|
- run: make test NPM_TESTS=build
|
||||||
- run: make test NPM_TESTS=${{ matrix.npm-test }}
|
- run: make test NPM_TESTS=${{ matrix.npm-test }}
|
||||||
- name: upload coverage
|
- name: Coverage
|
||||||
uses: codecov/codecov-action@v2
|
if: matrix.npm-test == 'test'
|
||||||
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: false
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
fail_ci_if_error: true
|
||||||
|
|||||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,4 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
commitlint:
|
commitlint:
|
||||||
uses: edx/.github/.github/workflows/commitlint.yml@master
|
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||||
|
|||||||
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#check package-lock file version
|
||||||
|
|
||||||
|
name: Lockfile Version check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version-check:
|
||||||
|
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# This workflow runs when a comment is made on the ticket
|
||||||
|
# If the comment starts with "assign me" it assigns the author to the
|
||||||
|
# ticket (case insensitive)
|
||||||
|
|
||||||
|
name: Assign comment author to ticket if they say "assign me"
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
self_assign_by_comment:
|
||||||
|
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||||
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
12
.github/workflows/update-browserslist-db.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: Update Browserslist DB
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-browserslist:
|
||||||
|
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ temp/babel-plugin-react-intl
|
|||||||
/temp
|
/temp
|
||||||
/.vscode
|
/.vscode
|
||||||
/module.config.js
|
/module.config.js
|
||||||
|
src/i18n/messages/
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[main]
|
|
||||||
host = https://www.transifex.com
|
|
||||||
|
|
||||||
[edx-platform.frontend-app-account]
|
|
||||||
file_filter = src/i18n/messages/<lang>.json
|
|
||||||
source_file = src/i18n/transifex_input.json
|
|
||||||
source_lang = en
|
|
||||||
type = KEYVALUEJSON
|
|
||||||
39
Makefile
Executable file → Normal file
39
Makefile
Executable file → Normal file
@@ -1,16 +1,12 @@
|
|||||||
transifex_resource = frontend-app-account
|
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
|
||||||
|
|
||||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||||
i18n = ./src/i18n
|
i18n = ./src/i18n
|
||||||
transifex_input = $(i18n)/transifex_input.json
|
transifex_input = $(i18n)/transifex_input.json
|
||||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
|
||||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
|
||||||
|
|
||||||
# This directory must match .babelrc .
|
# This directory must match .babelrc .
|
||||||
transifex_temp = ./temp/babel-plugin-react-intl
|
transifex_temp = ./temp/babel-plugin-formatjs
|
||||||
|
|
||||||
NPM_TESTS=build i18n_extract lint test is-es5
|
NPM_TESTS=build i18n_extract lint test
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
||||||
@@ -21,6 +17,11 @@ test.npm.%: validate-no-uncommitted-package-lock-changes
|
|||||||
npm run $(*)
|
npm run $(*)
|
||||||
|
|
||||||
.PHONY: requirements
|
.PHONY: requirements
|
||||||
|
|
||||||
|
precommit:
|
||||||
|
npm run lint
|
||||||
|
npm audit
|
||||||
|
|
||||||
requirements: ## install ci requirements
|
requirements: ## install ci requirements
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
@@ -40,20 +41,18 @@ detect_changed_source_translations:
|
|||||||
# Checking for changed translations...
|
# Checking for changed translations...
|
||||||
git diff --exit-code $(i18n)
|
git diff --exit-code $(i18n)
|
||||||
|
|
||||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
|
||||||
push_translations:
|
|
||||||
# Pushing strings to Transifex...
|
|
||||||
tx push -s
|
|
||||||
# Fetching hashes from Transifex...
|
|
||||||
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
|
||||||
# Writing out comments to file...
|
|
||||||
$(transifex_utils) $(transifex_temp) --comments
|
|
||||||
# Pushing comments to Transifex...
|
|
||||||
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
|
||||||
|
|
||||||
# Pulls translations from Transifex.
|
|
||||||
pull_translations:
|
pull_translations:
|
||||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
rm -rf src/i18n/messages
|
||||||
|
mkdir src/i18n/messages
|
||||||
|
cd src/i18n/messages \
|
||||||
|
&& atlas pull $(ATLAS_OPTIONS) \
|
||||||
|
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-component-header/src/i18n/messages:frontend-component-header \
|
||||||
|
translations/frontend-app-account/src/i18n/messages:frontend-app-account
|
||||||
|
|
||||||
|
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-account
|
||||||
|
|
||||||
# This target is used by Travis.
|
# This target is used by Travis.
|
||||||
validate-no-uncommitted-package-lock-changes:
|
validate-no-uncommitted-package-lock-changes:
|
||||||
|
|||||||
212
README.rst
212
README.rst
@@ -1,51 +1,48 @@
|
|||||||
|
####################
|
||||||
|
frontend-app-account
|
||||||
|
####################
|
||||||
|
|
||||||
|ci-badge| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
|
|ci-badge| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
|
||||||
|
|
||||||
frontend-app-account
|
|
||||||
====================
|
|
||||||
|
|
||||||
Please tag **@edx/community-engineering** on any PRs or issues. Thanks!
|
********
|
||||||
|
Purpose
|
||||||
Introduction
|
********
|
||||||
------------
|
|
||||||
|
|
||||||
This is a micro-frontend application responsible for the display and updating of a user's account information.
|
This is a micro-frontend application responsible for the display and updating of a user's account information.
|
||||||
|
|
||||||
What is the domain of this MFE?
|
What is the domain of this MFE?
|
||||||
|
|
||||||
In this MFE: Private user settings UIs. Public facing profile is in a `separate MFE (Profile) <https://github.com/edx/frontend-app-profile>`_
|
In this MFE: Private user settings UIs. Public facing profile is in a `separate MFE (Profile) <https://github.com/openedx/frontend-app-profile>`_
|
||||||
|
|
||||||
- Account settings page
|
- Account settings page
|
||||||
|
|
||||||
- Demographics collection
|
|
||||||
|
|
||||||
- IDV (Identity Verification)
|
- IDV (Identity Verification)
|
||||||
|
|
||||||
Installation
|
***************
|
||||||
------------
|
Getting Started
|
||||||
|
***************
|
||||||
|
|
||||||
This MFE is bundled with `Devstack <https://github.com/edx/devstack>`_, see the `Getting Started <https://github.com/edx/devstack#getting-started>`_ section for setup instructions.
|
Prerequisites
|
||||||
|
=============
|
||||||
|
|
||||||
1. Install Devstack using the `Getting Started <https://github.com/edx/devstack#getting-started>`_ instructions.
|
`Tutor`_ is currently recommended as a development environment for your
|
||||||
|
new MFE. Please refer
|
||||||
|
to the `relevant tutor-mfe documentation`_ to get started using it.
|
||||||
|
|
||||||
2. Start up Devstack, if it's not already started.
|
.. _Tutor: https://github.com/overhangio/tutor
|
||||||
|
|
||||||
3. Log in to Devstack (http://localhost:18000/login )
|
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
|
||||||
|
|
||||||
4. Within this project, install requirements and start the development server:
|
Plugins
|
||||||
|
=======
|
||||||
|
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||||
|
|
||||||
.. code-block::
|
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||||
|
|
||||||
npm install
|
|
||||||
npm start # The server will run on port 1997
|
|
||||||
|
|
||||||
5. Once the dev server is up, visit http://localhost:1997 to access the MFE
|
|
||||||
|
|
||||||
.. image:: ./docs/images/localhost_preview.png
|
|
||||||
|
|
||||||
Environment Variables/Setup Notes
|
Environment Variables/Setup Notes
|
||||||
---------------------------------
|
=================================
|
||||||
|
|
||||||
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
|
This MFE is configured via the ``frontend-platform`` configuration module. For more information on MFE configuration see the `Configuration documentation`_.
|
||||||
|
|
||||||
The account settings micro-frontend also supports the following additional variable:
|
The account settings micro-frontend also supports the following additional variable:
|
||||||
|
|
||||||
@@ -55,28 +52,22 @@ Example: ``https://support.example.com``
|
|||||||
|
|
||||||
The fully-qualified URL to the support page in the target environment.
|
The fully-qualified URL to the support page in the target environment.
|
||||||
|
|
||||||
edX-specific Environment Variables
|
``PASSWORD_RESET_SUPPORT_LINK``
|
||||||
**********************************
|
|
||||||
|
|
||||||
Furthermore, there are several edX-specific environment variables that enable integrations with closed-source services private to the edX organization, and are unsupported in Open edX. Enabling these environment variables will result in undefined behavior in Open edX installations:
|
Examples:
|
||||||
|
|
||||||
``COACHING_ENABLED``
|
- ``https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-``
|
||||||
|
|
||||||
Example: ``true`` | ``''`` (empty strings are falsy)
|
- ``mailto:support@example.com``
|
||||||
|
|
||||||
Enables support for a section of the micro-frontend that helps users arrange for coaching sessions. Integrates with a private coaching plugin and is only used by edx.org.
|
The fully-qualified URL to the support page or email to request the support from in the target environment.
|
||||||
|
|
||||||
``ENABLE_DEMOGRAPHICS_COLLECTION``
|
``ENABLE_ACCOUNT_DELETION``
|
||||||
|
|
||||||
Example: ``true`` | ``''`` (empty strings are falsy)
|
Example: ``'false'`` | ``''`` (empty strings are true)
|
||||||
|
|
||||||
Enables support for a section of the account settings page where a user can enter demographics information. Integrates with a private demographics service and is only used by edx.org.
|
Enable the account deletion option, defaults to true.
|
||||||
|
To disable account deletion set ``ENABLE_ACCOUNT_DELETION`` to ``'false'`` (string), otherwise it will default to true.
|
||||||
``DEMOGRAPHICS_BASE_URL``
|
|
||||||
|
|
||||||
Example: ``https://demographics.example.com``
|
|
||||||
|
|
||||||
Required only if ``ENABLE_DEMOGRAPHICS_COLLECTION`` is true. The fully-qualified URL to the private demographics service in the target environment.
|
|
||||||
|
|
||||||
Example build syntax with a single environment variable:
|
Example build syntax with a single environment variable:
|
||||||
|
|
||||||
@@ -84,29 +75,150 @@ Example build syntax with a single environment variable:
|
|||||||
|
|
||||||
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
|
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
|
||||||
|
|
||||||
For more information see the document: `Micro-frontend applications in Open
|
For more information see the document: `Configuration documentation`_
|
||||||
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
|
|
||||||
|
.. _Configuration documentation: https://openedx.github.io/frontend-platform/module-Config.html
|
||||||
|
|
||||||
|
Cloning and Startup
|
||||||
|
===================
|
||||||
|
|
||||||
|
.. code-block::
|
||||||
|
|
||||||
|
|
||||||
|
1. Clone your new repo:
|
||||||
|
|
||||||
|
``git clone https://github.com/openedx/frontend-app-account.git``
|
||||||
|
|
||||||
|
2. Use the version of Node specified in the ``.nvmrc`` file.
|
||||||
|
|
||||||
|
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
|
||||||
|
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>`_.
|
||||||
|
|
||||||
|
3. Install npm dependencies:
|
||||||
|
|
||||||
|
``cd frontend-app-account && npm ci``
|
||||||
|
|
||||||
|
4. Start the dev server:
|
||||||
|
|
||||||
|
``npm start``
|
||||||
|
|
||||||
|
Or for local development with custom configuration:
|
||||||
|
|
||||||
|
``npm run dev``
|
||||||
|
|
||||||
|
This runs the dev server with PUBLIC_PATH=/account/, MFE_CONFIG_API_URL pointing to localhost:8000, and hosts on apps.local.openedx.io.
|
||||||
|
|
||||||
|
Local module development
|
||||||
|
=========================
|
||||||
|
|
||||||
|
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
|
||||||
|
file (which is git-ignored) that defines where to find your local modules, for instance:
|
||||||
|
|
||||||
|
.. code-block:: js
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/*
|
||||||
|
Modules you want to use from local source code. Adding a module here means that when this app
|
||||||
|
runs its build, it'll resolve the source from peer directories of this app.
|
||||||
|
|
||||||
|
moduleName: the name you use to import code from the module.
|
||||||
|
dir: The relative path to the module's source code.
|
||||||
|
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
|
||||||
|
may want to use "src" if the module installs React as a peer/dev dependency.
|
||||||
|
*/
|
||||||
|
localModules: [
|
||||||
|
{ moduleName: '@openedx/paragon/scss', dir: '../paragon', dist: 'scss' },
|
||||||
|
{ moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' },
|
||||||
|
{ moduleName: '@openedx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
|
||||||
|
{ moduleName: '@openedx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||||
|
|
||||||
Known Issues
|
Known Issues
|
||||||
------------
|
===========
|
||||||
|
|
||||||
None
|
None
|
||||||
|
|
||||||
Development Roadmap
|
Development Roadmap
|
||||||
-------------------
|
===================
|
||||||
|
|
||||||
We don't have anything planned for the core of the MFE (the account settings page) - this MFE is currently in maintenance mode.
|
We don't have anything planned for the core of the MFE (the account settings page) - this MFE is currently in maintenance mode.
|
||||||
There may be a replacement for IDV coming down the pipe, so that may be DEPRed.
|
There may be a replacement for IDV coming down the pipe, so that may be DEPRed.
|
||||||
In the future, it's possible that demographics could be modeled as a plugin rather than being hard-coded into this MFE.
|
|
||||||
|
|
||||||
|
License
|
||||||
|
=======
|
||||||
|
|
||||||
|
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||||
|
noted.
|
||||||
|
|
||||||
|
Please see `LICENSE <LICENSE>`_ for details.
|
||||||
|
|
||||||
|
Contributing
|
||||||
|
============
|
||||||
|
|
||||||
|
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||||
|
|
||||||
|
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||||
|
|
||||||
|
This project is currently accepting all types of contributions, bug fixes,
|
||||||
|
security fixes, maintenance work, or new features. However, please make sure
|
||||||
|
to have a discussion about your new feature idea with the maintainers prior to
|
||||||
|
beginning development to maximize the chances of your change being accepted.
|
||||||
|
You can start a conversation by creating a new issue on this repo summarizing
|
||||||
|
your idea.
|
||||||
|
|
||||||
|
|
||||||
|
Getting Help
|
||||||
|
===========
|
||||||
|
|
||||||
|
If you're having trouble, we have discussion forums at
|
||||||
|
https://discuss.openedx.org where you can connect with others in the community.
|
||||||
|
|
||||||
|
Our real-time conversations are on Slack. You can request a `Slack
|
||||||
|
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||||
|
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||||
|
channel`_.
|
||||||
|
|
||||||
|
For anything non-trivial, the best path is to open an issue in this repository
|
||||||
|
with as many details about the issue you are facing as you can provide.
|
||||||
|
|
||||||
|
https://github.com/openedx/frontend-app-account/issues
|
||||||
|
|
||||||
|
For more information about these options, see the `Getting Help`_ page.
|
||||||
|
|
||||||
|
.. _Slack invitation: https://openedx.org/slack
|
||||||
|
.. _community Slack workspace: https://openedx.slack.com/
|
||||||
|
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||||
|
.. _Getting Help: https://openedx.org/community/connect
|
||||||
|
|
||||||
|
|
||||||
|
The Open edX Code of Conduct
|
||||||
|
============================
|
||||||
|
|
||||||
|
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||||
|
|
||||||
|
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||||
|
|
||||||
|
People
|
||||||
|
======
|
||||||
|
The assigned maintainers for this component and other project details may be found in Backstage or from inspecting catalog-info.yaml.
|
||||||
|
|
||||||
|
Reporting Security Issues
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Please do not report security issues in public. Please email security@openedx.org.
|
||||||
|
|
||||||
==============================
|
==============================
|
||||||
|
|
||||||
.. |ci-badge| image:: https://github.com/edx/edx-developer-docs/actions/workflows/ci.yml/badge.svg
|
.. |ci-badge| image:: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml/badge.svg
|
||||||
:target: https://github.com/edx/edx-developer-docs/actions/workflows/ci.yml
|
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
|
||||||
:alt: Continuous Integration
|
:alt: Continuous Integration
|
||||||
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
|
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
|
||||||
:target: https://codecov.io/gh/edx/frontend-app-account
|
:target: https://codecov.io/gh/openedx/frontend-app-account/
|
||||||
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-account.svg
|
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-account.svg
|
||||||
:target: @edx/frontend-app-account
|
:target: @edx/frontend-app-account
|
||||||
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-account.svg
|
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-account.svg
|
||||||
|
|||||||
19
catalog-info.yaml
Normal file
19
catalog-info.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# This file records information about this repo. Its use is described in OEP-55:
|
||||||
|
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||||
|
|
||||||
|
apiVersion: backstage.io/v1alpha1
|
||||||
|
kind: Component
|
||||||
|
metadata:
|
||||||
|
name: 'frontend-app-account'
|
||||||
|
description: "Open edX micro-frontend application for managing user account information."
|
||||||
|
links:
|
||||||
|
- url: "https://github.com/openedx/frontend-app-account"
|
||||||
|
title: "Frontend app account"
|
||||||
|
icon: "Web"
|
||||||
|
annotations:
|
||||||
|
openedx.org/arch-interest-groups: ""
|
||||||
|
openedx.org/release: "master"
|
||||||
|
spec:
|
||||||
|
owner: jacobo-dominguez-wgu
|
||||||
|
type: 'website'
|
||||||
|
lifecycle: 'production'
|
||||||
15
codecov.yml
Normal file
15
codecov.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
enabled: yes
|
||||||
|
target: auto
|
||||||
|
threshold: 0%
|
||||||
|
patch:
|
||||||
|
default:
|
||||||
|
enabled: yes
|
||||||
|
target: auto
|
||||||
|
threshold: 0%
|
||||||
|
ignore:
|
||||||
|
- "src/i18n"
|
||||||
|
- "src/index.jsx"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { createConfig } = require('@edx/frontend-build');
|
const { createConfig } = require('@openedx/frontend-build');
|
||||||
|
|
||||||
module.exports = createConfig('jest', {
|
module.exports = createConfig('jest', {
|
||||||
setupFilesAfterEnv: [
|
setupFilesAfterEnv: [
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# This file describes this Open edX repo, as described in OEP-2:
|
|
||||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
|
|
||||||
|
|
||||||
nick: acct
|
|
||||||
oeps: {}
|
|
||||||
openedx-release: {ref: master}
|
|
||||||
40393
package-lock.json
generated
40393
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
107
package.json
107
package.json
@@ -6,50 +6,52 @@
|
|||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/edx/frontend-app-account.git"
|
"url": "git+https://github.com/openedx/frontend-app-account.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "fedx-scripts webpack",
|
"build": "fedx-scripts webpack",
|
||||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
"dev": "PUBLIC_PATH=/account/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||||
"is-es5": "es-check es5 ./dist/*.js",
|
"i18n_extract": "fedx-scripts formatjs extract",
|
||||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||||
|
"lint:fix": "npm run lint -- --fix",
|
||||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||||
"start": "fedx-scripts webpack-dev-server --progress",
|
"start": "fedx-scripts webpack-dev-server --progress",
|
||||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/edx/frontend-app-account/issues"
|
"url": "https://github.com/openedx/frontend-app-account/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/edx/frontend-app-account#readme",
|
"homepage": "https://github.com/openedx/frontend-app-account#readme",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 2 versions",
|
"extends @edx/browserslist-config"
|
||||||
"ie 11"
|
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||||
"@edx/frontend-component-footer": "10.1.6",
|
"@edx/frontend-component-footer": "^14.6.0",
|
||||||
"@edx/frontend-component-header": "^2.4.3",
|
"@edx/frontend-component-header": "^6.2.0",
|
||||||
"@edx/frontend-platform": "1.12.7",
|
"@edx/frontend-platform": "^8.4.0",
|
||||||
"@edx/paragon": "16.1.0",
|
"@edx/openedx-atlas": "^0.7.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/react-fontawesome": "0.1.15",
|
"@fortawesome/react-fontawesome": "0.2.6",
|
||||||
"@tensorflow-models/blazeface": "0.0.7",
|
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||||
"@tensorflow/tfjs-converter": "1.7.4",
|
"@openedx/paragon": "^23.4.5",
|
||||||
"@tensorflow/tfjs-core": "1.7.4",
|
"@tensorflow-models/blazeface": "0.1.0",
|
||||||
"bowser": "2.11.0",
|
"@tensorflow/tfjs-converter": "4.22.0",
|
||||||
"classnames": "2.3.1",
|
"@tensorflow/tfjs-core": "4.22.0",
|
||||||
"core-js": "3.18.2",
|
"bowser": "2.14.1",
|
||||||
|
"classnames": "2.5.1",
|
||||||
|
"core-js": "3.48.0",
|
||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"form-urlencoded": "4.0.1",
|
"form-urlencoded": "6.1.6",
|
||||||
"formdata-polyfill": "4.0.10",
|
"formdata-polyfill": "4.0.10",
|
||||||
"history": "4.10.1",
|
"jslib-html5-camera-photo": "3.3.4",
|
||||||
"jslib-html5-camera-photo": "3.1.8",
|
"lodash.camelcase": "4.3.0",
|
||||||
"lodash.debounce": "4.0.8",
|
"lodash.debounce": "4.0.8",
|
||||||
"lodash.findindex": "4.6.0",
|
"lodash.findindex": "4.6.0",
|
||||||
"lodash.get": "4.4.2",
|
"lodash.get": "4.4.2",
|
||||||
@@ -58,36 +60,35 @@
|
|||||||
"lodash.omit": "4.5.0",
|
"lodash.omit": "4.5.0",
|
||||||
"lodash.pick": "4.4.0",
|
"lodash.pick": "4.4.0",
|
||||||
"lodash.pickby": "4.6.0",
|
"lodash.pickby": "4.6.0",
|
||||||
"memoize-one": "5.2.1",
|
"lodash.snakecase": "4.1.1",
|
||||||
"prop-types": "15.7.2",
|
"long": "5.3.2",
|
||||||
"qs": "6.10.1",
|
"memoize-one": "^6.0.0",
|
||||||
"react": "16.14.0",
|
"prop-types": "15.8.1",
|
||||||
"react-dom": "16.14.0",
|
"qs": "6.15.0",
|
||||||
"react-redux": "7.2.5",
|
"react": "18.3.1",
|
||||||
"react-router": "5.2.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "5.3.0",
|
"react-helmet": "6.1.0",
|
||||||
"react-router-hash-link": "1.2.2",
|
"react-redux": "7.2.9",
|
||||||
|
"react-router": "^6.25.1",
|
||||||
|
"react-router-dom": "^6.25.1",
|
||||||
|
"react-router-hash-link": "2.4.3",
|
||||||
"react-scrollspy": "3.4.3",
|
"react-scrollspy": "3.4.3",
|
||||||
"react-transition-group": "4.4.2",
|
"react-transition-group": "4.4.5",
|
||||||
"redux": "4.1.1",
|
"redux": "4.2.1",
|
||||||
"redux-devtools-extension": "2.13.9",
|
"redux-devtools-extension": "2.13.9",
|
||||||
"redux-logger": "3.0.6",
|
"redux-logger": "3.0.6",
|
||||||
"redux-saga": "1.1.3",
|
"redux-saga": "1.4.2",
|
||||||
"redux-thunk": "2.3.0",
|
"redux-thunk": "2.4.2",
|
||||||
"regenerator-runtime": "0.13.9",
|
"regenerator-runtime": "0.14.1",
|
||||||
"reselect": "4.0.0",
|
"reselect": "^5.1.1",
|
||||||
"universal-cookie": "4.0.4"
|
"universal-cookie": "7.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/frontend-build": "^9.0.5",
|
"@edx/browserslist-config": "1.5.1",
|
||||||
"@testing-library/jest-dom": "5.14.1",
|
"@openedx/frontend-build": "^14.6.2",
|
||||||
"@testing-library/react": "12.1.2",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"codecov": "3.8.3",
|
"@testing-library/react": "14.3.1",
|
||||||
"enzyme": "3.11.0",
|
"react-test-renderer": "^18.3.1",
|
||||||
"enzyme-adapter-react-16": "1.15.6",
|
"redux-mock-store": "1.5.5"
|
||||||
"es-check": "6.0.0",
|
|
||||||
"react-test-renderer": "16.14.0",
|
|
||||||
"reactifex": "1.1.1",
|
|
||||||
"redux-mock-store": "1.5.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,148 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en-us">
|
<html lang="en-us">
|
||||||
<head>
|
<head>
|
||||||
<title>Account | <%= process.env.SITE_NAME %></title>
|
<title>Account | <%= process.env.SITE_NAME %></title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
<link
|
||||||
<% if (process.env.OPTIMIZELY_PROJECT_ID) { %>
|
rel="shortcut icon"
|
||||||
<script
|
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
|
||||||
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"
|
type="image/x-icon"
|
||||||
></script>
|
/>
|
||||||
<% } %>
|
<% if (process.env.OPTIMIZELY_PROJECT_ID) { %>
|
||||||
|
<script src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"></script>
|
||||||
|
<% } %>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- begin usabilla live embed code -->
|
||||||
|
<script defer type="text/javascript">
|
||||||
|
window.lightningjs ||
|
||||||
|
(function (n) {
|
||||||
|
var e = "lightningjs";
|
||||||
|
function t(e, t) {
|
||||||
|
var r, i, a, o, d, c;
|
||||||
|
return (
|
||||||
|
t && (t += (/\?/.test(t) ? "&" : "?") + "lv=1"),
|
||||||
|
n[e] ||
|
||||||
|
((r = window),
|
||||||
|
(i = document),
|
||||||
|
(a = e),
|
||||||
|
(o = i.location.protocol),
|
||||||
|
(d = "load"),
|
||||||
|
(c = 0),
|
||||||
|
(function () {
|
||||||
|
n[a] = function () {
|
||||||
|
var t = arguments,
|
||||||
|
i = this,
|
||||||
|
o = ++c,
|
||||||
|
d = (i && i != r && i.id) || 0;
|
||||||
|
function s() {
|
||||||
|
return (s.id = o), n[a].apply(s, arguments);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(e.s = e.s || []).push([o, d, t]),
|
||||||
|
(s.then = function (n, t, r) {
|
||||||
|
var i = (e.fh[o] = e.fh[o] || []),
|
||||||
|
a = (e.eh[o] = e.eh[o] || []),
|
||||||
|
d = (e.ph[o] = e.ph[o] || []);
|
||||||
|
return (
|
||||||
|
n && i.push(n), t && a.push(t), r && d.push(r), s
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
s
|
||||||
|
);
|
||||||
|
};
|
||||||
|
var e = (n[a]._ = {});
|
||||||
|
function s() {
|
||||||
|
e.P(d), (e.w = 1), n[a]("_load");
|
||||||
|
}
|
||||||
|
(e.fh = {}),
|
||||||
|
(e.eh = {}),
|
||||||
|
(e.ph = {}),
|
||||||
|
(e.l = t
|
||||||
|
? t.replace(/^\/\//, ("https:" == o ? o : "http:") + "//")
|
||||||
|
: t),
|
||||||
|
(e.p = { 0: +new Date() }),
|
||||||
|
(e.P = function (n) {
|
||||||
|
e.p[n] = new Date() - e.p[0];
|
||||||
|
}),
|
||||||
|
e.w && s(),
|
||||||
|
r.addEventListener
|
||||||
|
? r.addEventListener(d, s, !1)
|
||||||
|
: r.attachEvent("onload", s);
|
||||||
|
var l = function () {
|
||||||
|
function n() {
|
||||||
|
return [
|
||||||
|
"<!DOCTYPE ",
|
||||||
|
o,
|
||||||
|
"><",
|
||||||
|
o,
|
||||||
|
"><head></head><",
|
||||||
|
t,
|
||||||
|
"><",
|
||||||
|
r,
|
||||||
|
' src="',
|
||||||
|
e.l,
|
||||||
|
'"></',
|
||||||
|
r,
|
||||||
|
"></",
|
||||||
|
t,
|
||||||
|
"></",
|
||||||
|
o,
|
||||||
|
">",
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
var t = "body",
|
||||||
|
r = "script",
|
||||||
|
o = "html",
|
||||||
|
d = i[t];
|
||||||
|
if (!d) return setTimeout(l, 100);
|
||||||
|
e.P(1);
|
||||||
|
var c,
|
||||||
|
s = i.createElement("div"),
|
||||||
|
h = s.appendChild(i.createElement("div")),
|
||||||
|
u = i.createElement("iframe");
|
||||||
|
(s.style.display = "none"),
|
||||||
|
(d.insertBefore(s, d.firstChild).id = "lightningjs-" + a),
|
||||||
|
(u.frameBorder = "0"),
|
||||||
|
(u.id = "lightningjs-frame-" + a),
|
||||||
|
/MSIE[ ]+6/.test(navigator.userAgent) &&
|
||||||
|
(u.src = "javascript:false"),
|
||||||
|
(u.allowTransparency = "true"),
|
||||||
|
h.appendChild(u);
|
||||||
|
try {
|
||||||
|
u.contentWindow.document.open();
|
||||||
|
} catch (n) {
|
||||||
|
(e.domain = i.domain),
|
||||||
|
(c =
|
||||||
|
"javascript:var d=document.open();d.domain='" +
|
||||||
|
i.domain +
|
||||||
|
"';"),
|
||||||
|
(u.src = c + "void(0);");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var p = u.contentWindow.document;
|
||||||
|
p.write(n()), p.close();
|
||||||
|
} catch (e) {
|
||||||
|
u.src =
|
||||||
|
c +
|
||||||
|
'd.write("' +
|
||||||
|
n().replace(/"/g, String.fromCharCode(92) + '"') +
|
||||||
|
'");d.close();';
|
||||||
|
}
|
||||||
|
e.P(2);
|
||||||
|
};
|
||||||
|
e.l && l();
|
||||||
|
})()),
|
||||||
|
(n[e].lv = "1"),
|
||||||
|
n[e]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var r = (window.lightningjs = t(e));
|
||||||
|
(r.require = t), (r.modules = n);
|
||||||
|
})({});
|
||||||
|
</script>
|
||||||
|
<!-- end usabilla live embed code -->
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
"pin"
|
"pin"
|
||||||
],
|
],
|
||||||
"automerge": true
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"automerge": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"timezone": "America/New_York"
|
"timezone": "America/New_York"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,11 +46,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Profile Information',
|
defaultMessage: 'Profile Information',
|
||||||
description: 'The profile information section heading.',
|
description: 'The profile information section heading.',
|
||||||
},
|
},
|
||||||
'account.settings.section.demographics.information': {
|
|
||||||
id: 'account.settings.section.demographics.information',
|
|
||||||
defaultMessage: 'Optional Information',
|
|
||||||
description: 'The optional information section heading.',
|
|
||||||
},
|
|
||||||
'account.settings.section.site.preferences': {
|
'account.settings.section.site.preferences': {
|
||||||
id: 'account.settings.section.site.preferences',
|
id: 'account.settings.section.site.preferences',
|
||||||
defaultMessage: 'Site Preferences',
|
defaultMessage: 'Site Preferences',
|
||||||
@@ -91,13 +86,13 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'The name that is used for ID verification and that appears on your certificates.',
|
defaultMessage: 'The name that is used for ID verification and that appears on your certificates.',
|
||||||
description: 'Help text for the account settings name field.',
|
description: 'Help text for the account settings name field.',
|
||||||
},
|
},
|
||||||
'account.settings.field.full.name.help.text.non.certificate': {
|
'account.settings.field.full.name.help.text.default': {
|
||||||
id: 'account.settings.field.full.name.help.text.non.certificate',
|
id: 'account.settings.field.full.name.help.text.default',
|
||||||
defaultMessage: 'The name that appears on your public profile.',
|
defaultMessage: 'The name that appears on your public profile.',
|
||||||
description: 'Help text for the account settings name field.',
|
description: 'Help text for the account settings name field.',
|
||||||
},
|
},
|
||||||
'account.settings.field.full.name.help.text.certificate': {
|
'account.settings.field.full.name.help.text.default.certificate': {
|
||||||
id: 'account.settings.field.full.name.help.text.certificate',
|
id: 'account.settings.field.full.name.help.text.default.certificate',
|
||||||
defaultMessage: 'This name is selected to appear on your certificates and public-facing records.',
|
defaultMessage: 'This name is selected to appear on your certificates and public-facing records.',
|
||||||
description: 'Help text for the account settings name field.',
|
description: 'Help text for the account settings name field.',
|
||||||
},
|
},
|
||||||
@@ -108,39 +103,64 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
'account.settings.field.name.verified.help.text.verified': {
|
'account.settings.field.name.verified.help.text.verified': {
|
||||||
id: 'account.settings.field.name.verified.help.text.verified',
|
id: 'account.settings.field.name.verified.help.text.verified',
|
||||||
defaultMessage: 'This name has been verified by government ID.',
|
defaultMessage: 'This name has been verified by photo ID.',
|
||||||
description: 'Help text for the account settings verified name field when the name is verified.',
|
description: 'Help text for the account settings verified name field when the name is verified.',
|
||||||
},
|
},
|
||||||
'account.settings.field.name.verified.help.text.certificate': {
|
'account.settings.field.name.verified.help.text.verified.proctored': {
|
||||||
id: 'account.settings.field.name.verified.help.text.certificate',
|
id: 'account.settings.field.name.verified.help.text.verified.proctored',
|
||||||
defaultMessage: 'This name has been verified by government ID and selected to appear on your certificates and public-facing records.',
|
defaultMessage: 'This name has been verified by proctoring.',
|
||||||
|
description: 'Help text for the account settings verified name field when the name is verified through proctoring.',
|
||||||
|
},
|
||||||
|
'account.settings.field.name.verified.help.text.verified.certificate': {
|
||||||
|
id: 'account.settings.field.name.verified.help.text.verified.certificate',
|
||||||
|
defaultMessage: 'This name has been verified by photo ID, and is selected to appear on your certificates and public-facing records.',
|
||||||
description: 'Help text for the account settings verified name field when the name is selected for certificates.',
|
description: 'Help text for the account settings verified name field when the name is selected for certificates.',
|
||||||
},
|
},
|
||||||
|
'account.settings.field.name.verified.help.text.verified.proctored.certificate': {
|
||||||
|
id: 'account.settings.field.name.verified.help.text.verified.proctored.certificate',
|
||||||
|
defaultMessage: 'This name has been verified by proctoring, and is selected to appear on your certificates and public-facing records.',
|
||||||
|
description: 'Help text for the account settings verified name field when the name is selected for certificates, and the name is verified through proctoring.',
|
||||||
|
},
|
||||||
'account.settings.field.name.verified.help.text.submitted': {
|
'account.settings.field.name.verified.help.text.submitted': {
|
||||||
id: 'account.settings.field.name.verified.help.text.submitted',
|
id: 'account.settings.field.name.verified.help.text.submitted',
|
||||||
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Verified name cannot be changed at this time.',
|
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Verified name cannot be changed at this time.',
|
||||||
description: 'Help text for the account settings verified name field when a verified name has been submitted.',
|
description: 'Help text for the account settings verified name field when a verified name has been submitted.',
|
||||||
},
|
},
|
||||||
|
'account.settings.field.name.verified.help.text.submitted.proctored': {
|
||||||
|
id: 'account.settings.field.name.verified.help.text.submitted.proctored',
|
||||||
|
defaultMessage: 'Your proctored exam has been submitted. Verified name cannot be changed at this time. Please check back in 2-5 days.',
|
||||||
|
description: 'Help text for the account settings verified name field when a verified name has been submitted through proctoring.',
|
||||||
|
},
|
||||||
'account.settings.field.name.verified.help.text.submitted.certificate': {
|
'account.settings.field.name.verified.help.text.submitted.certificate': {
|
||||||
id: 'account.settings.field.name.verified.help.text.submitted.certificate',
|
id: 'account.settings.field.name.verified.help.text.submitted.certificate',
|
||||||
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Verified name cannot be changed at this time.',
|
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Verified name cannot be changed at this time.',
|
||||||
description: 'Help text for the account settings verified name field when a verified name has been submitted and will appear on certificates.',
|
description: 'Help text for the account settings verified name field when a verified name has been submitted and will appear on certificates.',
|
||||||
},
|
},
|
||||||
'account.settings.field.name.verified.verification.alert': {
|
'account.settings.field.name.verified.help.text.submitted.proctored.certificate': {
|
||||||
id: 'account.settings.field.name.verified.verification.help',
|
id: 'account.settings.field.name.verified.help.text.submitted.proctored.certificate',
|
||||||
defaultMessage: 'Enter your name as it appears on your government-issued ID.',
|
defaultMessage: 'Once your proctored exam passes review, this name will appear on your certificate and public-facing records. Verified Name cannot be changed at this time.',
|
||||||
description: 'Form label instructing the user to enter the name on their ID.',
|
description: 'Help text for the account settings verified name field when a verified name has been submitted through proctoring and will appear on certificates.',
|
||||||
},
|
},
|
||||||
'account.settings.field.full.name.help.text.submitted': {
|
'account.settings.field.full.name.help.text.submitted': {
|
||||||
id: 'account.settings.field.full.name.help.text.submitted',
|
id: 'account.settings.field.full.name.help.text.submitted',
|
||||||
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Full name cannot be changed at this time.',
|
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Full name cannot be changed at this time.',
|
||||||
description: 'Help text for the account settings full name field when a verified name has been submitted.',
|
description: 'Help text for the account settings full name field when a verified name has been submitted.',
|
||||||
},
|
},
|
||||||
|
'account.settings.field.full.name.help.text.submitted.proctored': {
|
||||||
|
id: 'account.settings.field.full.name.help.text.submitted.proctored',
|
||||||
|
defaultMessage: 'Your proctored exam has been submitted. Full name cannot be changed at this time. Please check back in 2-5 days.',
|
||||||
|
description: 'Help text for the account settings full name field when a verified name has been submitted through proctoring.',
|
||||||
|
},
|
||||||
'account.settings.field.full.name.help.text.submitted.certificate': {
|
'account.settings.field.full.name.help.text.submitted.certificate': {
|
||||||
id: 'account.settings.field.full.name.help.text.submitted.certificate',
|
id: 'account.settings.field.full.name.help.text.submitted.certificate',
|
||||||
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.',
|
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.',
|
||||||
description: 'Help text for the account settings full name field when a full name has been submitted and will appear on certificates.',
|
description: 'Help text for the account settings full name field when a full name has been submitted and will appear on certificates.',
|
||||||
},
|
},
|
||||||
|
'account.settings.field.full.name.help.text.submitted.proctored.certificate': {
|
||||||
|
id: 'account.settings.field.full.name.help.text.submitted.proctored.certificate',
|
||||||
|
defaultMessage: 'Once your proctored exam passes review, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.',
|
||||||
|
description: 'Help text for the account settings full name field when a full name has been submitted and will appear on certificates.',
|
||||||
|
},
|
||||||
'account.settings.field.name.verified.success.message': {
|
'account.settings.field.name.verified.success.message': {
|
||||||
id: 'account.settings.field.name.verified.success.message',
|
id: 'account.settings.field.name.verified.success.message',
|
||||||
defaultMessage: 'Your identity verification request has successfully completed. You now have the option of selecting which name you prefer to appear on your certificates and public-records.',
|
defaultMessage: 'Your identity verification request has successfully completed. You now have the option of selecting which name you prefer to appear on your certificates and public-records.',
|
||||||
@@ -236,6 +256,56 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Select a year of birth',
|
defaultMessage: 'Select a year of birth',
|
||||||
description: 'Option for empty value on account settings year of birth field.',
|
description: 'Option for empty value on account settings year of birth field.',
|
||||||
},
|
},
|
||||||
|
'account.settings.field.dob.month': {
|
||||||
|
id: 'account.settings.field.dob.month',
|
||||||
|
defaultMessage: 'Month',
|
||||||
|
description: 'Label for account settings month of birth field.',
|
||||||
|
},
|
||||||
|
'account.settings.field.dob.year': {
|
||||||
|
id: 'account.settings.field.dob.year',
|
||||||
|
defaultMessage: 'Year',
|
||||||
|
description: 'Label for account settings year of birth field.',
|
||||||
|
},
|
||||||
|
'account.settings.field.dob.month.default': {
|
||||||
|
id: 'account.settings.field.month.year.default',
|
||||||
|
defaultMessage: 'Select month',
|
||||||
|
description: 'Default label for account settings month of birth field.',
|
||||||
|
},
|
||||||
|
'account.settings.field.dob.year.default': {
|
||||||
|
id: 'account.settings.field.dob.year.default',
|
||||||
|
defaultMessage: 'Select year',
|
||||||
|
description: 'Default label for account settings year of birth field.',
|
||||||
|
},
|
||||||
|
'account.settings.field.dob.form.button': {
|
||||||
|
id: 'account.settings.field.dob.form.button',
|
||||||
|
defaultMessage: 'Please confirm your date of birth',
|
||||||
|
description: 'Message to prompt user to enter dob',
|
||||||
|
},
|
||||||
|
'account.settings.field.dob.form.title': {
|
||||||
|
id: 'account.settings.field.dob.form.title',
|
||||||
|
defaultMessage: 'Enter your birth month and year',
|
||||||
|
description: 'Title of DOB form',
|
||||||
|
},
|
||||||
|
'account.settings.field.dob.form.help.text': {
|
||||||
|
id: 'account.settings.field.dob.form.help.text',
|
||||||
|
defaultMessage: 'We ask for birth month and year information to help us comply with our legal obligations.',
|
||||||
|
description: 'Help text for DOB form',
|
||||||
|
},
|
||||||
|
'account.settings.field.dob.form.success': {
|
||||||
|
id: 'account.settings.field.dob.form.success',
|
||||||
|
defaultMessage: 'Thank you for entering your information.',
|
||||||
|
description: 'Title of banner when date of birth is successfully entered',
|
||||||
|
},
|
||||||
|
'account.settings.field.month_of_birth.options.empty': {
|
||||||
|
id: 'account.settings.field.month_of_birth.options.empty',
|
||||||
|
defaultMessage: 'Select a month of birth',
|
||||||
|
description: 'Option for empty value on account settings month of birth field.',
|
||||||
|
},
|
||||||
|
'account.settingsfield.dob.error.general': {
|
||||||
|
id: 'account.settingsfield.dob.error.general',
|
||||||
|
defaultMessage: 'A technical error occurred. Please try again.',
|
||||||
|
description: 'Generic error message.',
|
||||||
|
},
|
||||||
'account.settings.field.country': {
|
'account.settings.field.country': {
|
||||||
id: 'account.settings.field.country',
|
id: 'account.settings.field.country',
|
||||||
defaultMessage: 'Country',
|
defaultMessage: 'Country',
|
||||||
@@ -331,8 +401,8 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'No formal education',
|
defaultMessage: 'No formal education',
|
||||||
description: 'Selected by the user to describe their education.',
|
description: 'Selected by the user to describe their education.',
|
||||||
},
|
},
|
||||||
'account.settings.field.education.levels.o': {
|
'account.settings.field.education.levels.other': {
|
||||||
id: 'account.settings.field.education.levels.o',
|
id: 'account.settings.field.education.levels.other',
|
||||||
defaultMessage: 'Other education',
|
defaultMessage: 'Other education',
|
||||||
description: 'Selected by the user if they have a type of education not described by the other choices.',
|
description: 'Selected by the user if they have a type of education not described by the other choices.',
|
||||||
},
|
},
|
||||||
@@ -439,15 +509,15 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Delete My Account',
|
defaultMessage: 'Delete My Account',
|
||||||
description: 'Header for the user account deletion area',
|
description: 'Header for the user account deletion area',
|
||||||
},
|
},
|
||||||
'account.settings.field.social.platform.name.twitter': {
|
'account.settings.field.social.platform.name.xTwitter': {
|
||||||
id: 'account.settings.field.social.platform.name.twitter',
|
id: 'account.settings.field.social.platform.name.xTwitter',
|
||||||
defaultMessage: 'Twitter',
|
defaultMessage: 'X (Twitter)',
|
||||||
description: 'Label for Twitter',
|
description: 'Label for X (Twitter)',
|
||||||
},
|
},
|
||||||
'account.settings.field.social.platform.name.twitter.empty': {
|
'account.settings.field.social.platform.name.xTwitter.empty': {
|
||||||
id: 'account.settings.field.social.platform.name.twitter.empty',
|
id: 'account.settings.field.social.platform.name.xTwitter.empty',
|
||||||
defaultMessage: 'Add Twitter profile',
|
defaultMessage: 'Add X profile',
|
||||||
description: 'Placeholder for an empty Twitter field',
|
description: 'Placeholder for an empty X field',
|
||||||
},
|
},
|
||||||
|
|
||||||
'account.settings.field.social.platform.name.facebook': {
|
'account.settings.field.social.platform.name.facebook': {
|
||||||
@@ -485,6 +555,26 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'No value set.',
|
defaultMessage: 'No value set.',
|
||||||
description: 'The placeholder for an empty but uneditable field when there is no administrator',
|
description: 'The placeholder for an empty but uneditable field when there is no administrator',
|
||||||
},
|
},
|
||||||
|
'notification.preferences.notifications.label': {
|
||||||
|
id: 'notification.preferences.notifications.label',
|
||||||
|
defaultMessage: 'Notifications',
|
||||||
|
description: 'Label for Notifications',
|
||||||
|
},
|
||||||
|
'account.settings.field.work.experience': {
|
||||||
|
id: 'account.settings.work.experience',
|
||||||
|
defaultMessage: 'Work Experience',
|
||||||
|
description: 'Label for account settings Work experience field.',
|
||||||
|
},
|
||||||
|
'account.settings.field.work.experience.empty': {
|
||||||
|
id: 'account.settings.field.work.experience.empty',
|
||||||
|
defaultMessage: 'Add work experience',
|
||||||
|
description: 'Placeholder for empty account settings work experience field.',
|
||||||
|
},
|
||||||
|
'account.settings.field.work.experience.options.empty': {
|
||||||
|
id: 'account.settings.field.work.experience.options.empty',
|
||||||
|
defaultMessage: 'Select work experience',
|
||||||
|
description: 'Placeholder for the work experience levels dropdown.',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
|
|||||||
@@ -2,18 +2,16 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
function Alert(props) {
|
const Alert = (props) => (
|
||||||
return (
|
<div className={classNames('alert d-flex align-items-start', props.className)}>
|
||||||
<div className={classNames('alert d-flex align-items-start', props.className)}>
|
<div>
|
||||||
<div>
|
{props.icon}
|
||||||
{props.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div>
|
||||||
}
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
Alert.propTypes = {
|
Alert.propTypes = {
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Button, Hyperlink } from '@edx/paragon';
|
import { Button, Hyperlink } from '@openedx/paragon';
|
||||||
|
|
||||||
import { betaLanguageBannerSelector } from './data/selectors';
|
import { betaLanguageBannerSelector } from './data/selectors';
|
||||||
import messages from './AccountSettingsPage.messages';
|
import messages from './AccountSettingsPage.messages';
|
||||||
@@ -49,6 +49,9 @@ class BetaLanguageBanner extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const savedLanguage = this.getSiteLanguageEntry(this.context.locale);
|
const savedLanguage = this.getSiteLanguageEntry(this.context.locale);
|
||||||
|
if (!savedLanguage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const isSavedLanguageReleased = savedLanguage.released === true;
|
const isSavedLanguageReleased = savedLanguage.released === true;
|
||||||
const noPreviousLanguageSet = this.props.siteLanguage.previousValue === null;
|
const noPreviousLanguageSet = this.props.siteLanguage.previousValue === null;
|
||||||
if (isSavedLanguageReleased || noPreviousLanguageSet) {
|
if (isSavedLanguageReleased || noPreviousLanguageSet) {
|
||||||
|
|||||||
165
src/account-settings/DOBForm.jsx
Normal file
165
src/account-settings/DOBForm.jsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import {
|
||||||
|
Form, StatefulButton, ModalDialog, ActionRow, useToggle, Button,
|
||||||
|
} from '@openedx/paragon';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { connect, useDispatch } from 'react-redux';
|
||||||
|
import messages from './AccountSettingsPage.messages';
|
||||||
|
import { YEAR_OF_BIRTH_OPTIONS } from './data/constants';
|
||||||
|
import { editableFieldSelector } from './data/selectors';
|
||||||
|
import { saveSettingsReset } from './data/actions';
|
||||||
|
|
||||||
|
const DOBModal = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const {
|
||||||
|
saveState,
|
||||||
|
error,
|
||||||
|
onSubmit,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const [isOpen, open, close, toggle] = useToggle(true, {});
|
||||||
|
const [monthValue, setMonthValue] = useState('');
|
||||||
|
const [yearValue, setYearValue] = useState('');
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.target.name === 'month') {
|
||||||
|
setMonthValue(e.target.value);
|
||||||
|
} else if (e.target.name === 'year') {
|
||||||
|
setYearValue(e.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const data = monthValue !== '' && yearValue !== '' ? [{ field_name: 'DOB', field_value: `${yearValue}-${monthValue}` }] : [];
|
||||||
|
onSubmit('extended_profile', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = useCallback(() => {
|
||||||
|
localStorage.setItem('submittedDOB', 'true');
|
||||||
|
close();
|
||||||
|
dispatch(saveSettingsReset());
|
||||||
|
}, [dispatch, close]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
close();
|
||||||
|
dispatch(saveSettingsReset());
|
||||||
|
}, [dispatch, close]);
|
||||||
|
|
||||||
|
function renderErrors() {
|
||||||
|
if (saveState === 'error' || error) {
|
||||||
|
return (
|
||||||
|
<Form.Control.Feedback type="invalid" key="general-error" data-testid="error-message">
|
||||||
|
{intl.formatMessage(messages['account.settingsfield.dob.error.general'])}
|
||||||
|
</Form.Control.Feedback>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveState === 'complete' && isOpen) {
|
||||||
|
handleComplete();
|
||||||
|
}
|
||||||
|
}, [handleComplete, saveState, isOpen, monthValue, yearValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="primary" onClick={open} data-testid="open-modal-button">
|
||||||
|
{intl.formatMessage(messages['account.settings.field.dob.form.button'])}
|
||||||
|
</Button>
|
||||||
|
<ModalDialog
|
||||||
|
title={intl.formatMessage(messages['account.settings.field.dob.form.title'])}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
hasCloseButton={false}
|
||||||
|
variant="default"
|
||||||
|
data-testid="dob-modal"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} data-testid="dob-form">
|
||||||
|
|
||||||
|
<ModalDialog.Header>
|
||||||
|
<ModalDialog.Title data-testid="modal-title">
|
||||||
|
{intl.formatMessage(messages['account.settings.field.dob.form.title'])}
|
||||||
|
</ModalDialog.Title>
|
||||||
|
</ModalDialog.Header>
|
||||||
|
|
||||||
|
<ModalDialog.Body className="overflow-hidden" style={{ padding: '1.5rem' }}>
|
||||||
|
<p data-testid="help-text">{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label data-testid="month-label">
|
||||||
|
{intl.formatMessage(messages['account.settings.field.dob.month'])}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="select"
|
||||||
|
name="month"
|
||||||
|
onChange={handleChange}
|
||||||
|
data-testid="month-select"
|
||||||
|
>
|
||||||
|
<option value="">{intl.formatMessage(messages['account.settings.field.dob.month.default'])}</option>
|
||||||
|
{[...Array(12).keys()].map(month => (
|
||||||
|
<option key={month + 1} value={month + 1}>{month + 1}</option>
|
||||||
|
))}
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label data-testid="year-label">
|
||||||
|
{intl.formatMessage(messages['account.settings.field.dob.year'])}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="select"
|
||||||
|
name="year"
|
||||||
|
onChange={handleChange}
|
||||||
|
data-testid="year-select"
|
||||||
|
>
|
||||||
|
<option value="">{intl.formatMessage(messages['account.settings.field.dob.year.default'])}</option>
|
||||||
|
{YEAR_OF_BIRTH_OPTIONS.map(year => (
|
||||||
|
<option key={year.value} value={year.value}>{year.label}</option>
|
||||||
|
))}
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Group>
|
||||||
|
{renderErrors()}
|
||||||
|
</ModalDialog.Body>
|
||||||
|
|
||||||
|
<ModalDialog.Footer>
|
||||||
|
<ActionRow>
|
||||||
|
<ModalDialog.CloseButton variant="tertiary" data-testid="cancel-button">
|
||||||
|
Cancel
|
||||||
|
</ModalDialog.CloseButton>
|
||||||
|
<StatefulButton
|
||||||
|
type="submit"
|
||||||
|
state={!(monthValue && yearValue) ? 'unedited' : saveState}
|
||||||
|
labels={{
|
||||||
|
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
|
||||||
|
}}
|
||||||
|
disabledStates={['unedited']}
|
||||||
|
data-testid="submit-button"
|
||||||
|
/>
|
||||||
|
</ActionRow>
|
||||||
|
</ModalDialog.Footer>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</ModalDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DOBModal.propTypes = {
|
||||||
|
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
||||||
|
error: PropTypes.string,
|
||||||
|
onSubmit: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
DOBModal.defaultProps = {
|
||||||
|
saveState: undefined,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(editableFieldSelector)(DOBModal);
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import classNames from 'classnames';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, Input, StatefulButton, ValidationFormGroup,
|
Button, Form, StatefulButton,
|
||||||
} from '@edx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { editableFieldSelector } from './data/selectors';
|
import { editableFieldSelector } from './data/selectors';
|
||||||
import CertificatePreference from './certificate-preference/CertificatePreference';
|
import CertificatePreference from './certificate-preference/CertificatePreference';
|
||||||
|
|
||||||
function EditableField(props) {
|
const EditableField = (props) => {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
@@ -26,7 +26,6 @@ function EditableField(props) {
|
|||||||
type,
|
type,
|
||||||
value,
|
value,
|
||||||
userSuppliedValue,
|
userSuppliedValue,
|
||||||
options,
|
|
||||||
saveState,
|
saveState,
|
||||||
error,
|
error,
|
||||||
confirmationMessageDefinition,
|
confirmationMessageDefinition,
|
||||||
@@ -39,10 +38,10 @@ function EditableField(props) {
|
|||||||
isEditing,
|
isEditing,
|
||||||
isEditable,
|
isEditable,
|
||||||
isGrayedOut,
|
isGrayedOut,
|
||||||
intl,
|
|
||||||
...others
|
...others
|
||||||
} = props;
|
} = props;
|
||||||
const id = `field-${name}`;
|
const id = `field-${name}`;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -74,15 +73,6 @@ function EditableField(props) {
|
|||||||
}
|
}
|
||||||
let finalValue = rawValue;
|
let finalValue = rawValue;
|
||||||
|
|
||||||
if (options) {
|
|
||||||
// Use == instead of === to prevent issues when HTML casts numbers as strings
|
|
||||||
// eslint-disable-next-line eqeqeq
|
|
||||||
const selectedOption = options.find(option => option.value == rawValue);
|
|
||||||
if (selectedOption) {
|
|
||||||
finalValue = selectedOption.label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userSuppliedValue) {
|
if (userSuppliedValue) {
|
||||||
finalValue += `: ${userSuppliedValue}`;
|
finalValue += `: ${userSuppliedValue}`;
|
||||||
}
|
}
|
||||||
@@ -94,9 +84,13 @@ function EditableField(props) {
|
|||||||
if (!confirmationMessageDefinition || !confirmationValue) {
|
if (!confirmationMessageDefinition || !confirmationValue) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return intl.formatMessage(confirmationMessageDefinition, {
|
return (
|
||||||
value: confirmationValue,
|
<span data-testid="editable-field-confirmation">
|
||||||
});
|
{intl.formatMessage(confirmationMessageDefinition, {
|
||||||
|
value: confirmationValue,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -105,26 +99,26 @@ function EditableField(props) {
|
|||||||
cases={{
|
cases={{
|
||||||
editing: (
|
editing: (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} data-testid="editable-field-form">
|
||||||
<ValidationFormGroup
|
<Form.Group
|
||||||
for={id}
|
controlId={id}
|
||||||
invalid={error != null}
|
isInvalid={error != null}
|
||||||
invalidMessage={error}
|
|
||||||
helpText={helpText}
|
|
||||||
>
|
>
|
||||||
<label className="h6 d-block" htmlFor={id}>{label}</label>
|
<Form.Label size="sm" className="h6 d-block" htmlFor={id}>{label}</Form.Label>
|
||||||
<Input
|
<Form.Control
|
||||||
data-hj-suppress
|
data-hj-suppress
|
||||||
name={name}
|
name={name}
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
options={options}
|
data-testid="editable-field-textbox"
|
||||||
{...others}
|
{...others}
|
||||||
/>
|
/>
|
||||||
<>{others.children}</>
|
{!!helpText && <Form.Text>{helpText}</Form.Text>}
|
||||||
</ValidationFormGroup>
|
{error != null && <Form.Control.Feedback hasIcon={false} data-testid="editable-field-error">{error}</Form.Control.Feedback>}
|
||||||
|
{others.children}
|
||||||
|
</Form.Group>
|
||||||
<p>
|
<p>
|
||||||
<StatefulButton
|
<StatefulButton
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -144,16 +138,21 @@ function EditableField(props) {
|
|||||||
if (saveState === 'pending') { e.preventDefault(); }
|
if (saveState === 'pending') { e.preventDefault(); }
|
||||||
}}
|
}}
|
||||||
disabledStates={[]}
|
disabledStates={[]}
|
||||||
|
data-testid="editable-field-save"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline-primary"
|
variant="outline-primary"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
|
data-testid="editable-field-cancel"
|
||||||
|
data-clicked="cancel"
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
||||||
</Button>
|
</Button>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
{['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
|
{['name', 'verified_name'].includes(name) && (
|
||||||
|
<CertificatePreference fieldName={name} data-testid="editable-field-certificate-preference" />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
default: (
|
default: (
|
||||||
@@ -161,19 +160,19 @@ function EditableField(props) {
|
|||||||
<div className="d-flex align-items-start">
|
<div className="d-flex align-items-start">
|
||||||
<h6 aria-level="3">{label}</h6>
|
<h6 aria-level="3">{label}</h6>
|
||||||
{isEditable ? (
|
{isEditable ? (
|
||||||
<Button variant="link" onClick={handleEdit} className="ml-3">
|
<Button variant="link" onClick={handleEdit} className="ml-3" data-testid="editable-field-edit" data-clicked="edit">
|
||||||
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
|
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p data-hj-suppress className={isGrayedOut ? 'grayed-out' : null}>{renderValue(value)}</p>
|
<p data-hj-suppress className={classNames('text-truncate', { 'grayed-out': isGrayedOut })}>{renderValue(value)}</p>
|
||||||
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
|
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
EditableField.propTypes = {
|
EditableField.propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
@@ -182,10 +181,6 @@ EditableField.propTypes = {
|
|||||||
type: PropTypes.string.isRequired,
|
type: PropTypes.string.isRequired,
|
||||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
userSuppliedValue: PropTypes.string,
|
userSuppliedValue: PropTypes.string,
|
||||||
options: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
||||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
||||||
})),
|
|
||||||
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
||||||
error: PropTypes.string,
|
error: PropTypes.string,
|
||||||
confirmationMessageDefinition: PropTypes.shape({
|
confirmationMessageDefinition: PropTypes.shape({
|
||||||
@@ -202,12 +197,10 @@ EditableField.propTypes = {
|
|||||||
isEditing: PropTypes.bool,
|
isEditing: PropTypes.bool,
|
||||||
isEditable: PropTypes.bool,
|
isEditable: PropTypes.bool,
|
||||||
isGrayedOut: PropTypes.bool,
|
isGrayedOut: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
EditableField.defaultProps = {
|
EditableField.defaultProps = {
|
||||||
value: undefined,
|
value: undefined,
|
||||||
options: undefined,
|
|
||||||
saveState: undefined,
|
saveState: undefined,
|
||||||
label: undefined,
|
label: undefined,
|
||||||
emptyLabel: undefined,
|
emptyLabel: undefined,
|
||||||
@@ -224,4 +217,4 @@ EditableField.defaultProps = {
|
|||||||
export default connect(editableFieldSelector, {
|
export default connect(editableFieldSelector, {
|
||||||
onEdit: openForm,
|
onEdit: openForm,
|
||||||
onCancel: closeForm,
|
onCancel: closeForm,
|
||||||
})(injectIntl(EditableField));
|
})(EditableField);
|
||||||
|
|||||||
250
src/account-settings/EditableSelectField.jsx
Normal file
250
src/account-settings/EditableSelectField.jsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import {
|
||||||
|
Button, Form, StatefulButton,
|
||||||
|
} from '@openedx/paragon';
|
||||||
|
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
|
||||||
|
import SwitchContent from './SwitchContent';
|
||||||
|
import messages from './AccountSettingsPage.messages';
|
||||||
|
|
||||||
|
import {
|
||||||
|
openForm,
|
||||||
|
closeForm,
|
||||||
|
} from './data/actions';
|
||||||
|
import { editableFieldSelector } from './data/selectors';
|
||||||
|
import CertificatePreference from './certificate-preference/CertificatePreference';
|
||||||
|
|
||||||
|
const EditableSelectField = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
emptyLabel,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
userSuppliedValue,
|
||||||
|
options,
|
||||||
|
saveState,
|
||||||
|
error,
|
||||||
|
confirmationMessageDefinition,
|
||||||
|
confirmationValue,
|
||||||
|
helpText,
|
||||||
|
onEdit,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
onChange,
|
||||||
|
isEditing,
|
||||||
|
isEditable,
|
||||||
|
isGrayedOut,
|
||||||
|
...others
|
||||||
|
} = props;
|
||||||
|
const id = `field-${name}`;
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(name, new FormData(e.target).get(name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
onChange(name, e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
onEdit(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
onCancel(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmptyLabel = () => {
|
||||||
|
if (isEditable) {
|
||||||
|
return <Button variant="link" onClick={handleEdit} className="p-0">{emptyLabel}</Button>;
|
||||||
|
}
|
||||||
|
return <span className="text-muted">{emptyLabel}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderValue = (rawValue) => {
|
||||||
|
if (!rawValue) {
|
||||||
|
return renderEmptyLabel();
|
||||||
|
}
|
||||||
|
let finalValue = rawValue;
|
||||||
|
|
||||||
|
if (options) {
|
||||||
|
// Use == instead of === to prevent issues when HTML casts numbers as strings
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
const selectedOption = options.find(option => option.value == rawValue);
|
||||||
|
if (selectedOption) {
|
||||||
|
finalValue = selectedOption.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userSuppliedValue) {
|
||||||
|
finalValue += `: ${userSuppliedValue}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderConfirmationMessage = () => {
|
||||||
|
if (!confirmationMessageDefinition || !confirmationValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return intl.formatMessage(confirmationMessageDefinition, {
|
||||||
|
value: confirmationValue,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const selectOptions = options.map((option) => {
|
||||||
|
if (option.group) {
|
||||||
|
// If the option has a 'group' property, it represents an element with sub-options.
|
||||||
|
return (
|
||||||
|
<optgroup label={option.label} key={option.label}>
|
||||||
|
{option.group.map((subOption) => (
|
||||||
|
<option
|
||||||
|
value={subOption.value}
|
||||||
|
key={`${subOption.value}-${subOption.label}`}
|
||||||
|
disabled={subOption?.disabled}
|
||||||
|
>
|
||||||
|
{subOption.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<option value={option.value} key={`${option.value}-${option.label}`} disabled={option?.disabled}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwitchContent
|
||||||
|
expression={isEditing ? 'editing' : 'default'}
|
||||||
|
cases={{
|
||||||
|
editing: (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Form.Group
|
||||||
|
controlId={id}
|
||||||
|
isInvalid={error != null}
|
||||||
|
>
|
||||||
|
<Form.Label size="sm" className="h6 d-block" htmlFor={id}>{label}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
data-hj-suppress
|
||||||
|
name={name}
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
as={type}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...others}
|
||||||
|
>
|
||||||
|
{options.length > 0 && selectOptions}
|
||||||
|
</Form.Control>
|
||||||
|
{!!helpText && <Form.Text>{helpText}</Form.Text>}
|
||||||
|
{error != null && <Form.Control.Feedback>{error}</Form.Control.Feedback>}
|
||||||
|
{others.children}
|
||||||
|
</Form.Group>
|
||||||
|
<p>
|
||||||
|
<StatefulButton
|
||||||
|
type="submit"
|
||||||
|
className="mr-2"
|
||||||
|
state={saveState}
|
||||||
|
labels={{
|
||||||
|
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Swallow clicks if the state is pending.
|
||||||
|
// We do this instead of disabling the button to prevent
|
||||||
|
// it from losing focus (disabled elements cannot have focus).
|
||||||
|
// Disabling it would causes upstream issues in focus management.
|
||||||
|
// Swallowing the onSubmit event on the form would be better, but
|
||||||
|
// we would have to add that logic for every field given our
|
||||||
|
// current structure of the application.
|
||||||
|
if (saveState === 'pending') { e.preventDefault(); }
|
||||||
|
}}
|
||||||
|
disabledStates={[]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline-primary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
default: (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="d-flex align-items-start">
|
||||||
|
<h6 aria-level="3">{label}</h6>
|
||||||
|
{isEditable ? (
|
||||||
|
<Button variant="link" onClick={handleEdit} className="ml-3">
|
||||||
|
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p data-hj-suppress className={isGrayedOut ? 'grayed-out' : null}>{renderValue(value)}</p>
|
||||||
|
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditableSelectField.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]),
|
||||||
|
emptyLabel: PropTypes.node,
|
||||||
|
type: PropTypes.string.isRequired,
|
||||||
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
userSuppliedValue: PropTypes.string,
|
||||||
|
options: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
})),
|
||||||
|
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
||||||
|
error: PropTypes.string,
|
||||||
|
confirmationMessageDefinition: PropTypes.shape({
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
defaultMessage: PropTypes.string.isRequired,
|
||||||
|
description: PropTypes.string,
|
||||||
|
}),
|
||||||
|
confirmationValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
helpText: PropTypes.node,
|
||||||
|
onEdit: PropTypes.func.isRequired,
|
||||||
|
onCancel: PropTypes.func.isRequired,
|
||||||
|
onSubmit: PropTypes.func.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
isEditing: PropTypes.bool,
|
||||||
|
isEditable: PropTypes.bool,
|
||||||
|
isGrayedOut: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
EditableSelectField.defaultProps = {
|
||||||
|
value: undefined,
|
||||||
|
options: [],
|
||||||
|
saveState: undefined,
|
||||||
|
label: undefined,
|
||||||
|
emptyLabel: undefined,
|
||||||
|
error: undefined,
|
||||||
|
confirmationMessageDefinition: undefined,
|
||||||
|
confirmationValue: undefined,
|
||||||
|
helpText: undefined,
|
||||||
|
isEditing: false,
|
||||||
|
isEditable: true,
|
||||||
|
isGrayedOut: false,
|
||||||
|
userSuppliedValue: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(editableFieldSelector, {
|
||||||
|
onEdit: openForm,
|
||||||
|
onCancel: closeForm,
|
||||||
|
})(EditableSelectField);
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, StatefulButton, Input, ValidationFormGroup,
|
Button, StatefulButton, Form, Tooltip, OverlayTrigger,
|
||||||
} from '@edx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
} from './data/actions';
|
} from './data/actions';
|
||||||
import { editableFieldSelector } from './data/selectors';
|
import { editableFieldSelector } from './data/selectors';
|
||||||
|
|
||||||
function EmailField(props) {
|
const EmailField = (props) => {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
@@ -35,9 +34,9 @@ function EmailField(props) {
|
|||||||
onChange,
|
onChange,
|
||||||
isEditing,
|
isEditing,
|
||||||
isEditable,
|
isEditable,
|
||||||
intl,
|
|
||||||
} = props;
|
} = props;
|
||||||
const id = `field-${name}`;
|
const id = `field-${name}`;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -106,14 +105,12 @@ function EmailField(props) {
|
|||||||
cases={{
|
cases={{
|
||||||
editing: (
|
editing: (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<ValidationFormGroup
|
<Form.Group
|
||||||
for={id}
|
controlId={id}
|
||||||
invalid={error != null}
|
isInvalid={error != null}
|
||||||
invalidMessage={error}
|
|
||||||
helpText={helpText}
|
|
||||||
>
|
>
|
||||||
<label className="h6 d-block" htmlFor={id}>{label}</label>
|
<Form.Label className="h6 d-block" htmlFor={id}>{label}</Form.Label>
|
||||||
<Input
|
<Form.Control
|
||||||
data-hj-suppress
|
data-hj-suppress
|
||||||
name={name}
|
name={name}
|
||||||
id={id}
|
id={id}
|
||||||
@@ -121,7 +118,9 @@ function EmailField(props) {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
</ValidationFormGroup>
|
{!!helpText && <Form.Text>{helpText}</Form.Text>}
|
||||||
|
{error != null && <Form.Control.Feedback hasIcon={false}>{error}</Form.Control.Feedback>}
|
||||||
|
</Form.Group>
|
||||||
<p>
|
<p>
|
||||||
<StatefulButton
|
<StatefulButton
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -162,14 +161,23 @@ function EmailField(props) {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p data-hj-suppress>{renderValue()}</p>
|
<OverlayTrigger
|
||||||
|
placement="top"
|
||||||
|
overlay={(
|
||||||
|
<Tooltip id={`tooltip-${name}`} variant="light" className="d-sm-none">
|
||||||
|
{renderValue()}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p data-hj-suppress className="text-truncate">{renderValue()}</p>
|
||||||
|
</OverlayTrigger>
|
||||||
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
|
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
EmailField.propTypes = {
|
EmailField.propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
@@ -191,7 +199,6 @@ EmailField.propTypes = {
|
|||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
isEditing: PropTypes.bool,
|
isEditing: PropTypes.bool,
|
||||||
isEditable: PropTypes.bool,
|
isEditable: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
EmailField.defaultProps = {
|
EmailField.defaultProps = {
|
||||||
@@ -210,4 +217,4 @@ EmailField.defaultProps = {
|
|||||||
export default connect(editableFieldSelector, {
|
export default connect(editableFieldSelector, {
|
||||||
onEdit: openForm,
|
onEdit: openForm,
|
||||||
onCancel: closeForm,
|
onCancel: closeForm,
|
||||||
})(injectIntl(EmailField));
|
})(EmailField);
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
import React from 'react';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { NavHashLink } from 'react-router-hash-link';
|
import { NavHashLink } from 'react-router-hash-link';
|
||||||
import Scrollspy from 'react-scrollspy';
|
import Scrollspy from 'react-scrollspy';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import messages from './AccountSettingsPage.messages';
|
import messages from './AccountSettingsPage.messages';
|
||||||
|
|
||||||
function JumpNav({ intl, displayDemographicsLink }) {
|
const JumpNav = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="jump-nav">
|
<div className={classNames('jump-nav', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
|
||||||
<Scrollspy
|
<Scrollspy
|
||||||
items={[
|
items={[
|
||||||
'basic-information',
|
'basic-information',
|
||||||
'profile-information',
|
'profile-information',
|
||||||
'demographics-information',
|
|
||||||
'social-media',
|
'social-media',
|
||||||
|
'notifications',
|
||||||
'site-preferences',
|
'site-preferences',
|
||||||
'linked-accounts',
|
'linked-accounts',
|
||||||
'delete-account',
|
'delete-account',
|
||||||
]}
|
]}
|
||||||
className="list-unstyled"
|
className="list-unstyled"
|
||||||
currentClassName="font-weight-bold"
|
currentClassName="font-weight-bold"
|
||||||
|
offset={-64}
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<NavHashLink to="#basic-information">
|
<NavHashLink to="#basic-information">
|
||||||
@@ -33,19 +36,16 @@ function JumpNav({ intl, displayDemographicsLink }) {
|
|||||||
{intl.formatMessage(messages['account.settings.section.profile.information'])}
|
{intl.formatMessage(messages['account.settings.section.profile.information'])}
|
||||||
</NavHashLink>
|
</NavHashLink>
|
||||||
</li>
|
</li>
|
||||||
{getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && displayDemographicsLink
|
|
||||||
&& (
|
|
||||||
<li>
|
|
||||||
<NavHashLink to="#demographics-information">
|
|
||||||
{intl.formatMessage(messages['account.settings.section.demographics.information'])}
|
|
||||||
</NavHashLink>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<li>
|
<li>
|
||||||
<NavHashLink to="#social-media">
|
<NavHashLink to="#social-media">
|
||||||
{intl.formatMessage(messages['account.settings.section.social.media'])}
|
{intl.formatMessage(messages['account.settings.section.social.media'])}
|
||||||
</NavHashLink>
|
</NavHashLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavHashLink to="#notifications">
|
||||||
|
{intl.formatMessage(messages['notification.preferences.notifications.label'])}
|
||||||
|
</NavHashLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NavHashLink to="#site-preferences">
|
<NavHashLink to="#site-preferences">
|
||||||
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
||||||
@@ -56,19 +56,17 @@ function JumpNav({ intl, displayDemographicsLink }) {
|
|||||||
{intl.formatMessage(messages['account.settings.section.linked.accounts'])}
|
{intl.formatMessage(messages['account.settings.section.linked.accounts'])}
|
||||||
</NavHashLink>
|
</NavHashLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{getConfig().ENABLE_ACCOUNT_DELETION
|
||||||
<NavHashLink to="#delete-account">
|
&& (
|
||||||
{intl.formatMessage(messages['account.settings.jump.nav.delete.account'])}
|
<li>
|
||||||
</NavHashLink>
|
<NavHashLink to="#delete-account">
|
||||||
</li>
|
{intl.formatMessage(messages['account.settings.jump.nav.delete.account'])}
|
||||||
|
</NavHashLink>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</Scrollspy>
|
</Scrollspy>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
JumpNav.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
displayDemographicsLink: PropTypes.bool.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(JumpNav);
|
export default JumpNav;
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
const NotFoundPage = () => (
|
||||||
return (
|
<div
|
||||||
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
|
className="container-fluid d-flex py-5 justify-content-center align-items-start text-center"
|
||||||
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
|
data-testid="not-found-page"
|
||||||
<FormattedMessage
|
>
|
||||||
id="error.notfound.message"
|
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
|
||||||
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
|
<FormattedMessage
|
||||||
description="error message when a page does not exist"
|
id="error.notfound.message"
|
||||||
/>
|
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
|
||||||
</p>
|
description="Error message when a page does not exist"
|
||||||
</div>
|
/>
|
||||||
);
|
</p>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NotFoundPage;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Alert } from '@edx/paragon';
|
import { Alert } from '@openedx/paragon';
|
||||||
|
|
||||||
export default function OneTimeDismissibleAlert(props) {
|
const OneTimeDismissibleAlert = (props) => {
|
||||||
const [dismissed, setDismissed] = useState(localStorage.getItem(props.id) !== 'true');
|
const [dismissed, setDismissed] = useState(localStorage.getItem(props.id) !== 'true');
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
@@ -25,7 +25,7 @@ export default function OneTimeDismissibleAlert(props) {
|
|||||||
</p>
|
</p>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
OneTimeDismissibleAlert.propTypes = {
|
OneTimeDismissibleAlert.propTypes = {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
@@ -41,3 +41,5 @@ OneTimeDismissibleAlert.defaultProps = {
|
|||||||
header: undefined,
|
header: undefined,
|
||||||
body: undefined,
|
body: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default OneTimeDismissibleAlert;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { TransitionReplace } from '@edx/paragon';
|
import { TransitionReplace } from '@openedx/paragon';
|
||||||
|
|
||||||
const onChildExit = (htmlNode) => {
|
const onChildExit = (htmlNode) => {
|
||||||
// If the leaving child has focus, take control and redirect it
|
// If the leaving child has focus, take control and redirect it
|
||||||
@@ -22,7 +22,7 @@ const onChildExit = (htmlNode) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function SwitchContent({ expression, cases, className }) {
|
const SwitchContent = ({ expression, cases, className }) => {
|
||||||
const getContent = (caseKey) => {
|
const getContent = (caseKey) => {
|
||||||
if (cases[caseKey]) {
|
if (cases[caseKey]) {
|
||||||
if (typeof cases[caseKey] === 'string') {
|
if (typeof cases[caseKey] === 'string') {
|
||||||
@@ -48,7 +48,7 @@ function SwitchContent({ expression, cases, className }) {
|
|||||||
{getContent(expression)}
|
{getContent(expression)}
|
||||||
</TransitionReplace>
|
</TransitionReplace>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
SwitchContent.propTypes = {
|
SwitchContent.propTypes = {
|
||||||
expression: PropTypes.string,
|
expression: PropTypes.string,
|
||||||
|
|||||||
@@ -14,12 +14,11 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jump-nav-sm {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.jump-nav {
|
.jump-nav {
|
||||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
|
||||||
padding-top: 1rem;
|
|
||||||
position: sticky;
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
li {
|
||||||
margin-bottom: .5rem;
|
margin-bottom: .5rem;
|
||||||
@@ -30,17 +29,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading {
|
|
||||||
@extend .h4;
|
|
||||||
margin-bottom: map-get($spacers, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-section {
|
|
||||||
// These properties together will shift the hashlink position
|
|
||||||
margin-bottom: map-get($spacers, 5);
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-switch {
|
.custom-switch {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
@@ -55,3 +43,8 @@
|
|||||||
filter: alpha(opacity = 60); /* MSIE */
|
filter: alpha(opacity = 60); /* MSIE */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#tooltip-email .small {
|
||||||
|
display: block;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { connect, useDispatch } from 'react-redux';
|
import { connect, useDispatch } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
Form,
|
Form,
|
||||||
ModalDialog,
|
ModalDialog,
|
||||||
StatefulButton,
|
StatefulButton,
|
||||||
} from '@edx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
closeForm,
|
closeForm,
|
||||||
@@ -21,26 +21,20 @@ import { certPreferenceSelector } from '../data/selectors';
|
|||||||
import commonMessages from '../AccountSettingsPage.messages';
|
import commonMessages from '../AccountSettingsPage.messages';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function CertificatePreference({
|
const CertificatePreference = ({
|
||||||
intl,
|
|
||||||
fieldName,
|
fieldName,
|
||||||
originalFullName,
|
originalFullName,
|
||||||
originalVerifiedName,
|
originalVerifiedName,
|
||||||
saveState,
|
saveState,
|
||||||
useVerifiedNameForCerts,
|
useVerifiedNameForCerts,
|
||||||
verifiedNameEnabled,
|
}) => {
|
||||||
}) {
|
|
||||||
if (!verifiedNameEnabled || !originalVerifiedName) {
|
|
||||||
// If the user doesn't have an approved verified name, do not display this component
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||||
const formId = 'useVerifiedNameForCerts';
|
const formId = 'useVerifiedNameForCerts';
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
function handleCheckboxChange() {
|
const handleCheckboxChange = () => {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
if (fieldName === 'verified_name') {
|
if (fieldName === 'verified_name') {
|
||||||
dispatch(updateDraft(formId, true));
|
dispatch(updateDraft(formId, true));
|
||||||
@@ -50,22 +44,22 @@ function CertificatePreference({
|
|||||||
} else {
|
} else {
|
||||||
setModalIsOpen(true);
|
setModalIsOpen(true);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleCancel() {
|
const handleCancel = () => {
|
||||||
setModalIsOpen(false);
|
setModalIsOpen(false);
|
||||||
dispatch(resetDrafts());
|
dispatch(resetDrafts());
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleModalChange(e) {
|
const handleModalChange = (e) => {
|
||||||
if (e.target.value === 'fullName') {
|
if (e.target.value === 'fullName') {
|
||||||
dispatch(updateDraft(formId, false));
|
dispatch(updateDraft(formId, false));
|
||||||
} else {
|
} else {
|
||||||
dispatch(updateDraft(formId, true));
|
dispatch(updateDraft(formId, true));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleSubmit(e) {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (saveState === 'pending') {
|
if (saveState === 'pending') {
|
||||||
@@ -73,24 +67,30 @@ function CertificatePreference({
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch(saveSettings(formId, useVerifiedNameForCerts));
|
dispatch(saveSettings(formId, useVerifiedNameForCerts));
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fieldName === 'verified_name') {
|
if (originalVerifiedName) {
|
||||||
setChecked(useVerifiedNameForCerts);
|
if (fieldName === 'verified_name') {
|
||||||
} else {
|
setChecked(useVerifiedNameForCerts);
|
||||||
setChecked(!useVerifiedNameForCerts);
|
} else {
|
||||||
|
setChecked(!useVerifiedNameForCerts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [useVerifiedNameForCerts]);
|
}, [originalVerifiedName, fieldName, useVerifiedNameForCerts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modalIsOpen && saveState === 'complete') {
|
if (originalVerifiedName) {
|
||||||
setModalIsOpen(false);
|
if (modalIsOpen && saveState === 'complete') {
|
||||||
dispatch(closeForm(fieldName));
|
setModalIsOpen(false);
|
||||||
|
dispatch(closeForm(fieldName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [modalIsOpen, saveState]);
|
}, [dispatch, originalVerifiedName, fieldName, modalIsOpen, saveState]);
|
||||||
|
|
||||||
return (
|
// If the user doesn't have an approved verified name, do not display this component
|
||||||
|
|
||||||
|
return originalVerifiedName ? (
|
||||||
<>
|
<>
|
||||||
<Form.Checkbox className="mt-1 mb-4" checked={checked} onChange={handleCheckboxChange}>
|
<Form.Checkbox className="mt-1 mb-4" checked={checked} onChange={handleCheckboxChange}>
|
||||||
{intl.formatMessage(messages['account.settings.field.name.checkbox.certificate.select'])}
|
{intl.formatMessage(messages['account.settings.field.name.checkbox.certificate.select'])}
|
||||||
@@ -151,17 +151,15 @@ function CertificatePreference({
|
|||||||
</Form>
|
</Form>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</>
|
</>
|
||||||
);
|
) : null;
|
||||||
}
|
};
|
||||||
|
|
||||||
CertificatePreference.propTypes = {
|
CertificatePreference.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
fieldName: PropTypes.string.isRequired,
|
fieldName: PropTypes.string.isRequired,
|
||||||
originalFullName: PropTypes.string,
|
originalFullName: PropTypes.string,
|
||||||
originalVerifiedName: PropTypes.string,
|
originalVerifiedName: PropTypes.string,
|
||||||
saveState: PropTypes.string,
|
saveState: PropTypes.string,
|
||||||
useVerifiedNameForCerts: PropTypes.bool,
|
useVerifiedNameForCerts: PropTypes.bool,
|
||||||
verifiedNameEnabled: PropTypes.bool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
CertificatePreference.defaultProps = {
|
CertificatePreference.defaultProps = {
|
||||||
@@ -169,7 +167,6 @@ CertificatePreference.defaultProps = {
|
|||||||
originalVerifiedName: '',
|
originalVerifiedName: '',
|
||||||
saveState: null,
|
saveState: null,
|
||||||
useVerifiedNameForCerts: false,
|
useVerifiedNameForCerts: false,
|
||||||
verifiedNameEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(certPreferenceSelector)(injectIntl(CertificatePreference));
|
export default connect(certPreferenceSelector)(CertificatePreference);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
|
||||||
|
import { postVerifiedNameConfig } from './service';
|
||||||
|
import { handleRequestError } from '../../data/utils';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('../../data/utils');
|
||||||
|
|
||||||
|
describe('postVerifiedNameConfig', () => {
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts verified name config with useVerifiedNameForCerts = true', async () => {
|
||||||
|
const mockResponse = { data: { success: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postVerifiedNameConfig('testuser', { useVerifiedNameForCerts: true });
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalled();
|
||||||
|
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/edx_name_affirmation/v1/verified_name/config',
|
||||||
|
{
|
||||||
|
username: 'testuser',
|
||||||
|
use_verified_name_for_certs: true,
|
||||||
|
},
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts verified name config with useVerifiedNameForCerts = false', async () => {
|
||||||
|
const mockResponse = { data: { success: false } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postVerifiedNameConfig('anotheruser', { useVerifiedNameForCerts: false });
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/edx_name_affirmation/v1/verified_name/config',
|
||||||
|
{
|
||||||
|
username: 'anotheruser',
|
||||||
|
use_verified_name_for_certs: false,
|
||||||
|
},
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleRequestError and throws when request fails', async () => {
|
||||||
|
const mockError = new Error('Request failed');
|
||||||
|
mockPost.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
handleRequestError.mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
postVerifiedNameConfig('erroruser', { useVerifiedNameForCerts: true }),
|
||||||
|
).rejects.toThrow('Request failed');
|
||||||
|
|
||||||
|
expect(handleRequestError).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
|
/* eslint-disable no-import-assign */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import configureStore from 'redux-mock-store';
|
import configureStore from 'redux-mock-store';
|
||||||
import {
|
import {
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { createMemoryHistory } from 'history';
|
|
||||||
|
|
||||||
import * as auth from '@edx/frontend-platform/auth';
|
import * as auth from '@edx/frontend-platform/auth';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import messages from '../messages';
|
||||||
|
|
||||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||||
ReactDOM.createPortal = node => node;
|
jest.mock('react-dom', () => ({
|
||||||
|
...jest.requireActual('react-dom'),
|
||||||
|
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||||
|
}));
|
||||||
|
|
||||||
import CertificatePreference from '../CertificatePreference'; // eslint-disable-line import/first
|
import CertificatePreference from '../CertificatePreference'; // eslint-disable-line import/first
|
||||||
|
|
||||||
@@ -27,10 +30,6 @@ jest.mock('react-redux', () => ({
|
|||||||
jest.mock('@edx/frontend-platform/auth');
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
|
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
|
||||||
|
|
||||||
const history = createMemoryHistory();
|
|
||||||
|
|
||||||
const IntlCertificatePreference = injectIntl(CertificatePreference);
|
|
||||||
|
|
||||||
const mockStore = configureStore();
|
const mockStore = configureStore();
|
||||||
|
|
||||||
describe('NameChange', () => {
|
describe('NameChange', () => {
|
||||||
@@ -38,10 +37,10 @@ describe('NameChange', () => {
|
|||||||
let store = {};
|
let store = {};
|
||||||
const formId = 'useVerifiedNameForCerts';
|
const formId = 'useVerifiedNameForCerts';
|
||||||
const updateDraft = 'UPDATE_DRAFT';
|
const updateDraft = 'UPDATE_DRAFT';
|
||||||
const labelText = 'If checked, this name will appear on your certificates and public-facing records.';
|
const labelText = messages['account.settings.field.name.checkbox.certificate.select'].defaultMessage;
|
||||||
|
|
||||||
const reduxWrapper = children => (
|
const reduxWrapper = children => (
|
||||||
<Router history={history}>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<Provider store={store}>{children}</Provider>
|
<Provider store={store}>{children}</Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
@@ -56,8 +55,6 @@ describe('NameChange', () => {
|
|||||||
originalVerifiedName: 'edX Verified',
|
originalVerifiedName: 'edX Verified',
|
||||||
saveState: null,
|
saveState: null,
|
||||||
useVerifiedNameForCerts: false,
|
useVerifiedNameForCerts: false,
|
||||||
verifiedNameEnabled: true,
|
|
||||||
intl: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
||||||
@@ -77,7 +74,7 @@ describe('NameChange', () => {
|
|||||||
originalVerifiedName: '',
|
originalVerifiedName: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapper = render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
const wrapper = render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
@@ -88,7 +85,7 @@ describe('NameChange', () => {
|
|||||||
useVerifiedNameForCerts: true,
|
useVerifiedNameForCerts: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
expect(checkbox.checked).toEqual(false);
|
expect(checkbox.checked).toEqual(false);
|
||||||
@@ -103,7 +100,7 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('triggers modal when attempting to uncheck checkbox', () => {
|
it('triggers modal when attempting to uncheck checkbox', () => {
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
expect(checkbox.checked).toEqual(true);
|
expect(checkbox.checked).toEqual(true);
|
||||||
@@ -115,7 +112,7 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates draft when changing radio value', () => {
|
it('updates draft when changing radio value', () => {
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
@@ -133,7 +130,7 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clears draft on cancel', () => {
|
it('clears draft on cancel', () => {
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
@@ -146,7 +143,7 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('submits', () => {
|
it('submits', () => {
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
@@ -154,7 +151,7 @@ describe('NameChange', () => {
|
|||||||
const submitButton = screen.getByText('Choose name');
|
const submitButton = screen.getByText('Choose name');
|
||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
expect(mockDispatch).toHaveBeenCalledWith({
|
expect(mockDispatch).toHaveBeenCalledWith({
|
||||||
payload: { formId, commitValues: false },
|
payload: { formId, commitValues: false, extendedProfile: {} },
|
||||||
type: 'ACCOUNT_SETTINGS__SAVE_SETTINGS',
|
type: 'ACCOUNT_SETTINGS__SAVE_SETTINGS',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -166,7 +163,7 @@ describe('NameChange', () => {
|
|||||||
useVerifiedNameForCerts: true,
|
useVerifiedNameForCerts: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
render(reduxWrapper(<IntlCertificatePreference {...props} />));
|
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(labelText);
|
const checkbox = screen.getByLabelText(labelText);
|
||||||
expect(checkbox.checked).toEqual(true);
|
expect(checkbox.checked).toEqual(true);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`NameChange does not render if there is no verified name 1`] = `
|
exports[`NameChange does not render if there is no verified name 1`] = `
|
||||||
Object {
|
{
|
||||||
"asFragment": [Function],
|
"asFragment": [Function],
|
||||||
"baseElement": <body>
|
"baseElement": <body>
|
||||||
<div />
|
<div />
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { getConfig, getQueryParameters } from '@edx/frontend-platform';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Hyperlink } from '@edx/paragon';
|
|
||||||
import { faCheck } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import get from 'lodash.get';
|
|
||||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import PageLoading from '../PageLoading';
|
|
||||||
import CoachingConsentForm from './CoachingConsentForm';
|
|
||||||
import messages from './CoachingConsent.messages';
|
|
||||||
import LogoSVG from '../../logo.svg';
|
|
||||||
import { fetchSettings } from '../data/actions';
|
|
||||||
import { coachingConsentPageSelector } from '../data/selectors';
|
|
||||||
|
|
||||||
const Logo = ({ src, alt, ...attributes }) => (
|
|
||||||
<>
|
|
||||||
<img src={src} alt={alt} {...attributes} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SuccessMessage = props => (
|
|
||||||
<div className="col-12 col-lg-6 shadow-lg mx-auto mt-4 p-5">
|
|
||||||
<FontAwesomeIcon className="text-success" icon={faCheck} size="5x" />
|
|
||||||
<div className="h3">{props.header}</div>
|
|
||||||
<div>{props.message}</div>
|
|
||||||
<Hyperlink destination={props.continueUrl} className="d-block p-2 my-3 text-center text-white bg-primary rounded">
|
|
||||||
{props.continue}
|
|
||||||
</Hyperlink>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const AutoRedirect = (props) => {
|
|
||||||
window.location.href = props.redirectUrl;
|
|
||||||
return <></>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VIEWS = {
|
|
||||||
NOT_LOADED: 'NOT_LOADED',
|
|
||||||
LOADED: 'LOADED',
|
|
||||||
SUCCESS: 'SUCCESS',
|
|
||||||
SUCCESS_PENDING: 'SUCCESS_PENDING',
|
|
||||||
DECLINED: 'DECLINED',
|
|
||||||
DECLINE_PENDING: 'DECLINE_PENDING',
|
|
||||||
};
|
|
||||||
|
|
||||||
class CoachingConsent extends React.Component {
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
// Used to redirect back to the courseware.
|
|
||||||
const nextUrl = this.sanitizeForwardingUrl(getQueryParameters().next);
|
|
||||||
this.state = {
|
|
||||||
redirectUrl: nextUrl || `${getConfig().LMS_BASE_URL}/dashboard/`,
|
|
||||||
formErrors: {},
|
|
||||||
formSubmitted: false,
|
|
||||||
declineSubmitted: false,
|
|
||||||
submissionSuccess: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
this.declineCoaching = this.declineCoaching.bind(this);
|
|
||||||
this.patchUsingCoachingConsentForm = this.patchUsingCoachingConsentForm.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.fetchSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitizeForwardingUrl(url) {
|
|
||||||
// Redirect to root of MFE if invalid next param is sent
|
|
||||||
return url && url.startsWith(getConfig().LMS_BASE_URL) ? url : `${getConfig().LMS_BASE_URL}/dashboard/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async patchUsingCoachingConsentForm(body) {
|
|
||||||
const { userId } = getAuthenticatedUser();
|
|
||||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/coaching/v1/coaching_consent/${userId}/`;
|
|
||||||
let formErrors = {};
|
|
||||||
const data = await getAuthenticatedHttpClient()
|
|
||||||
.patch(requestUrl, body)
|
|
||||||
.catch((error) => {
|
|
||||||
if (get(error, 'customAttributes.httpErrorResponseData')) {
|
|
||||||
formErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
|
|
||||||
} else {
|
|
||||||
formErrors = { full_name: 'Something went wrong. Please try again.' };
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
submissionSuccess: false,
|
|
||||||
formErrors,
|
|
||||||
formSubmitted: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (get(data, 'status') === 200) {
|
|
||||||
this.setState({ submissionSuccess: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const fullName = e.target.fullName.value;
|
|
||||||
const phoneNumber = e.target.phoneNumber.value;
|
|
||||||
const body = {
|
|
||||||
coaching_consent: true,
|
|
||||||
consent_form_seen: true,
|
|
||||||
phone_number: phoneNumber,
|
|
||||||
full_name: fullName,
|
|
||||||
};
|
|
||||||
this.setState({
|
|
||||||
formErrors: {},
|
|
||||||
formSubmitted: true,
|
|
||||||
declineSubmitted: false,
|
|
||||||
}, () => this.patchUsingCoachingConsentForm(body));
|
|
||||||
}
|
|
||||||
|
|
||||||
declineCoaching(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const body = {
|
|
||||||
coaching_consent: false,
|
|
||||||
consent_form_seen: true,
|
|
||||||
};
|
|
||||||
this.setState({
|
|
||||||
formErrors: {},
|
|
||||||
formSubmitted: false,
|
|
||||||
declineSubmitted: true,
|
|
||||||
}, () => this.patchUsingCoachingConsentForm(body));
|
|
||||||
}
|
|
||||||
|
|
||||||
renderView(currentView) {
|
|
||||||
switch (currentView) {
|
|
||||||
case VIEWS.NOT_LOADED:
|
|
||||||
return <PageLoading srMessage="" />;
|
|
||||||
case VIEWS.LOADED:
|
|
||||||
return (
|
|
||||||
<CoachingConsentForm
|
|
||||||
onSubmit={this.handleSubmit}
|
|
||||||
declineCoaching={this.declineCoaching}
|
|
||||||
formErrors={this.state.formErrors}
|
|
||||||
formValues={this.props.formValues}
|
|
||||||
redirectUrl={this.state.redirectUrl}
|
|
||||||
profileDataManager={this.props.profileDataManager}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case VIEWS.SUCCESS_PENDING:
|
|
||||||
return <PageLoading srMessage="Submitting..." />;
|
|
||||||
case VIEWS.SUCCESS:
|
|
||||||
return (
|
|
||||||
<SuccessMessage
|
|
||||||
continueUrl={this.state.redirectUrl}
|
|
||||||
header={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.header'])}
|
|
||||||
message={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.message'])}
|
|
||||||
continue={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.continue'])}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case VIEWS.DECLINE_PENDING:
|
|
||||||
return <PageLoading srMessage="Redirecting..." />;
|
|
||||||
case VIEWS.DECLINED:
|
|
||||||
return <AutoRedirect redirectUrl={this.state.redirectUrl} />;
|
|
||||||
default:
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { loaded } = this.props;
|
|
||||||
const formHasErrors = Object.keys(this.state.formErrors).length > 0;
|
|
||||||
let currentView = null;
|
|
||||||
// This amount of logic was making the template very hard to read, so I broke it out into views.
|
|
||||||
if (!loaded) {
|
|
||||||
currentView = VIEWS.NOT_LOADED;
|
|
||||||
} else if (this.state.formSubmitted && !formHasErrors) {
|
|
||||||
if (this.state.submissionSuccess) {
|
|
||||||
currentView = VIEWS.SUCCESS;
|
|
||||||
} else {
|
|
||||||
currentView = VIEWS.SUCCESS_PENDING;
|
|
||||||
}
|
|
||||||
} else if (this.state.declineSubmitted && !formHasErrors) {
|
|
||||||
if (this.state.submissionSuccess) {
|
|
||||||
currentView = VIEWS.DECLINED;
|
|
||||||
} else {
|
|
||||||
currentView = VIEWS.DECLINE_PENDING;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentView = VIEWS.LOADED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<div className="w-100 d-flex justify-content-center align-items-center shadow coaching-header">
|
|
||||||
<Logo
|
|
||||||
className="logo"
|
|
||||||
src={LogoSVG}
|
|
||||||
alt="Logo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{this.renderView(currentView)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logo.defaultProps = {
|
|
||||||
src: '',
|
|
||||||
alt: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
Logo.propTypes = {
|
|
||||||
src: PropTypes.string,
|
|
||||||
alt: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
SuccessMessage.defaultProps = {
|
|
||||||
header: '',
|
|
||||||
message: '',
|
|
||||||
continueUrl: '',
|
|
||||||
continue: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
SuccessMessage.propTypes = {
|
|
||||||
header: PropTypes.string,
|
|
||||||
message: PropTypes.string,
|
|
||||||
continueUrl: PropTypes.string,
|
|
||||||
continue: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
AutoRedirect.defaultProps = {
|
|
||||||
redirectUrl: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
AutoRedirect.propTypes = {
|
|
||||||
redirectUrl: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
CoachingConsent.defaultProps = {
|
|
||||||
loaded: false,
|
|
||||||
profileDataManager: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
CoachingConsent.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
loaded: PropTypes.bool,
|
|
||||||
formValues: PropTypes.shape({
|
|
||||||
name: PropTypes.string,
|
|
||||||
phone_number: PropTypes.string,
|
|
||||||
coaching: PropTypes.shape({
|
|
||||||
coaching_consent: PropTypes.bool.isRequired,
|
|
||||||
user: PropTypes.number.isRequired,
|
|
||||||
eligible_for_coaching: PropTypes.bool.isRequired,
|
|
||||||
consent_form_seen: PropTypes.bool.isRequired,
|
|
||||||
}),
|
|
||||||
}).isRequired,
|
|
||||||
formErrors: PropTypes.shape({
|
|
||||||
coaching: PropTypes.object,
|
|
||||||
}).isRequired,
|
|
||||||
confirmationValues: PropTypes.shape({
|
|
||||||
coaching: PropTypes.object,
|
|
||||||
name: PropTypes.object,
|
|
||||||
phone_number: PropTypes.object,
|
|
||||||
}).isRequired,
|
|
||||||
fetchSettings: PropTypes.func.isRequired,
|
|
||||||
profileDataManager: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(coachingConsentPageSelector, {
|
|
||||||
fetchSettings,
|
|
||||||
})(injectIntl(CoachingConsent));
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
'account.settings.coaching.consent.welcome.header': {
|
|
||||||
id: 'account.settings.coaching.consent.welcome.header',
|
|
||||||
defaultMessage: 'Let’s get started.',
|
|
||||||
description: 'The welcome header for consent form.',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.welcome.subheader': {
|
|
||||||
id: 'account.settings.coaching.consent.welcome.subheader',
|
|
||||||
defaultMessage: "We're here for you from start to finish",
|
|
||||||
description: 'The welcome subheader for consent form.',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.description': {
|
|
||||||
id: 'account.settings.coaching.consent.description',
|
|
||||||
defaultMessage: "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If you’re interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
|
|
||||||
description: 'Text describing what Coaching is.',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.text-messaging.disclaimer': {
|
|
||||||
id: 'account.settings.coaching.consent.text-messaging.disclaimer',
|
|
||||||
defaultMessage: '* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.',
|
|
||||||
description: 'Text describing what Coaching is.',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.accept-coaching': {
|
|
||||||
id: 'account.settings.coaching.consent.accept-coaching',
|
|
||||||
defaultMessage: 'Sign up for coaching',
|
|
||||||
description: 'Text to confirm coaching enablement',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.decline-coaching': {
|
|
||||||
id: 'account.settings.coaching.consent.decline-coaching',
|
|
||||||
defaultMessage: 'I prefer not to be contacted with free coaching services',
|
|
||||||
description: 'Text to decline coaching enablement',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.label.name': {
|
|
||||||
id: 'account.settings.coaching.consent.label.name',
|
|
||||||
defaultMessage: 'Please confirm your name',
|
|
||||||
description: 'Label for name input',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.label.phone-number': {
|
|
||||||
id: 'account.settings.coaching.consent.label.phone-number',
|
|
||||||
defaultMessage: 'Enter your mobile number',
|
|
||||||
description: 'Label for mobile phone number input',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.success.header': {
|
|
||||||
id: 'account.settings.coaching.consent.success.header',
|
|
||||||
defaultMessage: 'Success!',
|
|
||||||
description: 'Heading announcing that submission succeeded',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.success.message': {
|
|
||||||
id: 'account.settings.coaching.consent.success.message',
|
|
||||||
defaultMessage: "You're signed up for coaching. You can expect a message via email or SMS in the coming days.",
|
|
||||||
description: 'Text announcing that you have signed up and will receive texts',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.consent.success.continue': {
|
|
||||||
id: 'account.settings.coaching.consent.success.continue',
|
|
||||||
defaultMessage: 'Start my course',
|
|
||||||
description: 'Text that the user will be sent back to the courseware',
|
|
||||||
},
|
|
||||||
'account.settings.coaching.managed.support': {
|
|
||||||
id: 'account.settings.coaching.managed.support',
|
|
||||||
defaultMessage: 'support',
|
|
||||||
description: 'website support',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Input, Button, Hyperlink } from '@edx/paragon';
|
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import Alert from '../Alert';
|
|
||||||
import messages from './CoachingConsent.messages';
|
|
||||||
|
|
||||||
const ErrorMessage = props => (
|
|
||||||
<div className="alert-warning mb-2">{props.message}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ManagedProfileAlert = ({ profileDataManager }) => (
|
|
||||||
<Alert className="alert alert-primary" role="alert">
|
|
||||||
<FormattedMessage
|
|
||||||
id="account.settings.coaching.managed.alert"
|
|
||||||
defaultMessage="Your name is managed by {managerTitle}. Contact your administrator for help."
|
|
||||||
description="alert message informing the user their account data is managed by a third party"
|
|
||||||
values={{
|
|
||||||
managerTitle: <b>{profileDataManager}</b>,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
const CoachingForm = props => (
|
|
||||||
<div className="col-12 col-md-6 col-xl-5 mx-auto mt-4 p-5 shadow-lg">
|
|
||||||
<h2 className="h2">
|
|
||||||
{props.intl.formatMessage(messages['account.settings.coaching.consent.welcome.header'])}
|
|
||||||
</h2>
|
|
||||||
<p>{props.intl.formatMessage(messages['account.settings.coaching.consent.description'])}</p>
|
|
||||||
<div>
|
|
||||||
<form onSubmit={props.onSubmit}>
|
|
||||||
<div className="py-3">
|
|
||||||
{!!props.profileDataManager && (
|
|
||||||
<ManagedProfileAlert profileDataManager={props.profileDataManager} />
|
|
||||||
)}
|
|
||||||
<ErrorMessage message={props.formErrors.full_name} />
|
|
||||||
<label className="h6" htmlFor="fullName">
|
|
||||||
{props.intl.formatMessage(messages['account.settings.coaching.consent.label.name'])}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="full-name"
|
|
||||||
id="fullName"
|
|
||||||
disabled={!!props.profileDataManager}
|
|
||||||
defaultValue={props.formValues.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="py-3">
|
|
||||||
<ErrorMessage message={props.formErrors.phone_number} />
|
|
||||||
<label className="h6" htmlFor="phoneNumber">
|
|
||||||
{props.intl.formatMessage(messages['account.settings.coaching.consent.label.phone-number'])}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="phone_number"
|
|
||||||
id="phoneNumber"
|
|
||||||
defaultValue={props.formValues.phone_number}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className=" py-3">
|
|
||||||
<p className="small font-italic">
|
|
||||||
{props.intl.formatMessage(messages['account.settings.coaching.consent.text-messaging.disclaimer'])}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ErrorMessage message={props.formErrors.coaching} />
|
|
||||||
<div className="d-flex flex-column align-items-center">
|
|
||||||
<Button variant="outline-primary" className="w-100" type="submit">
|
|
||||||
{props.intl.formatMessage(messages['account.settings.coaching.consent.accept-coaching'])}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<Hyperlink
|
|
||||||
className="mt-3 text-dark btn-link small"
|
|
||||||
destination={props.redirectUrl}
|
|
||||||
onClick={props.declineCoaching}
|
|
||||||
>
|
|
||||||
{props.intl.formatMessage(messages['account.settings.coaching.consent.decline-coaching'])}
|
|
||||||
</Hyperlink>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
CoachingForm.defaultProps = {
|
|
||||||
formErrors: {
|
|
||||||
coaching: '',
|
|
||||||
name: '',
|
|
||||||
phone_number: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
CoachingForm.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
onSubmit: PropTypes.func.isRequired,
|
|
||||||
declineCoaching: PropTypes.func.isRequired,
|
|
||||||
formValues: PropTypes.shape({
|
|
||||||
name: PropTypes.string,
|
|
||||||
phone_number: PropTypes.string,
|
|
||||||
coaching: PropTypes.shape({
|
|
||||||
coaching_consent: PropTypes.bool.isRequired,
|
|
||||||
user: PropTypes.number.isRequired,
|
|
||||||
eligible_for_coaching: PropTypes.bool.isRequired,
|
|
||||||
consent_form_seen: PropTypes.bool.isRequired,
|
|
||||||
}),
|
|
||||||
}).isRequired,
|
|
||||||
formErrors: PropTypes.shape({
|
|
||||||
coaching: PropTypes.string,
|
|
||||||
full_name: PropTypes.string,
|
|
||||||
phone_number: PropTypes.string,
|
|
||||||
}),
|
|
||||||
redirectUrl: PropTypes.string.isRequired,
|
|
||||||
profileDataManager: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
ErrorMessage.defaultProps = {
|
|
||||||
message: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
ErrorMessage.propTypes = {
|
|
||||||
message: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
ManagedProfileAlert.propTypes = {
|
|
||||||
profileDataManager: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(CoachingForm);
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { ValidationFormGroup, Input } from '@edx/paragon';
|
|
||||||
import messages from './CoachingToggle.messages';
|
|
||||||
import { editableFieldSelector } from '../data/selectors';
|
|
||||||
import { saveSettings, updateDraft, saveMultipleSettings } from '../data/actions';
|
|
||||||
import EditableField from '../EditableField';
|
|
||||||
|
|
||||||
const CoachingToggle = props => (
|
|
||||||
<>
|
|
||||||
<EditableField
|
|
||||||
name="phone_number"
|
|
||||||
type="text"
|
|
||||||
value={props.phone_number}
|
|
||||||
label={props.intl.formatMessage(messages['account.settings.field.phone_number'])}
|
|
||||||
emptyLabel={props.intl.formatMessage(messages['account.settings.field.phone_number.empty'])}
|
|
||||||
onChange={props.updateDraft}
|
|
||||||
onSubmit={() => {
|
|
||||||
const { coaching } = props;
|
|
||||||
if (coaching.coaching_consent === true) {
|
|
||||||
return props.saveMultipleSettings([
|
|
||||||
{
|
|
||||||
formId: 'coaching',
|
|
||||||
commitValues: {
|
|
||||||
...coaching,
|
|
||||||
phone_number: props.phone_number,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
formId: 'phone_number',
|
|
||||||
commitValues: props.phone_number,
|
|
||||||
},
|
|
||||||
], 'phone_number');
|
|
||||||
}
|
|
||||||
return props.saveSettings('phone_number', props.phone_number);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ValidationFormGroup
|
|
||||||
for="coachingConsent"
|
|
||||||
helpText={props.intl.formatMessage(messages['account.settings.field.coaching_consent.tooltip'])}
|
|
||||||
invalid={!!props.error}
|
|
||||||
invalidMessage={props.intl.formatMessage(messages['account.settings.field.coaching_consent.error'])}
|
|
||||||
className="custom-control custom-switch"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name={props.name}
|
|
||||||
className="custom-control-input"
|
|
||||||
disabled={props.saveState === 'pending'}
|
|
||||||
type="checkbox"
|
|
||||||
id="coachingConsent"
|
|
||||||
checked={props.coaching.coaching_consent}
|
|
||||||
value={props.coaching.coaching_consent}
|
|
||||||
onChange={async (e) => {
|
|
||||||
const { name } = e.target;
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
const { user, eligible_for_coaching } = props.coaching;
|
|
||||||
const value = {
|
|
||||||
user,
|
|
||||||
eligible_for_coaching,
|
|
||||||
coaching_consent: e.target.checked,
|
|
||||||
};
|
|
||||||
props.saveSettings(name, value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label className="custom-control-label" htmlFor="coachingConsent">{props.intl.formatMessage(messages['account.settings.field.coaching_consent'])}</label>
|
|
||||||
</ValidationFormGroup>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
CoachingToggle.defaultProps = {
|
|
||||||
phone_number: '',
|
|
||||||
error: '',
|
|
||||||
saveState: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
CoachingToggle.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
error: PropTypes.string,
|
|
||||||
coaching: PropTypes.shape({
|
|
||||||
coaching_consent: PropTypes.bool.isRequired,
|
|
||||||
user: PropTypes.number.isRequired,
|
|
||||||
eligible_for_coaching: PropTypes.bool.isRequired,
|
|
||||||
}).isRequired,
|
|
||||||
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
|
||||||
saveSettings: PropTypes.func.isRequired,
|
|
||||||
saveMultipleSettings: PropTypes.func.isRequired,
|
|
||||||
updateDraft: PropTypes.func.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
phone_number: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(editableFieldSelector, {
|
|
||||||
saveSettings,
|
|
||||||
updateDraft,
|
|
||||||
saveMultipleSettings,
|
|
||||||
})(injectIntl(CoachingToggle));
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
'account.settings.field.phone_number': {
|
|
||||||
id: 'account.settings.field.phone_number',
|
|
||||||
defaultMessage: 'Phone Number',
|
|
||||||
description: 'The label for a phone numbers setting in the user profile',
|
|
||||||
},
|
|
||||||
'account.settings.field.phone_number.empty': {
|
|
||||||
id: 'account.settings.field.phone_number.empty',
|
|
||||||
defaultMessage: 'Add a phone number',
|
|
||||||
description: 'placeholder for a profiles empty phone number field',
|
|
||||||
},
|
|
||||||
'account.settings.field.coaching_consent': {
|
|
||||||
id: 'account.settings.field.coaching_consent',
|
|
||||||
defaultMessage: 'Coaching consent',
|
|
||||||
description: 'The label for the coaching consent setting in the user profile',
|
|
||||||
},
|
|
||||||
'account.settings.field.coaching_consent.tooltip': {
|
|
||||||
id: 'account.settings.field.coaching_consent.tooltip',
|
|
||||||
defaultMessage: 'MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available to learners with U.S. mobile phone numbers. Standard messaging rates apply. Text ‘STOP’ at anytime to opt-out of messages.',
|
|
||||||
description: 'A tooltip explaining what coaching is and who it is for',
|
|
||||||
},
|
|
||||||
'account.settings.field.coaching_consent.error': {
|
|
||||||
id: 'account.settings.field.coaching_consent.error',
|
|
||||||
defaultMessage: 'A valid US phone number is required to opt into coaching',
|
|
||||||
description: 'An error message that displays when a user attempts to consent to coaching without first providing a phone number in their profile',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import get from 'lodash.get';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get all settings related to the coaching plugin. Settings used
|
|
||||||
* by Microbachelors students.
|
|
||||||
* @param {Number} userId users are identified in the api by LMS id
|
|
||||||
*/
|
|
||||||
export async function getCoachingPreferences(userId) {
|
|
||||||
let data = {};
|
|
||||||
try {
|
|
||||||
({ data } = await getAuthenticatedHttpClient()
|
|
||||||
.get(`${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`));
|
|
||||||
} catch (error) {
|
|
||||||
// If a user isn't active the API call will fail with a lack of credentials.
|
|
||||||
data = {
|
|
||||||
coaching_consent: false,
|
|
||||||
user: userId,
|
|
||||||
eligible_for_coaching: false,
|
|
||||||
consent_form_seen: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* patch all of the settings related to coaching.
|
|
||||||
* @param {Number} userId users are identified in the api by LMS id
|
|
||||||
* @param {Object} commitValues { coaching }
|
|
||||||
*/
|
|
||||||
export async function patchCoachingPreferences(userId, commitValues) {
|
|
||||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`;
|
|
||||||
const { coaching } = commitValues;
|
|
||||||
coaching.user = userId;
|
|
||||||
|
|
||||||
await getAuthenticatedHttpClient()
|
|
||||||
.patch(requestUrl, coaching)
|
|
||||||
.catch((error) => {
|
|
||||||
const apiError = Object.create(error);
|
|
||||||
apiError.fieldErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
|
|
||||||
if (get(apiError, 'fieldErrors.phone_number')) {
|
|
||||||
// eslint-disable-next-line prefer-destructuring
|
|
||||||
apiError.fieldErrors.coaching = apiError.fieldErrors.phone_number[0];
|
|
||||||
delete apiError.fieldErrors.phone_number;
|
|
||||||
}
|
|
||||||
throw apiError;
|
|
||||||
});
|
|
||||||
return commitValues;
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import renderer from 'react-test-renderer';
|
|
||||||
import { act } from 'react-dom/test-utils';
|
|
||||||
import configureStore from 'redux-mock-store';
|
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import * as auth from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import CoachingConsent from '../CoachingConsent';
|
|
||||||
import * as selectors from '../../data/selectors';
|
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/auth');
|
|
||||||
|
|
||||||
const IntlCoachingConsent = injectIntl(CoachingConsent);
|
|
||||||
|
|
||||||
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ coachingConsentPageSelector: () => ({}) })));
|
|
||||||
|
|
||||||
const mockStore = configureStore();
|
|
||||||
|
|
||||||
describe('CoachingConsent', () => {
|
|
||||||
let props = {};
|
|
||||||
let store = {};
|
|
||||||
selectors.mockClear();
|
|
||||||
|
|
||||||
const reduxWrapper = children => (
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<Provider store={store}>{children}</Provider>
|
|
||||||
</IntlProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = mockStore();
|
|
||||||
props = {
|
|
||||||
fetchSettings: jest.fn(),
|
|
||||||
loaded: true,
|
|
||||||
saveState: undefined,
|
|
||||||
formValues: {
|
|
||||||
name: 'edx edx',
|
|
||||||
phone_number: '1234567890',
|
|
||||||
coaching: {
|
|
||||||
coaching_consent: true,
|
|
||||||
consent_form_seen: false,
|
|
||||||
eligible_for_coaching: true,
|
|
||||||
user: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
formErrors: {},
|
|
||||||
confirmationValues: {},
|
|
||||||
profileDataManager: '',
|
|
||||||
intl: {},
|
|
||||||
};
|
|
||||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
|
||||||
patch: async () => ({
|
|
||||||
data: { status: 200 },
|
|
||||||
catch: () => {},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3 }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render', () => {
|
|
||||||
const wrapper = renderer.create(reduxWrapper(<IntlCoachingConsent {...props} />)).toJSON();
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables name field on enterprise user', () => {
|
|
||||||
props = {
|
|
||||||
...props,
|
|
||||||
profileDataManager: 'test person',
|
|
||||||
};
|
|
||||||
const wrapper = renderer.create(reduxWrapper(<IntlCoachingConsent {...props} />)).toJSON();
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('display completed box when successfully submitted', async () => {
|
|
||||||
const fakeEvent = {
|
|
||||||
preventDefault: () => {},
|
|
||||||
target: {
|
|
||||||
fullName: { value: 'edx edx' },
|
|
||||||
phoneNumber: { value: '9783028731' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const wrapper = renderer.create(
|
|
||||||
reduxWrapper(<IntlCoachingConsent {...props} />),
|
|
||||||
{
|
|
||||||
// bypass the forward-ref. we don't care about focus for this one test
|
|
||||||
createNodeMock: (element) => {
|
|
||||||
if (element.type === 'button') {
|
|
||||||
// mock a focus function
|
|
||||||
return {
|
|
||||||
focus: async () => wrapper.root.findByType('form').props.onSubmit(fakeEvent),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const form = wrapper.root.findByType('form');
|
|
||||||
await act(async () => { await form.props.onSubmit(fakeEvent); });
|
|
||||||
expect(wrapper.toJSON()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`CoachingConsent disables name field on enterprise user 1`] = `
|
|
||||||
<main>
|
|
||||||
<div
|
|
||||||
className="w-100 d-flex justify-content-center align-items-center shadow coaching-header"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Logo"
|
|
||||||
className="logo"
|
|
||||||
src="icon/mock/path"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="col-12 col-md-6 col-xl-5 mx-auto mt-4 p-5 shadow-lg"
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
className="h2"
|
|
||||||
>
|
|
||||||
Let’s get started.
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If you’re interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<form
|
|
||||||
onSubmit={[Function]}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="py-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="alert d-flex align-items-start alert alert-primary"
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
<div>
|
|
||||||
<span>
|
|
||||||
Your name is managed by
|
|
||||||
<b>
|
|
||||||
test person
|
|
||||||
</b>
|
|
||||||
. Contact your administrator for help.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="alert-warning mb-2"
|
|
||||||
>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
className="h6"
|
|
||||||
htmlFor="fullName"
|
|
||||||
>
|
|
||||||
Please confirm your name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
defaultValue="edx edx"
|
|
||||||
disabled={true}
|
|
||||||
id="fullName"
|
|
||||||
name="full-name"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="py-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="alert-warning mb-2"
|
|
||||||
>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
className="h6"
|
|
||||||
htmlFor="phoneNumber"
|
|
||||||
>
|
|
||||||
Enter your mobile number
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
defaultValue="1234567890"
|
|
||||||
id="phoneNumber"
|
|
||||||
name="phone_number"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className=" py-3"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="small font-italic"
|
|
||||||
>
|
|
||||||
* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="alert-warning mb-2"
|
|
||||||
>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="d-flex flex-column align-items-center"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="w-100 btn btn-outline-primary"
|
|
||||||
disabled={false}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Sign up for coaching
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mt-3"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
className="mt-3 text-dark btn-link small"
|
|
||||||
href="http://localhost:18000/dashboard/"
|
|
||||||
onClick={[Function]}
|
|
||||||
target="_self"
|
|
||||||
>
|
|
||||||
I prefer not to be contacted with free coaching services
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`CoachingConsent display completed box when successfully submitted 1`] = `
|
|
||||||
<main>
|
|
||||||
<div
|
|
||||||
className="w-100 d-flex justify-content-center align-items-center shadow coaching-header"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Logo"
|
|
||||||
className="logo"
|
|
||||||
src="icon/mock/path"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="d-flex justify-content-center align-items-center flex-column"
|
|
||||||
style={
|
|
||||||
Object {
|
|
||||||
"height": "50vh",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="spinner-border text-primary"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="sr-only"
|
|
||||||
>
|
|
||||||
Submitting...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`CoachingConsent should render 1`] = `
|
|
||||||
<main>
|
|
||||||
<div
|
|
||||||
className="w-100 d-flex justify-content-center align-items-center shadow coaching-header"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Logo"
|
|
||||||
className="logo"
|
|
||||||
src="icon/mock/path"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="col-12 col-md-6 col-xl-5 mx-auto mt-4 p-5 shadow-lg"
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
className="h2"
|
|
||||||
>
|
|
||||||
Let’s get started.
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If you’re interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<form
|
|
||||||
onSubmit={[Function]}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="py-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="alert-warning mb-2"
|
|
||||||
>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
className="h6"
|
|
||||||
htmlFor="fullName"
|
|
||||||
>
|
|
||||||
Please confirm your name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
defaultValue="edx edx"
|
|
||||||
disabled={false}
|
|
||||||
id="fullName"
|
|
||||||
name="full-name"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="py-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="alert-warning mb-2"
|
|
||||||
>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
className="h6"
|
|
||||||
htmlFor="phoneNumber"
|
|
||||||
>
|
|
||||||
Enter your mobile number
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
defaultValue="1234567890"
|
|
||||||
id="phoneNumber"
|
|
||||||
name="phone_number"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className=" py-3"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="small font-italic"
|
|
||||||
>
|
|
||||||
* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="alert-warning mb-2"
|
|
||||||
>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="d-flex flex-column align-items-center"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="w-100 btn btn-outline-primary"
|
|
||||||
disabled={false}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Sign up for coaching
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mt-3"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
className="mt-3 text-dark btn-link small"
|
|
||||||
href="http://localhost:18000/dashboard/"
|
|
||||||
onClick={[Function]}
|
|
||||||
target="_self"
|
|
||||||
>
|
|
||||||
I prefer not to be contacted with free coaching services
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
`;
|
|
||||||
@@ -27,6 +27,7 @@ export const fetchSettingsSuccess = ({
|
|||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
|
countriesCodesList,
|
||||||
}) => ({
|
}) => ({
|
||||||
type: FETCH_SETTINGS.SUCCESS,
|
type: FETCH_SETTINGS.SUCCESS,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -35,6 +36,7 @@ export const fetchSettingsSuccess = ({
|
|||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
|
countriesCodesList,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,9 +79,9 @@ export const beginNameChange = (formId) => ({
|
|||||||
});
|
});
|
||||||
// SAVE SETTINGS ACTIONS
|
// SAVE SETTINGS ACTIONS
|
||||||
|
|
||||||
export const saveSettings = (formId, commitValues) => ({
|
export const saveSettings = (formId, commitValues, extendedProfile = {}) => ({
|
||||||
type: SAVE_SETTINGS.BASE,
|
type: SAVE_SETTINGS.BASE,
|
||||||
payload: { formId, commitValues },
|
payload: { formId, commitValues, extendedProfile },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saveSettingsBegin = () => ({
|
export const saveSettingsBegin = () => ({
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export const YEAR_OF_BIRTH_OPTIONS = (() => {
|
|||||||
return years.reverse();
|
return years.reverse();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
export const COPPA_COMPLIANCE_YEAR = (() => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
return currentYear - 13;
|
||||||
|
})();
|
||||||
|
|
||||||
export const EDUCATION_LEVELS = [
|
export const EDUCATION_LEVELS = [
|
||||||
'',
|
'',
|
||||||
'p',
|
'p',
|
||||||
@@ -20,7 +25,7 @@ export const EDUCATION_LEVELS = [
|
|||||||
'jhs',
|
'jhs',
|
||||||
'el',
|
'el',
|
||||||
'none',
|
'none',
|
||||||
'o',
|
'other',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GENDER_OPTIONS = [
|
export const GENDER_OPTIONS = [
|
||||||
@@ -29,6 +34,21 @@ export const GENDER_OPTIONS = [
|
|||||||
'm',
|
'm',
|
||||||
'o',
|
'o',
|
||||||
];
|
];
|
||||||
|
export const WORK_EXPERIENCE_OPTIONS = [
|
||||||
|
'',
|
||||||
|
'0',
|
||||||
|
'1',
|
||||||
|
'2',
|
||||||
|
'3',
|
||||||
|
'4',
|
||||||
|
'5',
|
||||||
|
'6',
|
||||||
|
'7',
|
||||||
|
'8',
|
||||||
|
'9',
|
||||||
|
'10+',
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
export const COUNTRY_WITH_STATES = 'US';
|
export const COUNTRY_WITH_STATES = 'US';
|
||||||
|
|
||||||
@@ -112,6 +132,6 @@ export function getStatesList(country) {
|
|||||||
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
|
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DECLINED = 'declined';
|
export const FIELD_LABELS = {
|
||||||
export const SELF_DESCRIBE = 'self-describe';
|
COUNTRY: 'country',
|
||||||
export const OTHER = 'other';
|
};
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ export const defaultState = {
|
|||||||
verifiedName: null,
|
verifiedName: null,
|
||||||
mostRecentVerifiedName: {},
|
mostRecentVerifiedName: {},
|
||||||
verifiedNameHistory: {},
|
verifiedNameHistory: {},
|
||||||
verifiedNameEnabled: false,
|
countriesCodesList: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const reducer = (state = defaultState, action) => {
|
const reducer = (state = defaultState, action = {}) => {
|
||||||
let dispatcherIsOpenForm;
|
let dispatcherIsOpenForm;
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
@@ -65,6 +65,7 @@ const reducer = (state = defaultState, action) => {
|
|||||||
loaded: true,
|
loaded: true,
|
||||||
loadingError: null,
|
loadingError: null,
|
||||||
verifiedNameHistory: action.payload.verifiedNameHistory,
|
verifiedNameHistory: action.payload.verifiedNameHistory,
|
||||||
|
countriesCodesList: action.payload.countriesCodesList,
|
||||||
};
|
};
|
||||||
case FETCH_SETTINGS.FAILURE:
|
case FETCH_SETTINGS.FAILURE:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function* handleFetchSettings() {
|
|||||||
const { username, userId, roles: userRoles } = getAuthenticatedUser();
|
const { username, userId, roles: userRoles } = getAuthenticatedUser();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
|
thirdPartyAuthProviders, profileDataManager, timeZones, countries, ...values
|
||||||
} = yield call(
|
} = yield call(
|
||||||
getSettings,
|
getSettings,
|
||||||
username,
|
username,
|
||||||
@@ -71,6 +71,7 @@ export function* handleFetchSettings() {
|
|||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
|
countriesCodesList: countries,
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
yield put(fetchSettingsFailure(e.message));
|
yield put(fetchSettingsFailure(e.message));
|
||||||
@@ -83,8 +84,8 @@ export function* handleSaveSettings(action) {
|
|||||||
yield put(saveSettingsBegin());
|
yield put(saveSettingsBegin());
|
||||||
|
|
||||||
const { username, userId } = getAuthenticatedUser();
|
const { username, userId } = getAuthenticatedUser();
|
||||||
const { commitValues, formId } = action.payload;
|
const { commitValues, formId, extendedProfile } = action.payload;
|
||||||
const commitData = { [formId]: commitValues };
|
const commitData = Object.keys(extendedProfile).length > 0 ? extendedProfile : { [formId]: commitValues };
|
||||||
let savedValues = null;
|
let savedValues = null;
|
||||||
if (formId === 'siteLanguage') {
|
if (formId === 'siteLanguage') {
|
||||||
const previousSiteLanguage = getLocale();
|
const previousSiteLanguage = getLocale();
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const verifiedNameSettingsSelector = createSelector(
|
|||||||
accountSettingsSelector,
|
accountSettingsSelector,
|
||||||
accountSettings => ({
|
accountSettings => ({
|
||||||
history: accountSettings.verifiedNameHistory.results,
|
history: accountSettings.verifiedNameHistory.results,
|
||||||
verifiedNameEnabled: accountSettings?.verifiedNameHistory.verified_name_enabled,
|
|
||||||
useVerifiedNameForCerts: accountSettings?.verifiedNameHistory.use_verified_name_for_certs,
|
useVerifiedNameForCerts: accountSettings?.verifiedNameHistory.use_verified_name_for_certs,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -89,6 +88,11 @@ const previousSiteLanguageSelector = createSelector(
|
|||||||
accountSettings => accountSettings.previousSiteLanguage,
|
accountSettings => accountSettings.previousSiteLanguage,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const countriesSelector = createSelector(
|
||||||
|
accountSettingsSelector,
|
||||||
|
accountSettings => accountSettings.countriesCodesList,
|
||||||
|
);
|
||||||
|
|
||||||
const editableFieldErrorSelector = createSelector(
|
const editableFieldErrorSelector = createSelector(
|
||||||
editableFieldNameSelector,
|
editableFieldNameSelector,
|
||||||
accountSettingsSelector,
|
accountSettingsSelector,
|
||||||
@@ -107,11 +111,6 @@ const isEditingSelector = createSelector(
|
|||||||
(name, accountSettings) => accountSettings.openFormId === name,
|
(name, accountSettings) => accountSettings.openFormId === name,
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmationValuesSelector = createSelector(
|
|
||||||
accountSettingsSelector,
|
|
||||||
accountSettings => accountSettings.confirmationValues,
|
|
||||||
);
|
|
||||||
|
|
||||||
const errorSelector = createSelector(
|
const errorSelector = createSelector(
|
||||||
accountSettingsSelector,
|
accountSettingsSelector,
|
||||||
accountSettings => accountSettings.errors,
|
accountSettings => accountSettings.errors,
|
||||||
@@ -162,7 +161,7 @@ function chooseFormValue(draft, committed) {
|
|||||||
return draft !== undefined ? draft : committed;
|
return draft !== undefined ? draft : committed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formValuesSelector = createSelector(
|
export const formValuesSelector = createSelector(
|
||||||
valuesSelector,
|
valuesSelector,
|
||||||
draftsSelector,
|
draftsSelector,
|
||||||
(values, drafts) => {
|
(values, drafts) => {
|
||||||
@@ -170,6 +169,20 @@ const formValuesSelector = createSelector(
|
|||||||
Object.entries(values).forEach(([name, value]) => {
|
Object.entries(values).forEach(([name, value]) => {
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
formValues[name] = chooseFormValue(drafts[name], value);
|
formValues[name] = chooseFormValue(drafts[name], value);
|
||||||
|
} else if (typeof value === 'object' && name === 'extended_profile' && value !== null) {
|
||||||
|
const extendedProfile = value.slice();
|
||||||
|
const draftsKeys = Object.keys(drafts);
|
||||||
|
|
||||||
|
if (draftsKeys.length !== 0) {
|
||||||
|
const draftFieldName = draftsKeys[0];
|
||||||
|
const index = extendedProfile.findIndex((profile) => profile.field_name === draftFieldName);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
extendedProfile[index] = { field_name: draftFieldName, field_value: drafts[draftFieldName] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formValues.extended_profile = [...extendedProfile];
|
||||||
} else {
|
} else {
|
||||||
formValues[name] = chooseFormValue(drafts[name], value) || '';
|
formValues[name] = chooseFormValue(drafts[name], value) || '';
|
||||||
}
|
}
|
||||||
@@ -180,7 +193,7 @@ const formValuesSelector = createSelector(
|
|||||||
|
|
||||||
const transformTimeZonesToOptions = timeZoneArr => timeZoneArr
|
const transformTimeZonesToOptions = timeZoneArr => timeZoneArr
|
||||||
.map(({ time_zone, description }) => ({ // eslint-disable-line camelcase
|
.map(({ time_zone, description }) => ({ // eslint-disable-line camelcase
|
||||||
value: time_zone, label: description,
|
value: time_zone, label: description, // eslint-disable-line camelcase
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const timeZonesSelector = createSelector(
|
const timeZonesSelector = createSelector(
|
||||||
@@ -229,7 +242,7 @@ export const accountSettingsPageSelector = createSelector(
|
|||||||
mostRecentApprovedVerifiedNameValueSelector,
|
mostRecentApprovedVerifiedNameValueSelector,
|
||||||
mostRecentVerifiedNameSelector,
|
mostRecentVerifiedNameSelector,
|
||||||
sortedVerifiedNameHistorySelector,
|
sortedVerifiedNameHistorySelector,
|
||||||
verifiedNameSettingsSelector,
|
countriesSelector,
|
||||||
(
|
(
|
||||||
accountSettings,
|
accountSettings,
|
||||||
siteLanguageOptions,
|
siteLanguageOptions,
|
||||||
@@ -247,7 +260,7 @@ export const accountSettingsPageSelector = createSelector(
|
|||||||
verifiedName,
|
verifiedName,
|
||||||
mostRecentVerifiedName,
|
mostRecentVerifiedName,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
verifiedNameSettings,
|
countriesCodesList,
|
||||||
) => ({
|
) => ({
|
||||||
siteLanguageOptions,
|
siteLanguageOptions,
|
||||||
siteLanguage,
|
siteLanguage,
|
||||||
@@ -268,19 +281,17 @@ export const accountSettingsPageSelector = createSelector(
|
|||||||
verifiedName,
|
verifiedName,
|
||||||
mostRecentVerifiedName,
|
mostRecentVerifiedName,
|
||||||
verifiedNameHistory,
|
verifiedNameHistory,
|
||||||
verifiedNameEnabled: verifiedNameSettings?.verifiedNameEnabled,
|
countriesCodesList,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const certPreferenceSelector = createSelector(
|
export const certPreferenceSelector = createSelector(
|
||||||
verifiedNameSettingsSelector,
|
|
||||||
valuesSelector,
|
valuesSelector,
|
||||||
formValuesSelector,
|
formValuesSelector,
|
||||||
mostRecentApprovedVerifiedNameValueSelector,
|
mostRecentApprovedVerifiedNameValueSelector,
|
||||||
saveStateSelector,
|
saveStateSelector,
|
||||||
errorSelector,
|
errorSelector,
|
||||||
(
|
(
|
||||||
verifiedNameSettings,
|
|
||||||
committedValues,
|
committedValues,
|
||||||
formValues,
|
formValues,
|
||||||
mostRecentApprovedVerifiedNameValue,
|
mostRecentApprovedVerifiedNameValue,
|
||||||
@@ -291,51 +302,6 @@ export const certPreferenceSelector = createSelector(
|
|||||||
originalVerifiedName: mostRecentApprovedVerifiedNameValue?.verified_name || '',
|
originalVerifiedName: mostRecentApprovedVerifiedNameValue?.verified_name || '',
|
||||||
useVerifiedNameForCerts: formValues.useVerifiedNameForCerts || false,
|
useVerifiedNameForCerts: formValues.useVerifiedNameForCerts || false,
|
||||||
saveState,
|
saveState,
|
||||||
verifiedNameEnabled: verifiedNameSettings.verifiedNameEnabled || false,
|
|
||||||
formErrors: errors,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const coachingConsentPageSelector = createSelector(
|
|
||||||
accountSettingsSelector,
|
|
||||||
formValuesSelector,
|
|
||||||
activeAccountSelector,
|
|
||||||
profileDataManagerSelector,
|
|
||||||
saveStateSelector,
|
|
||||||
confirmationValuesSelector,
|
|
||||||
errorSelector,
|
|
||||||
(
|
|
||||||
accountSettings,
|
|
||||||
formValues,
|
|
||||||
activeAccount,
|
|
||||||
profileDataManager,
|
|
||||||
saveState,
|
|
||||||
confirmationValues,
|
|
||||||
errors,
|
|
||||||
) => ({
|
|
||||||
loading: accountSettings.loading,
|
|
||||||
loaded: accountSettings.loaded,
|
|
||||||
loadingError: accountSettings.loadingError,
|
|
||||||
isActive: activeAccount,
|
|
||||||
profileDataManager,
|
|
||||||
formValues,
|
|
||||||
saveState,
|
|
||||||
confirmationValues,
|
|
||||||
formErrors: errors,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const demographicsSectionSelector = createSelector(
|
|
||||||
formValuesSelector,
|
|
||||||
draftsSelector,
|
|
||||||
errorSelector,
|
|
||||||
(
|
|
||||||
formValues,
|
|
||||||
drafts,
|
|
||||||
errors,
|
|
||||||
) => ({
|
|
||||||
formValues,
|
|
||||||
drafts,
|
|
||||||
formErrors: errors,
|
formErrors: errors,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
72
src/account-settings/data/selectors.test.js
Normal file
72
src/account-settings/data/selectors.test.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { profileDataManagerSelector, formValuesSelector } from './selectors';
|
||||||
|
|
||||||
|
const testValue = 'test VALUE';
|
||||||
|
|
||||||
|
describe('profileDataManagerSelector', () => {
|
||||||
|
it('returns the profileDataManager from the state', () => {
|
||||||
|
const state = {
|
||||||
|
accountSettings: {
|
||||||
|
profileDataManager: { testValue },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = profileDataManagerSelector(state);
|
||||||
|
|
||||||
|
expect(result).toEqual(state.accountSettings.profileDataManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly select form values', () => {
|
||||||
|
const state = {
|
||||||
|
accountSettings: {
|
||||||
|
values: {
|
||||||
|
name: 'John Doe',
|
||||||
|
age: 25,
|
||||||
|
},
|
||||||
|
drafts: {
|
||||||
|
age: 26,
|
||||||
|
|
||||||
|
},
|
||||||
|
verifiedNameHistory: 'test',
|
||||||
|
confirmationValues: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formValuesSelector(state);
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
name: 'John Doe',
|
||||||
|
age: 26,
|
||||||
|
verified_name: '',
|
||||||
|
useVerifiedNameForCerts: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly select form values with extended_profile', () => {
|
||||||
|
// Mock data with extended_profile field in both values and drafts
|
||||||
|
const state = {
|
||||||
|
accountSettings: {
|
||||||
|
values: {
|
||||||
|
extended_profile: [
|
||||||
|
{ field_name: 'test_field', field_value: '5' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
drafts: { test_field: '6' },
|
||||||
|
verifiedNameHistory: 'test',
|
||||||
|
confirmationValues: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formValuesSelector(state);
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
verified_name: '',
|
||||||
|
useVerifiedNameForCerts: false,
|
||||||
|
extended_profile: [ // Draft value should override the committed value
|
||||||
|
{ field_name: 'test_field', field_value: '6' }, // Value from the committed values
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
import pick from 'lodash.pick';
|
import pick from 'lodash.pick';
|
||||||
import pickBy from 'lodash.pickby';
|
|
||||||
import omit from 'lodash.omit';
|
import omit from 'lodash.omit';
|
||||||
import isEmpty from 'lodash.isempty';
|
import isEmpty from 'lodash.isempty';
|
||||||
|
|
||||||
import { handleRequestError, unpackFieldErrors } from './utils';
|
import { handleRequestError, unpackFieldErrors } from './utils';
|
||||||
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
||||||
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
|
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
|
||||||
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
|
import { FIELD_LABELS } from './constants';
|
||||||
import { getDemographics, getDemographicsOptions, patchDemographics } from '../demographics/data/service';
|
|
||||||
import { DEMOGRAPHICS_FIELDS } from '../demographics/data/utils';
|
|
||||||
|
|
||||||
const SOCIAL_PLATFORMS = [
|
const SOCIAL_PLATFORMS = [
|
||||||
{ id: 'twitter', key: 'social_link_twitter' },
|
{ id: 'xTwitter', key: 'social_link_x' },
|
||||||
{ id: 'facebook', key: 'social_link_facebook' },
|
{ id: 'facebook', key: 'social_link_facebook' },
|
||||||
{ id: 'linkedin', key: 'social_link_linkedin' },
|
{ id: 'linkedin', key: 'social_link_linkedin' },
|
||||||
];
|
];
|
||||||
@@ -155,41 +153,6 @@ export async function getProfileDataManager(username, userRoles) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A function to determine if the Demographics questions should be displayed to the user. For the
|
|
||||||
* MVP release of Demographics we are limiting the Demographics question visibility only to
|
|
||||||
* MicroBachelors learners.
|
|
||||||
*/
|
|
||||||
export async function shouldDisplayDemographicsQuestions() {
|
|
||||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/demographics/v1/demographics/status/`;
|
|
||||||
let data = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
({ data } = await getAuthenticatedHttpClient().get(requestUrl));
|
|
||||||
if (data.display) {
|
|
||||||
return data.display;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// if there was an error then we just hide the section
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getVerifiedNameEnabled() {
|
|
||||||
let data;
|
|
||||||
const client = getAuthenticatedHttpClient();
|
|
||||||
try {
|
|
||||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name_enabled`;
|
|
||||||
({ data } = await client.get(requestUrl));
|
|
||||||
} catch (error) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getVerifiedName() {
|
export async function getVerifiedName() {
|
||||||
let data;
|
let data;
|
||||||
const client = getAuthenticatedHttpClient();
|
const client = getAuthenticatedHttpClient();
|
||||||
@@ -225,31 +188,43 @@ export async function postVerifiedName(data) {
|
|||||||
.catch(error => handleRequestError(error));
|
.catch(error => handleRequestError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCountryList(data) {
|
||||||
|
return data?.fields
|
||||||
|
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
|
||||||
|
?.options?.map(({ value }) => (value)) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCountryList() {
|
||||||
|
const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||||
|
return extractCountryList(data);
|
||||||
|
} catch (e) {
|
||||||
|
logError(e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single function to GET everything considered a setting.
|
* A single function to GET everything considered a setting. Currently encapsulates Account, Preferences, and
|
||||||
* Currently encapsulates Account, Preferences, Coaching, ThirdPartyAuth, and Demographics
|
* ThirdPartyAuth.
|
||||||
*/
|
*/
|
||||||
export async function getSettings(username, userRoles, userId) {
|
export async function getSettings(username, userRoles) {
|
||||||
const [
|
const [
|
||||||
account,
|
account,
|
||||||
preferences,
|
preferences,
|
||||||
thirdPartyAuthProviders,
|
thirdPartyAuthProviders,
|
||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
coaching,
|
countries,
|
||||||
shouldDisplayDemographicsQuestionsResponse,
|
|
||||||
demographics,
|
|
||||||
demographicsOptions,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getAccount(username),
|
getAccount(username),
|
||||||
getPreferences(username),
|
getPreferences(username),
|
||||||
getThirdPartyAuthProviders(),
|
getThirdPartyAuthProviders(),
|
||||||
getProfileDataManager(username, userRoles),
|
getProfileDataManager(username, userRoles),
|
||||||
getTimeZones(),
|
getTimeZones(),
|
||||||
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
|
getCountryList(),
|
||||||
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && shouldDisplayDemographicsQuestions(),
|
|
||||||
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographics(userId),
|
|
||||||
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographicsOptions(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -258,36 +233,26 @@ export async function getSettings(username, userRoles, userId) {
|
|||||||
thirdPartyAuthProviders,
|
thirdPartyAuthProviders,
|
||||||
profileDataManager,
|
profileDataManager,
|
||||||
timeZones,
|
timeZones,
|
||||||
coaching,
|
countries,
|
||||||
shouldDisplayDemographicsSection: shouldDisplayDemographicsQuestionsResponse,
|
|
||||||
...demographics,
|
|
||||||
demographicsOptions,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single function to PATCH everything considered a setting.
|
* A single function to PATCH everything considered a setting.
|
||||||
* Currently encapsulates Account, Preferences, coaching and ThirdPartyAuth
|
* Currently encapsulates Account, Preferences, ThirdPartyAuth
|
||||||
*/
|
*/
|
||||||
export async function patchSettings(username, commitValues, userId) {
|
export async function patchSettings(username, commitValues) {
|
||||||
// Note: time_zone exists in the return value from user/v1/accounts
|
// Note: time_zone exists in the return value from user/v1/accounts
|
||||||
// but it is always null and won't update. It also exists in
|
// but it is always null and won't update. It also exists in
|
||||||
// user/v1/preferences where it does update. This is the one we use.
|
// user/v1/preferences where it does update. This is the one we use.
|
||||||
const preferenceKeys = ['time_zone'];
|
const preferenceKeys = ['time_zone'];
|
||||||
const coachingKeys = ['coaching'];
|
|
||||||
const demographicsKeys = DEMOGRAPHICS_FIELDS;
|
|
||||||
const certificateKeys = ['useVerifiedNameForCerts'];
|
const certificateKeys = ['useVerifiedNameForCerts'];
|
||||||
const isDemographicsKey = (value, key) => key.includes('demographics');
|
|
||||||
const accountCommitValues = omit(
|
const accountCommitValues = omit(
|
||||||
commitValues,
|
commitValues,
|
||||||
preferenceKeys,
|
preferenceKeys,
|
||||||
coachingKeys,
|
|
||||||
demographicsKeys,
|
|
||||||
certificateKeys,
|
certificateKeys,
|
||||||
);
|
);
|
||||||
const preferenceCommitValues = pick(commitValues, preferenceKeys);
|
const preferenceCommitValues = pick(commitValues, preferenceKeys);
|
||||||
const coachingCommitValues = pick(commitValues, coachingKeys);
|
|
||||||
const demographicsCommitValues = pickBy(commitValues, isDemographicsKey);
|
|
||||||
const certCommitValues = pick(commitValues, certificateKeys);
|
const certCommitValues = pick(commitValues, certificateKeys);
|
||||||
const patchRequests = [];
|
const patchRequests = [];
|
||||||
|
|
||||||
@@ -297,12 +262,6 @@ export async function patchSettings(username, commitValues, userId) {
|
|||||||
if (!isEmpty(preferenceCommitValues)) {
|
if (!isEmpty(preferenceCommitValues)) {
|
||||||
patchRequests.push(patchPreferences(username, preferenceCommitValues));
|
patchRequests.push(patchPreferences(username, preferenceCommitValues));
|
||||||
}
|
}
|
||||||
if (!isEmpty(coachingCommitValues)) {
|
|
||||||
patchRequests.push(patchCoachingPreferences(userId, coachingCommitValues));
|
|
||||||
}
|
|
||||||
if (!isEmpty(demographicsCommitValues)) {
|
|
||||||
patchRequests.push(patchDemographics(userId, demographicsCommitValues));
|
|
||||||
}
|
|
||||||
if (!isEmpty(certCommitValues)) {
|
if (!isEmpty(certCommitValues)) {
|
||||||
patchRequests.push(postVerifiedNameConfig(username, certCommitValues));
|
patchRequests.push(postVerifiedNameConfig(username, certCommitValues));
|
||||||
}
|
}
|
||||||
|
|||||||
181
src/account-settings/data/service.test.js
Normal file
181
src/account-settings/data/service.test.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
|
import { FIELD_LABELS } from './constants';
|
||||||
|
import {
|
||||||
|
getAccount,
|
||||||
|
patchAccount,
|
||||||
|
getPreferences,
|
||||||
|
patchPreferences,
|
||||||
|
getTimeZones,
|
||||||
|
getProfileDataManager,
|
||||||
|
getVerifiedName,
|
||||||
|
getVerifiedNameHistory,
|
||||||
|
postVerifiedName,
|
||||||
|
getCountryList,
|
||||||
|
patchSettings,
|
||||||
|
} from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('@edx/frontend-platform/logging');
|
||||||
|
|
||||||
|
const mockHttpClient = {
|
||||||
|
get: jest.fn(),
|
||||||
|
patch: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
||||||
|
getConfig.mockReturnValue({ LMS_BASE_URL: 'http://lms.test' });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('account service', () => {
|
||||||
|
describe('getAccount', () => {
|
||||||
|
it('returns unpacked account data', async () => {
|
||||||
|
const apiResponse = {
|
||||||
|
username: 'testuser',
|
||||||
|
social_links: [{ platform: 'xTwitter', social_link: 'http://t' }],
|
||||||
|
language_proficiencies: [{ code: 'en' }],
|
||||||
|
};
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: apiResponse });
|
||||||
|
|
||||||
|
const result = await getAccount('testuser');
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledWith('http://lms.test/api/user/v1/accounts/testuser');
|
||||||
|
expect(result.social_link_x).toEqual('http://t');
|
||||||
|
expect(result.language_proficiencies).toEqual('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchAccount', () => {
|
||||||
|
it('sends packed commit data and returns unpacked response', async () => {
|
||||||
|
const commit = { social_link_x: 'http://t' };
|
||||||
|
const apiResponse = {
|
||||||
|
username: 'testuser',
|
||||||
|
social_links: [{ platform: 'xTwitter', social_link: 'http://t' }],
|
||||||
|
language_proficiencies: [],
|
||||||
|
};
|
||||||
|
mockHttpClient.patch.mockResolvedValue({ data: apiResponse });
|
||||||
|
|
||||||
|
const result = await patchAccount('testuser', commit);
|
||||||
|
expect(mockHttpClient.patch).toHaveBeenCalledWith(
|
||||||
|
'http://lms.test/api/user/v1/accounts/testuser',
|
||||||
|
expect.objectContaining({ social_links: [{ platform: 'xTwitter', social_link: 'http://t' }] }),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(result.social_link_x).toEqual('http://t');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPreferences', () => {
|
||||||
|
it('returns preferences data', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: { theme: 'dark' } });
|
||||||
|
const result = await getPreferences('user');
|
||||||
|
expect(result.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchPreferences', () => {
|
||||||
|
it('patches preferences and returns commitValues', async () => {
|
||||||
|
mockHttpClient.patch.mockResolvedValue({});
|
||||||
|
const commit = { time_zone: 'UTC' };
|
||||||
|
const result = await patchPreferences('user', commit);
|
||||||
|
expect(mockHttpClient.patch).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(commit);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTimeZones', () => {
|
||||||
|
it('returns data from API', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: ['UTC', 'PST'] });
|
||||||
|
const result = await getTimeZones('PK');
|
||||||
|
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||||
|
'http://lms.test/user_api/v1/preferences/time_zones/',
|
||||||
|
{ params: { country_code: 'PK' } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual(['UTC', 'PST']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProfileDataManager', () => {
|
||||||
|
it('returns null if no enterprise manages profile', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: { results: [] } });
|
||||||
|
const result = await getProfileDataManager('user', ['learner']);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns enterprise name if sync is enabled', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: { results: [{ enterprise_customer: { name: 'Acme', sync_learner_profile_data: true } }] } });
|
||||||
|
const result = await getProfileDataManager('user', ['enterprise_learner']);
|
||||||
|
expect(result).toBe('Acme');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVerifiedName', () => {
|
||||||
|
it('returns verified name data', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: { verified: true } });
|
||||||
|
const result = await getVerifiedName();
|
||||||
|
expect(result.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns {} on error', async () => {
|
||||||
|
mockHttpClient.get.mockRejectedValue(new Error('fail'));
|
||||||
|
const result = await getVerifiedName();
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVerifiedNameHistory', () => {
|
||||||
|
it('returns verified name history data', async () => {
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: [{ id: 1 }] });
|
||||||
|
const result = await getVerifiedNameHistory();
|
||||||
|
expect(result[0].id).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postVerifiedName', () => {
|
||||||
|
it('posts verified name data', async () => {
|
||||||
|
mockHttpClient.post.mockResolvedValue({});
|
||||||
|
await postVerifiedName({ first_name: 'A' });
|
||||||
|
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
||||||
|
'http://lms.test/api/edx_name_affirmation/v1/verified_name',
|
||||||
|
{ first_name: 'A' },
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCountryList', () => {
|
||||||
|
it('extracts country values from registration API', async () => {
|
||||||
|
const apiResponse = { fields: [{ name: FIELD_LABELS.COUNTRY, options: [{ value: 'PK' }] }] };
|
||||||
|
mockHttpClient.get.mockResolvedValue({ data: apiResponse });
|
||||||
|
const result = await getCountryList();
|
||||||
|
expect(result).toEqual(['PK']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] and logs error on failure', async () => {
|
||||||
|
mockHttpClient.get.mockRejectedValue(new Error('fail'));
|
||||||
|
const result = await getCountryList();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(logError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('patchSettings', () => {
|
||||||
|
it('calls patchAccount and patchPreferences as needed', async () => {
|
||||||
|
mockHttpClient.patch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
username: 'user',
|
||||||
|
social_links: [],
|
||||||
|
language_proficiencies: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await patchSettings('user', { time_zone: 'UTC', social_link_twitter: 't' });
|
||||||
|
expect(result.username).toBe('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { put } from 'redux-saga/effects';
|
import { put } from 'redux-saga/effects';
|
||||||
import { logError } from '@edx/frontend-platform/logging';
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
import { history } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
export default function* handleFailure(error, failureAction = null, failureRedirectPath = null) {
|
export default function* handleFailure(error, navigate, failureAction = null, failureRedirectPath = null) {
|
||||||
if (error.fieldErrors && failureAction !== null) {
|
if (error.fieldErrors && failureAction !== null) {
|
||||||
yield put(failureAction({ fieldErrors: error.fieldErrors }));
|
yield put(failureAction({ fieldErrors: error.fieldErrors }));
|
||||||
}
|
}
|
||||||
@@ -11,6 +10,6 @@ export default function* handleFailure(error, failureAction = null, failureRedir
|
|||||||
yield put(failureAction(error.message));
|
yield put(failureAction(error.message));
|
||||||
}
|
}
|
||||||
if (failureRedirectPath !== null) {
|
if (failureRedirectPath !== null) {
|
||||||
history.push(failureRedirectPath);
|
navigate(failureRedirectPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Hyperlink } from '@edx/paragon';
|
import { Hyperlink } from '@openedx/paragon';
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
@@ -13,7 +12,8 @@ import messages from './messages';
|
|||||||
import Alert from '../Alert';
|
import Alert from '../Alert';
|
||||||
|
|
||||||
const BeforeProceedingBanner = (props) => {
|
const BeforeProceedingBanner = (props) => {
|
||||||
const { instructionMessageId, intl, supportArticleUrl } = props;
|
const { instructionMessageId, supportArticleUrl } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -25,10 +25,12 @@ const BeforeProceedingBanner = (props) => {
|
|||||||
defaultMessage="Before proceeding, please {actionLink}."
|
defaultMessage="Before proceeding, please {actionLink}."
|
||||||
description="Error that appears if you are trying to delete your account, but something about your account needs attention first. The actionLink will be instructions, such as 'unlink your Facebook account'."
|
description="Error that appears if you are trying to delete your account, but something about your account needs attention first. The actionLink will be instructions, such as 'unlink your Facebook account'."
|
||||||
values={{
|
values={{
|
||||||
actionLink: (
|
actionLink: supportArticleUrl ? (
|
||||||
<Hyperlink destination={supportArticleUrl}>
|
<Hyperlink destination={supportArticleUrl}>
|
||||||
{intl.formatMessage(messages[instructionMessageId])}
|
{intl.formatMessage(messages[instructionMessageId])}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
|
) : (
|
||||||
|
intl.formatMessage(messages[instructionMessageId])
|
||||||
),
|
),
|
||||||
siteName: getConfig().SITE_NAME,
|
siteName: getConfig().SITE_NAME,
|
||||||
}}
|
}}
|
||||||
@@ -39,8 +41,7 @@ const BeforeProceedingBanner = (props) => {
|
|||||||
|
|
||||||
BeforeProceedingBanner.propTypes = {
|
BeforeProceedingBanner.propTypes = {
|
||||||
instructionMessageId: PropTypes.string.isRequired,
|
instructionMessageId: PropTypes.string.isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
supportArticleUrl: PropTypes.string.isRequired,
|
supportArticleUrl: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(BeforeProceedingBanner);
|
export default BeforeProceedingBanner;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import renderer from 'react-test-renderer';
|
||||||
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
jest.mock('react-dom', () => ({
|
||||||
|
...jest.requireActual('react-dom'),
|
||||||
|
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||||
|
}));
|
||||||
|
|
||||||
|
import BeforeProceedingBanner from './BeforeProceedingBanner'; // eslint-disable-line import/first
|
||||||
|
|
||||||
|
describe('BeforeProceedingBanner', () => {
|
||||||
|
it('should match the snapshot if SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT does not have a support link', () => {
|
||||||
|
const props = {
|
||||||
|
instructionMessageId: 'account.settings.delete.account.please.unlink',
|
||||||
|
supportArticleUrl: '',
|
||||||
|
};
|
||||||
|
const tree = renderer
|
||||||
|
.create((
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<BeforeProceedingBanner
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
))
|
||||||
|
.toJSON();
|
||||||
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match the snapshot when SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT has a support link', () => {
|
||||||
|
const props = {
|
||||||
|
instructionMessageId: 'account.settings.delete.account.please.unlink',
|
||||||
|
supportArticleUrl: 'http://test-support.edx',
|
||||||
|
};
|
||||||
|
const tree = renderer
|
||||||
|
.create((
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<BeforeProceedingBanner
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
))
|
||||||
|
.toJSON();
|
||||||
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,12 @@ import React, { Component } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button, Input, Modal, ValidationFormGroup,
|
AlertModal,
|
||||||
} from '@edx/paragon';
|
Button, Form, ActionRow,
|
||||||
|
} from '@openedx/paragon';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import Alert from '../Alert';
|
import Alert from '../Alert';
|
||||||
@@ -74,59 +74,63 @@ export class ConfirmationModal extends Component {
|
|||||||
: 'account.settings.delete.account.modal.text.2';
|
: 'account.settings.delete.account.modal.text.2';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<AlertModal
|
||||||
open={open}
|
isOpen={open}
|
||||||
title={intl.formatMessage(messages['account.settings.delete.account.modal.header'])}
|
title={intl.formatMessage(messages['account.settings.delete.account.modal.header'])}
|
||||||
body={(
|
|
||||||
<div>
|
|
||||||
{this.renderError()}
|
|
||||||
<Alert
|
|
||||||
className="alert-warning mt-n2"
|
|
||||||
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
|
|
||||||
>
|
|
||||||
<h6>
|
|
||||||
{intl.formatMessage(
|
|
||||||
messages['account.settings.delete.account.modal.text.1'],
|
|
||||||
{ siteName: getConfig().SITE_NAME },
|
|
||||||
)}
|
|
||||||
</h6>
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage(
|
|
||||||
messages[deleteAccountModalText2MessageKey],
|
|
||||||
{ siteName: getConfig().SITE_NAME },
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<PrintingInstructions />
|
|
||||||
</p>
|
|
||||||
</Alert>
|
|
||||||
<ValidationFormGroup
|
|
||||||
for={passwordFieldId}
|
|
||||||
invalid={errorType !== null}
|
|
||||||
invalidMessage={intl.formatMessage(invalidMessage)}
|
|
||||||
>
|
|
||||||
<label className="d-block" htmlFor={passwordFieldId}>
|
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.modal.enter.password'])}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
name="password"
|
|
||||||
id={passwordFieldId}
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</ValidationFormGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
buttons={[
|
|
||||||
<Button variant="danger" onClick={onSubmit}>
|
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.delete'])}
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
closeText={intl.formatMessage(messages['account.settings.delete.account.modal.confirm.cancel'])}
|
|
||||||
renderHeaderCloseButton={false}
|
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
/>
|
isOverflowVisible
|
||||||
|
footerNode={(
|
||||||
|
<ActionRow>
|
||||||
|
<Button variant="link" onClick={onCancel}>{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.cancel'])}</Button>
|
||||||
|
<Button variant="danger" onClick={onSubmit}>{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.delete'])}</Button>
|
||||||
|
</ActionRow>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-3">
|
||||||
|
{this.renderError()}
|
||||||
|
<Alert
|
||||||
|
className="alert-warning mt-n2"
|
||||||
|
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
|
||||||
|
>
|
||||||
|
<h6>
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages['account.settings.delete.account.modal.text.1'],
|
||||||
|
{ siteName: getConfig().SITE_NAME },
|
||||||
|
)}
|
||||||
|
</h6>
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages[deleteAccountModalText2MessageKey],
|
||||||
|
{ siteName: getConfig().SITE_NAME },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<PrintingInstructions />
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
<Form.Group
|
||||||
|
for={passwordFieldId}
|
||||||
|
isInvalid={errorType !== null}
|
||||||
|
>
|
||||||
|
<Form.Label className="d-block" htmlFor={passwordFieldId}>
|
||||||
|
{intl.formatMessage(messages['account.settings.delete.account.modal.enter.password'])}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
name="password"
|
||||||
|
id={passwordFieldId}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
{errorType !== null && (
|
||||||
|
<Form.Control.Feedback type="invalid" feedback-for={passwordFieldId}>
|
||||||
|
{intl.formatMessage(invalidMessage)}
|
||||||
|
</Form.Control.Feedback>
|
||||||
|
)}
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</AlertModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||||
ReactDOM.createPortal = node => node;
|
jest.mock('react-dom', () => ({
|
||||||
|
...jest.requireActual('react-dom'),
|
||||||
|
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||||
|
}));
|
||||||
|
|
||||||
import { ConfirmationModal } from './ConfirmationModal'; // eslint-disable-line import/first
|
import { ConfirmationModal } from './ConfirmationModal'; // eslint-disable-line import/first
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Hyperlink } from '@edx/paragon';
|
import { Button, Hyperlink } from '@openedx/paragon';
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +59,7 @@ export class DeleteAccount extends React.Component {
|
|||||||
hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
|
hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const canDelete = isVerifiedAccount && !hasLinkedTPA;
|
const canDelete = isVerifiedAccount && !hasLinkedTPA;
|
||||||
|
const supportArticleUrl = process.env.SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT;
|
||||||
|
|
||||||
// TODO: We lack a good way of providing custom language for a particular site. This is a hack
|
// TODO: We lack a good way of providing custom language for a particular site. This is a hack
|
||||||
// to allow edx.org to fulfill its business requirements.
|
// to allow edx.org to fulfill its business requirements.
|
||||||
@@ -66,70 +67,83 @@ export class DeleteAccount extends React.Component {
|
|||||||
? 'account.settings.delete.account.text.2.edX'
|
? 'account.settings.delete.account.text.2.edX'
|
||||||
: 'account.settings.delete.account.text.2';
|
: 'account.settings.delete.account.text.2';
|
||||||
|
|
||||||
|
const optInInstructionMessageId = getConfig().MARKETING_EMAILS_OPT_IN
|
||||||
|
? 'account.settings.delete.account.please.confirm'
|
||||||
|
: 'account.settings.delete.account.please.activate';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="section-heading">
|
<h2 className="section-heading h4 mb-3">
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.header'])}
|
{intl.formatMessage(messages['account.settings.delete.account.header'])}
|
||||||
</h2>
|
</h2>
|
||||||
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
|
{
|
||||||
<p>
|
this.props.canDeleteAccount ? (
|
||||||
{intl.formatMessage(
|
<>
|
||||||
messages['account.settings.delete.account.text.1'],
|
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
|
||||||
{ siteName: getConfig().SITE_NAME },
|
<p>
|
||||||
)}
|
{intl.formatMessage(
|
||||||
</p>
|
messages['account.settings.delete.account.text.1'],
|
||||||
<p>
|
{ siteName: getConfig().SITE_NAME },
|
||||||
{intl.formatMessage(
|
)}
|
||||||
messages[deleteAccountText2MessageKey],
|
</p>
|
||||||
{ siteName: getConfig().SITE_NAME },
|
<p>
|
||||||
)}
|
{intl.formatMessage(
|
||||||
</p>
|
messages[deleteAccountText2MessageKey],
|
||||||
<p>
|
{ siteName: getConfig().SITE_NAME },
|
||||||
<PrintingInstructions />
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-danger h6">
|
<p>
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.text.warning'],
|
<PrintingInstructions />
|
||||||
{ siteName: getConfig().SITE_NAME })}
|
</p>
|
||||||
</p>
|
<p className="text-danger h6">
|
||||||
<p>
|
{intl.formatMessage(
|
||||||
<Hyperlink destination="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings">
|
messages['account.settings.delete.account.text.warning'],
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
{ siteName: getConfig().SITE_NAME },
|
||||||
</Hyperlink>
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<Button
|
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
|
||||||
variant="outline-danger"
|
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
||||||
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
|
</Hyperlink>
|
||||||
disabled={!canDelete}
|
</p>
|
||||||
>
|
<p>
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.button'])}
|
<Button
|
||||||
</Button>
|
variant="outline-danger"
|
||||||
</p>
|
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
|
||||||
|
disabled={!canDelete}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages['account.settings.delete.account.button'])}
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
{isVerifiedAccount ? null : (
|
||||||
|
<BeforeProceedingBanner
|
||||||
|
instructionMessageId={optInInstructionMessageId}
|
||||||
|
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasLinkedTPA ? (
|
||||||
|
<BeforeProceedingBanner
|
||||||
|
instructionMessageId="account.settings.delete.account.please.unlink"
|
||||||
|
supportArticleUrl={supportArticleUrl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isVerifiedAccount ? null : (
|
<ConnectedConfirmationModal
|
||||||
<BeforeProceedingBanner
|
status={status}
|
||||||
instructionMessageId="account.settings.delete.account.please.activate"
|
errorType={errorType}
|
||||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
|
onSubmit={this.handleSubmit}
|
||||||
/>
|
onCancel={this.handleCancel}
|
||||||
)}
|
onChange={this.handlePasswordChange}
|
||||||
|
password={this.state.password}
|
||||||
|
/>
|
||||||
|
|
||||||
{hasLinkedTPA ? (
|
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
|
||||||
<BeforeProceedingBanner
|
</>
|
||||||
instructionMessageId="account.settings.delete.account.please.unlink"
|
) : (
|
||||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/207206067"
|
<p>{intl.formatMessage(messages['account.settings.cannot.delete.account.text'])}</p>
|
||||||
/>
|
)
|
||||||
) : null}
|
}
|
||||||
|
|
||||||
<ConnectedConfirmationModal
|
|
||||||
status={status}
|
|
||||||
errorType={errorType}
|
|
||||||
onSubmit={this.handleSubmit}
|
|
||||||
onCancel={this.handleCancel}
|
|
||||||
onChange={this.handlePasswordChange}
|
|
||||||
password={this.state.password}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,6 +159,7 @@ DeleteAccount.propTypes = {
|
|||||||
errorType: PropTypes.oneOf(['empty-password', 'server']),
|
errorType: PropTypes.oneOf(['empty-password', 'server']),
|
||||||
hasLinkedTPA: PropTypes.bool,
|
hasLinkedTPA: PropTypes.bool,
|
||||||
isVerifiedAccount: PropTypes.bool,
|
isVerifiedAccount: PropTypes.bool,
|
||||||
|
canDeleteAccount: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,6 +168,7 @@ DeleteAccount.defaultProps = {
|
|||||||
isVerifiedAccount: true,
|
isVerifiedAccount: true,
|
||||||
status: null,
|
status: null,
|
||||||
errorType: null,
|
errorType: null,
|
||||||
|
canDeleteAccount: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Assume we're part of the accountSettings state.
|
// Assume we're part of the accountSettings state.
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
/* eslint-disable react/jsx-no-useless-fragment */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
// Testing the modals separately, they just clutter up the snapshots if included here.
|
// Testing the modals separately, they just clutter up the snapshots if included here.
|
||||||
jest.mock('./ConfirmationModal');
|
jest.mock('./ConfirmationModal', () => function ConfirmationModalMock() {
|
||||||
jest.mock('./SuccessModal');
|
return <></>;
|
||||||
|
});
|
||||||
|
jest.mock('./SuccessModal', () => function SuccessModalMock() {
|
||||||
|
return <></>;
|
||||||
|
});
|
||||||
|
|
||||||
import { DeleteAccount } from './DeleteAccount'; // eslint-disable-line import/first
|
import { DeleteAccount } from './DeleteAccount'; // eslint-disable-line import/first
|
||||||
|
|
||||||
@@ -37,6 +42,7 @@ describe('DeleteAccount', () => {
|
|||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
))
|
))
|
||||||
.toJSON();
|
.toJSON();
|
||||||
|
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import React from 'react';
|
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { Hyperlink } from '@openedx/paragon';
|
||||||
import { Hyperlink } from '@edx/paragon';
|
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const PrintingInstructions = (props) => {
|
const PrintingInstructions = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const actionLink = (
|
const actionLink = (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
// TODO: What would a generic version of this link look like? Should
|
// TODO: What would a generic version of this link look like? Should
|
||||||
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
|
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
|
||||||
// We've removed the link from the default message.
|
// We've removed the link from the default message.
|
||||||
destination="https://support.edx.org/hc/en-us/sections/115004173027-Receive-and-Share-edX-Certificates"
|
destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates"
|
||||||
>
|
>
|
||||||
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
|
{intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -40,8 +40,4 @@ const PrintingInstructions = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PrintingInstructions.propTypes = {
|
export default PrintingInstructions;
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(PrintingInstructions);
|
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Modal } from '@edx/paragon';
|
import { ModalLayer, ModalCloseButton } from '@openedx/paragon';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
export const SuccessModal = (props) => {
|
export const SuccessModal = (props) => {
|
||||||
const { status, intl, onClose } = props;
|
const intl = useIntl();
|
||||||
|
const { status, onClose } = props;
|
||||||
return (
|
return (
|
||||||
<Modal
|
|
||||||
open={status === 'deleted'}
|
<ModalLayer isOpen={status === 'deleted'} onClose={onClose}>
|
||||||
title={intl.formatMessage(messages['account.settings.delete.account.modal.after.header'])}
|
<div className="mw-sm p-5 bg-white mx-auto my-3">
|
||||||
body={(
|
<h3>
|
||||||
<div>
|
{intl.formatMessage(messages['account.settings.delete.account.modal.after.header'])}
|
||||||
|
</h3>
|
||||||
|
<div className="p-3">
|
||||||
<p className="h6">
|
<p className="h6">
|
||||||
{intl.formatMessage(messages['account.settings.delete.account.modal.after.text'])}
|
{intl.formatMessage(messages['account.settings.delete.account.modal.after.text'])}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p>
|
||||||
closeText={intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}
|
<ModalCloseButton className="float-right" variant="link">{intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}</ModalCloseButton>
|
||||||
renderHeaderCloseButton={false}
|
</p>
|
||||||
onClose={onClose}
|
</div>
|
||||||
/>
|
|
||||||
|
</ModalLayer>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SuccessModal.propTypes = {
|
SuccessModal.propTypes = {
|
||||||
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
|
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
|
||||||
intl: intlShape.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,4 +38,4 @@ SuccessModal.defaultProps = {
|
|||||||
status: null,
|
status: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(SuccessModal);
|
export default SuccessModal;
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
import { SuccessModal } from './SuccessModal';
|
||||||
|
|
||||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||||
ReactDOM.createPortal = node => node;
|
jest.mock('react-dom', () => ({
|
||||||
|
...jest.requireActual('react-dom'),
|
||||||
import { SuccessModal } from './SuccessModal'; // eslint-disable-line import/first
|
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||||
|
}));
|
||||||
const IntlSuccessModal = injectIntl(SuccessModal);
|
|
||||||
|
|
||||||
describe('SuccessModal', () => {
|
describe('SuccessModal', () => {
|
||||||
let props = {};
|
let props = {};
|
||||||
@@ -20,39 +19,40 @@ describe('SuccessModal', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match default closed success modal snapshot', () => {
|
it('should match default closed success modal snapshot', async () => {
|
||||||
let tree = renderer.create((
|
await waitFor(() => {
|
||||||
<IntlProvider locale="en"><IntlSuccessModal {...props} /></IntlProvider>))
|
const tree = renderer.create((
|
||||||
.toJSON();
|
<IntlProvider locale="en"><SuccessModal {...props} /></IntlProvider>)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
tree = renderer.create((
|
await waitFor(() => {
|
||||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="confirming" /></IntlProvider>))
|
const tree = renderer.create((
|
||||||
.toJSON();
|
<IntlProvider locale="en"><SuccessModal {...props} status="confirming" /></IntlProvider>)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
tree = renderer.create((
|
await waitFor(() => {
|
||||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="pending" /></IntlProvider>))
|
const tree = renderer.create((
|
||||||
.toJSON();
|
<IntlProvider locale="en"><SuccessModal {...props} status="pending" /></IntlProvider>)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
tree = renderer.create((
|
await waitFor(() => {
|
||||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="failed" /></IntlProvider>))
|
const tree = renderer.create((
|
||||||
.toJSON();
|
<IntlProvider locale="en"><SuccessModal {...props} status="failed" /></IntlProvider>)).toJSON();
|
||||||
expect(tree).toMatchSnapshot();
|
expect(tree).toMatchSnapshot();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match open success modal snapshot', () => {
|
it('should match open success modal snapshot', async () => {
|
||||||
const tree = renderer
|
await waitFor(() => {
|
||||||
.create((
|
const tree = renderer.create(
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<IntlSuccessModal
|
<SuccessModal
|
||||||
{...props}
|
{...props}
|
||||||
status="deleted" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
|
status="deleted"
|
||||||
/>
|
/>
|
||||||
</IntlProvider>
|
</IntlProvider>,
|
||||||
))
|
).toJSON();
|
||||||
.toJSON();
|
expect(tree).toMatchSnapshot();
|
||||||
expect(tree).toMatchSnapshot();
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`BeforeProceedingBanner should match the snapshot if SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT does not have a support link 1`] = `
|
||||||
|
<div
|
||||||
|
className="alert d-flex align-items-start alert-warning mt-n2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="svg-inline--fa fa-triangle-exclamation mr-2"
|
||||||
|
data-icon="triangle-exclamation"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
style={{}}
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Before proceeding, please unlink all social media accounts.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`BeforeProceedingBanner should match the snapshot when SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT has a support link 1`] = `
|
||||||
|
<div
|
||||||
|
className="alert d-flex align-items-start alert-warning mt-n2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="svg-inline--fa fa-triangle-exclamation mr-2"
|
||||||
|
data-icon="triangle-exclamation"
|
||||||
|
data-prefix="fas"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
style={{}}
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Before proceeding, please
|
||||||
|
<a
|
||||||
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
|
href="http://test-support.edx"
|
||||||
|
target="_self"
|
||||||
|
>
|
||||||
|
unlink all social media accounts
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
exports[`DeleteAccount should match default section snapshot 1`] = `
|
exports[`DeleteAccount should match default section snapshot 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
className="section-heading"
|
className="section-heading h4 mb-3"
|
||||||
>
|
>
|
||||||
Delete My Account
|
Delete My Account
|
||||||
</h2>
|
</h2>
|
||||||
@@ -17,9 +17,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
|||||||
Once your account is deleted, you cannot use it to take courses on localhost.
|
Once your account is deleted, you cannot use it to take courses on localhost.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span>
|
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
|
||||||
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className="text-danger h6"
|
className="text-danger h6"
|
||||||
@@ -28,9 +26,8 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
className="default-link standalone-link"
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||||
onClick={[Function]}
|
|
||||||
target="_self"
|
target="_self"
|
||||||
>
|
>
|
||||||
Want to change your email, name, or password instead?
|
Want to change your email, name, or password instead?
|
||||||
@@ -52,7 +49,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
|||||||
exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
className="section-heading"
|
className="section-heading h4 mb-3"
|
||||||
>
|
>
|
||||||
Delete My Account
|
Delete My Account
|
||||||
</h2>
|
</h2>
|
||||||
@@ -66,9 +63,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
|||||||
Once your account is deleted, you cannot use it to take courses on localhost.
|
Once your account is deleted, you cannot use it to take courses on localhost.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span>
|
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
|
||||||
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className="text-danger h6"
|
className="text-danger h6"
|
||||||
@@ -77,9 +72,8 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
className="default-link standalone-link"
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||||
onClick={[Function]}
|
|
||||||
target="_self"
|
target="_self"
|
||||||
>
|
>
|
||||||
Want to change your email, name, or password instead?
|
Want to change your email, name, or password instead?
|
||||||
@@ -101,35 +95,32 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
|||||||
<div>
|
<div>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
|
className="svg-inline--fa fa-triangle-exclamation mr-2"
|
||||||
data-icon="exclamation-triangle"
|
data-icon="triangle-exclamation"
|
||||||
data-prefix="fas"
|
data-prefix="fas"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
role="img"
|
role="img"
|
||||||
style={Object {}}
|
style={{}}
|
||||||
viewBox="0 0 576 512"
|
viewBox="0 0 512 512"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
|
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
style={Object {}}
|
style={{}}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>
|
Before proceeding, please
|
||||||
Before proceeding, please
|
<a
|
||||||
<a
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
className="default-link standalone-link"
|
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||||
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
|
target="_self"
|
||||||
onClick={[Function]}
|
>
|
||||||
target="_self"
|
activate your account
|
||||||
>
|
</a>
|
||||||
activate your account
|
.
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +129,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
|||||||
exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
className="section-heading"
|
className="section-heading h4 mb-3"
|
||||||
>
|
>
|
||||||
Delete My Account
|
Delete My Account
|
||||||
</h2>
|
</h2>
|
||||||
@@ -152,9 +143,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
|||||||
Once your account is deleted, you cannot use it to take courses on localhost.
|
Once your account is deleted, you cannot use it to take courses on localhost.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span>
|
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
|
||||||
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className="text-danger h6"
|
className="text-danger h6"
|
||||||
@@ -163,9 +152,8 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
className="default-link standalone-link"
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||||
onClick={[Function]}
|
|
||||||
target="_self"
|
target="_self"
|
||||||
>
|
>
|
||||||
Want to change your email, name, or password instead?
|
Want to change your email, name, or password instead?
|
||||||
@@ -187,35 +175,32 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
|||||||
<div>
|
<div>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
|
className="svg-inline--fa fa-triangle-exclamation mr-2"
|
||||||
data-icon="exclamation-triangle"
|
data-icon="triangle-exclamation"
|
||||||
data-prefix="fas"
|
data-prefix="fas"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
role="img"
|
role="img"
|
||||||
style={Object {}}
|
style={{}}
|
||||||
viewBox="0 0 576 512"
|
viewBox="0 0 512 512"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
|
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
style={Object {}}
|
style={{}}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>
|
Before proceeding, please
|
||||||
Before proceeding, please
|
<a
|
||||||
<a
|
className="pgn__hyperlink default-link standalone-link"
|
||||||
className="default-link standalone-link"
|
href="https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account"
|
||||||
href="https://support.edx.org/hc/en-us/articles/207206067"
|
target="_self"
|
||||||
onClick={[Function]}
|
>
|
||||||
target="_self"
|
unlink all social media accounts
|
||||||
>
|
</a>
|
||||||
unlink all social media accounts
|
.
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
65
src/account-settings/delete-account/data/service.test.js
Normal file
65
src/account-settings/delete-account/data/service.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import formurlencoded from 'form-urlencoded';
|
||||||
|
import { handleRequestError } from '../../data/utils';
|
||||||
|
|
||||||
|
import { postDeleteAccount } from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('form-urlencoded');
|
||||||
|
jest.mock('../../data/utils');
|
||||||
|
|
||||||
|
describe('postDeleteAccount', () => {
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
formurlencoded.mockImplementation(obj => `encoded:${JSON.stringify(obj)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts delete account request with password', async () => {
|
||||||
|
const mockResponse = { data: { success: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postDeleteAccount('mypassword');
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalled();
|
||||||
|
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||||
|
expect(formurlencoded).toHaveBeenCalledWith({ password: 'mypassword' });
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/user/v1/accounts/deactivate_logout/',
|
||||||
|
'encoded:{"password":"mypassword"}',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleRequestError and throws when request fails', async () => {
|
||||||
|
const mockError = new Error('Request failed');
|
||||||
|
mockPost.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
handleRequestError.mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(postDeleteAccount('wrongpassword')).rejects.toThrow('Request failed');
|
||||||
|
|
||||||
|
expect(handleRequestError).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
export { default } from './DeleteAccount';
|
export { default } from './DeleteAccount';
|
||||||
export { default as reducer } from './data/reducers';
|
export { default as reducer } from './data/reducers';
|
||||||
export { default as saga } from './data/sagas';
|
export { default as saga } from './data/sagas';
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
'account.settings.cannot.delete.account.text': {
|
||||||
|
id: 'account.settings.cannot.delete.account.text',
|
||||||
|
defaultMessage: 'Please note that, for legal and regulatory compliance purposes, account deletion is currently unavailable.',
|
||||||
|
description: 'This text is visible when user is not allowed to delete account',
|
||||||
|
},
|
||||||
'account.settings.delete.account.header': {
|
'account.settings.delete.account.header': {
|
||||||
id: 'account.settings.delete.account.header',
|
id: 'account.settings.delete.account.header',
|
||||||
defaultMessage: 'Delete My Account',
|
defaultMessage: 'Delete My Account',
|
||||||
@@ -51,6 +56,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'activate your account',
|
defaultMessage: 'activate your account',
|
||||||
description: 'This is the text on a link that goes to the support page. It is part of this sentence: Before proceeding, please activate your account.',
|
description: 'This is the text on a link that goes to the support page. It is part of this sentence: Before proceeding, please activate your account.',
|
||||||
},
|
},
|
||||||
|
'account.settings.delete.account.please.confirm': {
|
||||||
|
id: 'account.settings.delete.account.please.confirm',
|
||||||
|
defaultMessage: 'confirm your account',
|
||||||
|
description: 'This is the text on a link that goes to the support page. It is part of this sentence: Before proceeding, please confirm your account.',
|
||||||
|
},
|
||||||
'account.settings.delete.account.please.unlink': {
|
'account.settings.delete.account.please.unlink': {
|
||||||
id: 'account.settings.delete.account.please.unlink',
|
id: 'account.settings.delete.account.please.unlink',
|
||||||
defaultMessage: 'unlink all social media accounts',
|
defaultMessage: 'unlink all social media accounts',
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { CheckBox } from '@edx/paragon';
|
|
||||||
import { DECLINED } from '../data/constants';
|
|
||||||
|
|
||||||
const Checkboxes = (props) => {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
options,
|
|
||||||
values,
|
|
||||||
onChange,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState(values);
|
|
||||||
useEffect(() => {
|
|
||||||
onChange(id, selected);
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const handleToggle = (value, option) => {
|
|
||||||
// If the user checked 'declined', uncheck all other options
|
|
||||||
if (value && option === DECLINED) {
|
|
||||||
setSelected([DECLINED]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If option checked, make sure this option is in `selected` (and remove 'declined')
|
|
||||||
if (value && !selected.includes(option)) {
|
|
||||||
const newSelected = selected.filter(i => i !== DECLINED).concat(option);
|
|
||||||
setSelected(newSelected);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If unchecked, make sure this option is NOT in `selected`
|
|
||||||
if (!value) {
|
|
||||||
setSelected(selected.filter(i => i !== option));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCheckboxes = () => options.map((option, index) => {
|
|
||||||
const isFirst = index === 0;
|
|
||||||
const isChecked = selected.includes(option.value);
|
|
||||||
return (
|
|
||||||
<div key={option.value} className="checkboxOption">
|
|
||||||
<CheckBox
|
|
||||||
type="checkbox"
|
|
||||||
id={option.value}
|
|
||||||
name={option.value}
|
|
||||||
value={option.value}
|
|
||||||
checked={isChecked}
|
|
||||||
autoFocus={isFirst}
|
|
||||||
label={option.label}
|
|
||||||
onChange={(value) => handleToggle(value, option.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div role="group">
|
|
||||||
{renderCheckboxes()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Checkboxes.propTypes = {
|
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
options: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
value: PropTypes.string,
|
|
||||||
label: PropTypes.string,
|
|
||||||
})),
|
|
||||||
values: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
Checkboxes.defaultProps = {
|
|
||||||
options: [],
|
|
||||||
values: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Checkboxes;
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import {
|
|
||||||
FormattedMessage,
|
|
||||||
injectIntl,
|
|
||||||
intlShape,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import { Hyperlink, Input } from '@edx/paragon';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import get from 'lodash.get';
|
|
||||||
import isEmpty from 'lodash.isempty';
|
|
||||||
import memoize from 'memoize-one';
|
|
||||||
import { demographicsSectionSelector } from '../data/selectors';
|
|
||||||
import EditableField from '../EditableField';
|
|
||||||
import Checkboxes from './Checkboxes';
|
|
||||||
import Alert from '../Alert';
|
|
||||||
import { saveMultipleSettings, updateDraft } from '../data/actions';
|
|
||||||
import {
|
|
||||||
OTHER,
|
|
||||||
SELF_DESCRIBE,
|
|
||||||
} from '../data/constants';
|
|
||||||
import messages from './DemographicsSection.messages';
|
|
||||||
|
|
||||||
class DemographicsSection extends React.Component {
|
|
||||||
// We check the `demographicsOptions` prop to see if it is empty before we attempt to extract and
|
|
||||||
// format the available options for each question from the API response.
|
|
||||||
getApiOptions = memoize((demographicsOptions) => (this.hasRetrievedDemographicsOptions() && {
|
|
||||||
demographicsGenderOptions: this.addDefaultOption('account.settings.field.demographics.gender.options.empty')
|
|
||||||
.concat(demographicsOptions.actions.POST.gender.choices.map(key => ({
|
|
||||||
value: key.value,
|
|
||||||
label: key.display_name,
|
|
||||||
}))),
|
|
||||||
/* Ethnicity options don't need the blank/default option */
|
|
||||||
demographicsEthnicityOptions: demographicsOptions.actions.POST.user_ethnicity.child.children.ethnicity.choices.map(
|
|
||||||
key => ({
|
|
||||||
value: key.value,
|
|
||||||
label: key.display_name,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
demographicsIncomeOptions: this.addDefaultOption('account.settings.field.demographics.income.options.empty')
|
|
||||||
.concat(demographicsOptions.actions.POST.income.choices.map(key => ({
|
|
||||||
value: key.value,
|
|
||||||
label: key.display_name,
|
|
||||||
}))),
|
|
||||||
demographicsMilitaryHistoryOptions: this.addDefaultOption('account.settings.field.demographics.military_history.options.empty')
|
|
||||||
.concat(demographicsOptions.actions.POST.military_history.choices.map(key => ({
|
|
||||||
value: key.value,
|
|
||||||
label: key.display_name,
|
|
||||||
}))),
|
|
||||||
demographicsEducationLevelOptions: this.addDefaultOption('account.settings.field.demographics.education_level.options.empty')
|
|
||||||
.concat(demographicsOptions.actions.POST.learner_education_level.choices.map(key => ({
|
|
||||||
value: key.value,
|
|
||||||
label: key.display_name,
|
|
||||||
}))),
|
|
||||||
demographicsWorkStatusOptions: this.addDefaultOption('account.settings.field.demographics.work_status.options.empty')
|
|
||||||
.concat(demographicsOptions.actions.POST.work_status.choices.map(key => ({
|
|
||||||
value: key.value,
|
|
||||||
label: key.display_name,
|
|
||||||
}))),
|
|
||||||
demographicsWorkSectorOptions: this.addDefaultOption('account.settings.field.demographics.work_sector.options.empty')
|
|
||||||
.concat(demographicsOptions.actions.POST.current_work_sector.choices.map(key => ({
|
|
||||||
value: key.value,
|
|
||||||
label: key.display_name,
|
|
||||||
}))),
|
|
||||||
}));
|
|
||||||
|
|
||||||
ethnicityFieldDisplay = (demographicsEthnicityOptions) => {
|
|
||||||
let ethnicities = [];
|
|
||||||
if (get(this, 'props.formValues.demographics_user_ethnicity')) {
|
|
||||||
ethnicities = this.props.formValues.demographics_user_ethnicity;
|
|
||||||
}
|
|
||||||
return ethnicities.map((e) => {
|
|
||||||
const matchingOption = demographicsEthnicityOptions.filter(option => option.value === e)[0];
|
|
||||||
return matchingOption && matchingOption.label;
|
|
||||||
}).join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEditableFieldChange = (name, value) => {
|
|
||||||
this.props.updateDraft(name, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSubmit = (formId) => {
|
|
||||||
// We have some custom fields in this section. Instead of relying on the
|
|
||||||
// submitted values, submit the values stored in 'drafts'.
|
|
||||||
const { drafts } = this.props;
|
|
||||||
const settingsArray = Object.entries(drafts).map(([field, value]) => ({
|
|
||||||
formId: field,
|
|
||||||
commitValues: value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.props.saveMultipleSettings(settingsArray, formId);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility method that adds the specified message as a default option to the list of available
|
|
||||||
* choices.
|
|
||||||
*
|
|
||||||
* @param {*} messageId id of message matching desired default label text
|
|
||||||
*/
|
|
||||||
addDefaultOption(messageId) {
|
|
||||||
return [{
|
|
||||||
value: '',
|
|
||||||
label: this.props.intl.formatMessage(messages[messageId]),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility method that helps determine if we were able to retrieve the available options for
|
|
||||||
* the Demographics questions. Returns true if the `demographicsOptions` prop is _not_ empty,
|
|
||||||
* otherwise false. This prop being empty is indicative of a failure communicating with the
|
|
||||||
* Demographics IDA's API.
|
|
||||||
*/
|
|
||||||
hasRetrievedDemographicsOptions() {
|
|
||||||
return !isEmpty(this.props.formValues.demographicsOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If an error is encountered when trying to communicate with the Demographics IDA then we will
|
|
||||||
* display an Alert letting the user know that their info will not be displayed and temporarily
|
|
||||||
* cannot be updated.
|
|
||||||
*/
|
|
||||||
renderDemographicsServiceIssueWarning() {
|
|
||||||
if (!isEmpty(this.props.formErrors.demographicsError)
|
|
||||||
|| !this.hasRetrievedDemographicsOptions()) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
tabIndex="-1"
|
|
||||||
ref={this.alertRef}
|
|
||||||
>
|
|
||||||
<Alert className="alert alert-danger" role="alert">
|
|
||||||
<FormattedMessage
|
|
||||||
id="account.settings.message.demographics.service.issue"
|
|
||||||
defaultMessage="An error occurred attempting to retrieve or save your account information. Please try again later."
|
|
||||||
description="alert message informing the user that the there is a problem retrieving or updating information from the Demographics microservice"
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const editableFieldProps = {
|
|
||||||
onChange: this.handleEditableFieldChange,
|
|
||||||
onSubmit: this.handleSubmit,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
demographicsGenderOptions,
|
|
||||||
demographicsEthnicityOptions,
|
|
||||||
demographicsIncomeOptions,
|
|
||||||
demographicsMilitaryHistoryOptions,
|
|
||||||
demographicsEducationLevelOptions,
|
|
||||||
demographicsWorkStatusOptions,
|
|
||||||
demographicsWorkSectorOptions,
|
|
||||||
} = this.getApiOptions(this.props.formValues.demographicsOptions);
|
|
||||||
|
|
||||||
const showSelfDescribe = this.props.formValues.demographics_gender === SELF_DESCRIBE;
|
|
||||||
const showWorkStatusDescribe = this.props.formValues.demographics_work_status === OTHER;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="account-section" id="demographics-information" ref={this.props.forwardRef}>
|
|
||||||
<h2 className="section-heading">
|
|
||||||
{this.props.intl.formatMessage(messages['account.settings.section.demographics.information'])}
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
<Hyperlink
|
|
||||||
destination={`${getConfig().MARKETING_SITE_BASE_URL}/demographics`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{this.props.intl.formatMessage(
|
|
||||||
messages['account.settings.section.demographics.why'],
|
|
||||||
{
|
|
||||||
siteName: getConfig().SITE_NAME,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</Hyperlink>
|
|
||||||
</p>
|
|
||||||
{this.renderDemographicsServiceIssueWarning()}
|
|
||||||
{/*
|
|
||||||
If the demographicsOptions props are empty then there is no need to display the fields as
|
|
||||||
the user will not have any choices available to select, nor will they be able to update
|
|
||||||
their answers.
|
|
||||||
*/}
|
|
||||||
{this.hasRetrievedDemographicsOptions() && (
|
|
||||||
<div id="demographics-fields">
|
|
||||||
<EditableField
|
|
||||||
name="demographics_gender"
|
|
||||||
type="select"
|
|
||||||
value={this.props.formValues.demographics_gender}
|
|
||||||
userSuppliedValue={showSelfDescribe ? this.props.formValues.demographics_gender_description : null}
|
|
||||||
options={demographicsGenderOptions}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
>
|
|
||||||
{showSelfDescribe && (
|
|
||||||
<Input
|
|
||||||
name="demographics_gender_description"
|
|
||||||
id="field-demographics_gender_description"
|
|
||||||
type="text"
|
|
||||||
placeholder={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender_description.empty'])}
|
|
||||||
value={this.props.formValues.demographics_gender_description}
|
|
||||||
onChange={(e) => this.handleEditableFieldChange('demographics_gender_description', e.target.value)}
|
|
||||||
aria-label={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender_description'])}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EditableField>
|
|
||||||
<EditableField
|
|
||||||
name="demographics_user_ethnicity"
|
|
||||||
type="select"
|
|
||||||
hidden
|
|
||||||
value={this.ethnicityFieldDisplay(demographicsEthnicityOptions)}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.ethnicity'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.ethnicity.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
>
|
|
||||||
<Checkboxes
|
|
||||||
id="demographics_user_ethnicity"
|
|
||||||
options={demographicsEthnicityOptions}
|
|
||||||
values={this.props.formValues.demographics_user_ethnicity}
|
|
||||||
{...editableFieldProps}
|
|
||||||
/>
|
|
||||||
</EditableField>
|
|
||||||
<EditableField
|
|
||||||
name="demographics_income"
|
|
||||||
type="select"
|
|
||||||
value={this.props.formValues.demographics_income}
|
|
||||||
options={demographicsIncomeOptions}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.income'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.income.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
/>
|
|
||||||
<EditableField
|
|
||||||
name="demographics_military_history"
|
|
||||||
type="select"
|
|
||||||
value={this.props.formValues.demographics_military_history}
|
|
||||||
options={demographicsMilitaryHistoryOptions}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.military_history'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.military_history.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
/>
|
|
||||||
<EditableField
|
|
||||||
name="demographics_learner_education_level"
|
|
||||||
type="select"
|
|
||||||
value={this.props.formValues.demographics_learner_education_level}
|
|
||||||
options={demographicsEducationLevelOptions}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.learner_education_level'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.learner_education_level.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
/>
|
|
||||||
<EditableField
|
|
||||||
name="demographics_parent_education_level"
|
|
||||||
type="select"
|
|
||||||
value={this.props.formValues.demographics_parent_education_level}
|
|
||||||
options={demographicsEducationLevelOptions}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.parent_education_level'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.parent_education_level.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
/>
|
|
||||||
<EditableField
|
|
||||||
name="demographics_work_status"
|
|
||||||
type="select"
|
|
||||||
value={this.props.formValues.demographics_work_status}
|
|
||||||
userSuppliedValue={showWorkStatusDescribe
|
|
||||||
? this.props.formValues.demographics_work_status_description
|
|
||||||
: null}
|
|
||||||
options={demographicsWorkStatusOptions}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
>
|
|
||||||
{showWorkStatusDescribe && (
|
|
||||||
<Input
|
|
||||||
name="demographics_work_status_description"
|
|
||||||
id="field-demographics_work_status_description"
|
|
||||||
type="text"
|
|
||||||
placeholder={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status_description.empty'])}
|
|
||||||
value={this.props.formValues.demographics_work_status_description}
|
|
||||||
onChange={(e) => this.handleEditableFieldChange('demographics_work_status_description', e.target.value)}
|
|
||||||
aria-label={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status_description'])}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EditableField>
|
|
||||||
<EditableField
|
|
||||||
name="demographics_current_work_sector"
|
|
||||||
type="select"
|
|
||||||
value={this.props.formValues.demographics_current_work_sector}
|
|
||||||
options={demographicsWorkSectorOptions}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.current_work_sector'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.current_work_sector.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
/>
|
|
||||||
<EditableField
|
|
||||||
name="demographics_future_work_sector"
|
|
||||||
type="select"
|
|
||||||
value={this.props.formValues.demographics_future_work_sector}
|
|
||||||
options={demographicsWorkSectorOptions}
|
|
||||||
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.future_work_sector'])}
|
|
||||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.future_work_sector.empty'])}
|
|
||||||
{...editableFieldProps}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DemographicsSection.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
formValues: PropTypes.shape({
|
|
||||||
demographics_gender: PropTypes.string,
|
|
||||||
demographics_user_ethnicity: PropTypes.array,
|
|
||||||
demographics_income: PropTypes.string,
|
|
||||||
demographics_military_history: PropTypes.string,
|
|
||||||
demographics_learner_education_level: PropTypes.string,
|
|
||||||
demographics_parent_education_level: PropTypes.string,
|
|
||||||
demographics_work_status: PropTypes.string,
|
|
||||||
demographics_current_work_sector: PropTypes.string,
|
|
||||||
demographics_future_work_sector: PropTypes.string,
|
|
||||||
demographics_work_status_description: PropTypes.string,
|
|
||||||
demographics_gender_description: PropTypes.string,
|
|
||||||
demographicsOptions: PropTypes.object,
|
|
||||||
}).isRequired,
|
|
||||||
drafts: PropTypes.shape({
|
|
||||||
demographics_gender: PropTypes.string,
|
|
||||||
demographics_user_ethnicity: PropTypes.array,
|
|
||||||
demographics_income: PropTypes.string,
|
|
||||||
demographics_military_history: PropTypes.string,
|
|
||||||
demographics_learner_education_level: PropTypes.string,
|
|
||||||
demographics_parent_education_level: PropTypes.string,
|
|
||||||
demographics_work_status: PropTypes.string,
|
|
||||||
demographics_current_work_sector: PropTypes.string,
|
|
||||||
demographics_future_work_sector: PropTypes.string,
|
|
||||||
demographics_work_status_description: PropTypes.string,
|
|
||||||
demographics_gender_description: PropTypes.string,
|
|
||||||
demographicsOptions: PropTypes.object,
|
|
||||||
}).isRequired,
|
|
||||||
formErrors: PropTypes.shape({
|
|
||||||
demographicsError: PropTypes.string,
|
|
||||||
}).isRequired,
|
|
||||||
forwardRef: PropTypes.func.isRequired,
|
|
||||||
updateDraft: PropTypes.func.isRequired,
|
|
||||||
saveMultipleSettings: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(demographicsSectionSelector, {
|
|
||||||
saveMultipleSettings,
|
|
||||||
updateDraft,
|
|
||||||
})(injectIntl(DemographicsSection));
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
/* Demographics section heading */
|
|
||||||
'account.settings.section.demographics.information': {
|
|
||||||
id: 'account.settings.section.demographics.information',
|
|
||||||
defaultMessage: 'Optional Information',
|
|
||||||
description: 'The optional information section heading.',
|
|
||||||
},
|
|
||||||
/* Gender identity */
|
|
||||||
'account.settings.field.demographics.gender': {
|
|
||||||
id: 'account.settings.field.demographics.gender',
|
|
||||||
defaultMessage: 'Gender identity',
|
|
||||||
description: 'Label for account settings gender identity field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.gender.empty': {
|
|
||||||
id: 'account.settings.field.demographics.gender.empty',
|
|
||||||
defaultMessage: 'Add gender identity',
|
|
||||||
description: 'Placeholder for empty account settings gender identity field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.gender.options.empty': {
|
|
||||||
id: 'account.settings.field.demographics.gender.options.empty',
|
|
||||||
defaultMessage: 'Select a gender identity',
|
|
||||||
description: 'Placeholder for the gender identity options dropdown.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.gender_description': {
|
|
||||||
id: 'account.settings.field.demographics.gender_description',
|
|
||||||
defaultMessage: 'Gender identity description',
|
|
||||||
description: 'Label for account settings gender identity description field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.gender_description.empty': {
|
|
||||||
id: 'account.settings.field.demographics.gender_description.empty',
|
|
||||||
defaultMessage: 'Enter description',
|
|
||||||
description: 'Placeholder for empty account settings gender identity field.',
|
|
||||||
},
|
|
||||||
/* Ethnicity */
|
|
||||||
'account.settings.field.demographics.ethnicity': {
|
|
||||||
id: 'account.settings.field.demographics.ethnicity',
|
|
||||||
defaultMessage: 'Race/Ethnicity identity',
|
|
||||||
description: 'Label for account settings ethnic background field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.ethnicity.empty': {
|
|
||||||
id: 'account.settings.field.demographics.ethnicity.empty',
|
|
||||||
defaultMessage: 'Add race/ethnicity identity',
|
|
||||||
description: 'Placeholder for empty account settings ethnic background field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.ethnicity.options.empty': {
|
|
||||||
id: 'account.settings.field.demographics.ethnicity.options.empty',
|
|
||||||
defaultMessage: 'Select all that apply', // TODO: Is this the desired text?
|
|
||||||
description: 'Placeholder for the ethnic background options field.',
|
|
||||||
},
|
|
||||||
/* Income */
|
|
||||||
'account.settings.field.demographics.income': {
|
|
||||||
id: 'account.settings.field.demographics.income',
|
|
||||||
defaultMessage: 'Family income',
|
|
||||||
description: 'Label for account settings household income field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.income.empty': {
|
|
||||||
id: 'account.settings.field.demographics.income.empty',
|
|
||||||
defaultMessage: 'Add family income',
|
|
||||||
description: 'Placeholder for empty account settings household income field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.income.options.empty': {
|
|
||||||
id: 'account.settings.field.demographics.income.options.empty',
|
|
||||||
defaultMessage: 'Select a family income range',
|
|
||||||
description: 'Placeholder for the household income dropdown.',
|
|
||||||
},
|
|
||||||
/* Military history */
|
|
||||||
'account.settings.field.demographics.military_history': {
|
|
||||||
id: 'account.settings.field.demographics.military_history',
|
|
||||||
defaultMessage: 'U.S. Military status',
|
|
||||||
description: 'Label for account settings military history field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.military_history.empty': {
|
|
||||||
id: 'account.settings.field.demographics.military_history.empty',
|
|
||||||
defaultMessage: 'Add military status',
|
|
||||||
description: 'Placeholder for empty account settings military history field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.military_history.options.empty': {
|
|
||||||
id: 'account.settings.field.demographics.military_history.options.empty',
|
|
||||||
defaultMessage: 'Select military status',
|
|
||||||
description: 'Placeholder for the military history dropdown.',
|
|
||||||
},
|
|
||||||
/* Learner and family education level */
|
|
||||||
'account.settings.field.demographics.learner_education_level': {
|
|
||||||
id: 'account.settings.field.demographics.learner_education_level',
|
|
||||||
defaultMessage: 'Your education level',
|
|
||||||
description: 'Label for account settings learner education level field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.learner_education_level.empty': {
|
|
||||||
id: 'account.settings.field.demographics.learner_education_level.empty',
|
|
||||||
defaultMessage: 'Add education level',
|
|
||||||
description: 'Placeholder for empty account settings learner education level field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.parent_education_level': {
|
|
||||||
id: 'account.settings.field.demographics.parent_education_level',
|
|
||||||
defaultMessage: 'Parents/Guardians education level',
|
|
||||||
description: 'Label for account settings parent education level field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.parent_education_level.empty': {
|
|
||||||
id: 'account.settings.field.demographics.parent_education_level.empty',
|
|
||||||
defaultMessage: 'Add education level',
|
|
||||||
description: 'Placeholder for empty account settings parent education level field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.education_level.options.empty': {
|
|
||||||
id: 'account.settings.field.demographics.education_level.options.empty',
|
|
||||||
defaultMessage: 'Select education level',
|
|
||||||
description: 'Placeholder for the education level options dropdown.',
|
|
||||||
},
|
|
||||||
/* Work status */
|
|
||||||
'account.settings.field.demographics.work_status': {
|
|
||||||
id: 'account.settings.field.demographics.work_status',
|
|
||||||
defaultMessage: 'Employment status',
|
|
||||||
description: 'Label for account settings work status field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.work_status.empty': {
|
|
||||||
id: 'account.settings.field.demographics.work_status.empty',
|
|
||||||
defaultMessage: 'Add employment status',
|
|
||||||
description: 'Placeholder for empty account settings work status field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.work_status.options.empty': {
|
|
||||||
id: 'account.settings.field.demographics.work_status.options.empty',
|
|
||||||
defaultMessage: 'Select employment status',
|
|
||||||
description: 'Placeholder for the work status options dropdown.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.work_status_description': {
|
|
||||||
id: 'account.settings.field.demographics.work_status_description',
|
|
||||||
defaultMessage: 'Employment status description',
|
|
||||||
description: 'Label for account settings work status description field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.work_status_description.empty': {
|
|
||||||
id: 'account.settings.field.demographics.work_status_description.empty',
|
|
||||||
defaultMessage: 'Enter description',
|
|
||||||
description: 'Placeholder for empty account settings work status description field.',
|
|
||||||
},
|
|
||||||
/* Work sector */
|
|
||||||
'account.settings.field.demographics.current_work_sector': {
|
|
||||||
id: 'account.settings.field.demographics.current_work_sector',
|
|
||||||
defaultMessage: 'Current work industry',
|
|
||||||
description: 'Label for account settings current work sector field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.current_work_sector.empty': {
|
|
||||||
id: 'account.settings.field.demographics.current_work_sector.empty',
|
|
||||||
defaultMessage: 'Add work industry',
|
|
||||||
description: 'Placeholder for empty account settings current work sector field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.future_work_sector': {
|
|
||||||
id: 'account.settings.field.demographics.future_work_sector',
|
|
||||||
defaultMessage: 'Future work industry',
|
|
||||||
description: 'Label for account settings future work sector field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.future_work_sector.empty': {
|
|
||||||
id: 'account.settings.field.demographics.future_work_sector.empty',
|
|
||||||
defaultMessage: 'Add work industry',
|
|
||||||
description: 'Placeholder for empty account settings future work sector field.',
|
|
||||||
},
|
|
||||||
'account.settings.field.demographics.work_sector.options.empty': {
|
|
||||||
id: 'account.settings.field.demographics.work_sector.options.empty',
|
|
||||||
defaultMessage: 'Select work industry',
|
|
||||||
description: 'Placeholder for the work sector options dropdown.',
|
|
||||||
},
|
|
||||||
/* Legal copy link text */
|
|
||||||
'account.settings.section.demographics.why': {
|
|
||||||
id: 'account.settings.section.demographics.why',
|
|
||||||
defaultMessage: 'Why does {siteName} collect this information?',
|
|
||||||
description: 'Link text for a link to external legal text',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import get from 'lodash.get';
|
|
||||||
import { convertData, TO, FROM } from './utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility method that attempts to extract errors from the response of a PATCH request in order to
|
|
||||||
* display a warning or otherwise meaningful message to the user.
|
|
||||||
*
|
|
||||||
* @param {Error} error
|
|
||||||
*/
|
|
||||||
export function createDemographicsError(error) {
|
|
||||||
const apiError = Object.create(error);
|
|
||||||
// If the error received has the `httpResponseData` field in it, then we should have reason to
|
|
||||||
// believe the Demographics service is alive and responding. Extract errors from fields where
|
|
||||||
// appropriate so we can display them to the user.
|
|
||||||
if (get(error, 'customAttributes.httpErrorResponseData')) {
|
|
||||||
apiError.fieldErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
|
|
||||||
if (get(apiError, 'fieldErrors.gender_description')) {
|
|
||||||
// eslint-disable-next-line prefer-destructuring
|
|
||||||
apiError.fieldErrors.demographics_gender = apiError.fieldErrors.gender_description[0];
|
|
||||||
delete apiError.fieldErrors.gender_description;
|
|
||||||
} else if (get(apiError, 'fieldErrors.work_status_description')) {
|
|
||||||
// eslint-disable-next-line prefer-destructuring
|
|
||||||
apiError.fieldErrors.demographics_work_status = apiError.fieldErrors.work_status_description[0];
|
|
||||||
delete apiError.fieldErrors.work_status_description;
|
|
||||||
}
|
|
||||||
// Otherwise, when the service is down, the error response will not contain a
|
|
||||||
// `httpErrorResponseData` field. Add a generic 'demographicsError' field to the fieldErrors that
|
|
||||||
// will trigger showing an Alert to the user to them them know the update was unsuccessful.
|
|
||||||
} else {
|
|
||||||
apiError.fieldErrors = {
|
|
||||||
demographicsError: error.customAttributes.httpErrorType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* post all of the data related to demographics.
|
|
||||||
* @param {Number} userId users are identified in the api by LMS id
|
|
||||||
* @param {Object} commitValues { demographics }
|
|
||||||
*/
|
|
||||||
export async function postDemographics(userId) {
|
|
||||||
const requestConfig = { headers: { 'Content-Type': 'application/json' } };
|
|
||||||
const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/`;
|
|
||||||
const commitValues = { user: userId };
|
|
||||||
let data = {};
|
|
||||||
|
|
||||||
({ data } = await getAuthenticatedHttpClient()
|
|
||||||
.post(requestUrl, commitValues, requestConfig)
|
|
||||||
.catch((error) => {
|
|
||||||
const apiError = createDemographicsError(error);
|
|
||||||
throw apiError;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return convertData(data, FROM);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get all data related to the demographics.
|
|
||||||
* @param {Number} userId users are identified in the api by LMS id
|
|
||||||
*/
|
|
||||||
export async function getDemographics(userId) {
|
|
||||||
const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/${userId}/`;
|
|
||||||
let data = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
({ data } = await getAuthenticatedHttpClient()
|
|
||||||
.get(requestUrl));
|
|
||||||
|
|
||||||
data = convertData(data, FROM);
|
|
||||||
} catch (error) {
|
|
||||||
const apiError = Object.create(error);
|
|
||||||
// if the API called resulted in this user receiving a 404 then follow up with a POST call to
|
|
||||||
// try and create the demographics entity on the backend
|
|
||||||
if (apiError.customAttributes.httpErrorStatus) {
|
|
||||||
if (apiError.customAttributes.httpErrorStatus === 404) {
|
|
||||||
data = await postDemographics(userId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data = {
|
|
||||||
user: userId,
|
|
||||||
demographics_gender: '',
|
|
||||||
demographics_gender_description: '',
|
|
||||||
demographics_income: '',
|
|
||||||
demographics_learner_education_level: '',
|
|
||||||
demographics_parent_education_level: '',
|
|
||||||
demographics_military_history: '',
|
|
||||||
demographics_work_status: '',
|
|
||||||
demographics_work_status_description: '',
|
|
||||||
demographics_current_work_sector: '',
|
|
||||||
demographics_future_work_sector: '',
|
|
||||||
demographics_user_ethnicity: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* patch all of the data related to demographics.
|
|
||||||
* @param {Number} userId users are identified in the api by LMS id
|
|
||||||
* @param {Object} commitValues { demographics }
|
|
||||||
*/
|
|
||||||
export async function patchDemographics(userId, commitValues) {
|
|
||||||
const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/${userId}/`;
|
|
||||||
const convertedCommitValues = convertData(commitValues, TO);
|
|
||||||
let data = {};
|
|
||||||
|
|
||||||
({ data } = await getAuthenticatedHttpClient()
|
|
||||||
.patch(requestUrl, convertedCommitValues)
|
|
||||||
.catch((error) => {
|
|
||||||
const apiError = createDemographicsError(error);
|
|
||||||
throw apiError;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return convertData(data, FROM);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieve the options for each field from the Demographics API
|
|
||||||
*/
|
|
||||||
export async function getDemographicsOptions() {
|
|
||||||
const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/`;
|
|
||||||
let data = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
({ data } = await getAuthenticatedHttpClient().options(requestUrl));
|
|
||||||
} catch (error) {
|
|
||||||
// We are catching and suppressing errors here on purpose. If an error occurs during the
|
|
||||||
// getDemographicsOptions call we will pass back an empty `data` object. Downstream we make
|
|
||||||
// the assumption that if the demographicsOptions object is empty that there was an issue or
|
|
||||||
// error communicating with the service/API.
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
export const TO = 'to';
|
|
||||||
export const FROM = 'from';
|
|
||||||
export const DEMOGRAPHICS_FIELDS = [
|
|
||||||
'demographics_gender',
|
|
||||||
'demographics_gender_description',
|
|
||||||
'demographics_income',
|
|
||||||
'demographics_learner_education_level',
|
|
||||||
'demographics_parent_education_level',
|
|
||||||
'demographics_military_history',
|
|
||||||
'demographics_work_status',
|
|
||||||
'demographics_work_status_description',
|
|
||||||
'demographics_current_work_sector',
|
|
||||||
'demographics_future_work_sector',
|
|
||||||
'demographics_user_ethnicity',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Frontend wants (example):
|
|
||||||
// demographics_user_ethnicity: ["asian", "white", "other"]
|
|
||||||
//
|
|
||||||
// Demographics wants (example):
|
|
||||||
// user_ethnicity: [
|
|
||||||
// { ethnicity: "asian" },
|
|
||||||
// { ethnicity: "white" },
|
|
||||||
// { ethnicity: "other" }
|
|
||||||
// ]
|
|
||||||
function convertEthnicity(ethnicityData, direction) {
|
|
||||||
if (direction === FROM) {
|
|
||||||
return ethnicityData.map(e => e.ethnicity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (direction === TO) {
|
|
||||||
return ethnicityData.map(e => ({ ethnicity: e }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ethnicityData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handles conversion of data to/from Demographics IDA to/from format needed for
|
|
||||||
// frontend
|
|
||||||
// * handles ethnicity field
|
|
||||||
// * adds/removes 'demographics' to/from key
|
|
||||||
// * replace `null` with empty string or empty string with null
|
|
||||||
export function convertData(dataObject, direction) {
|
|
||||||
const converted = {};
|
|
||||||
|
|
||||||
Object.entries(dataObject).forEach(([key, value]) => {
|
|
||||||
let newValue = value;
|
|
||||||
|
|
||||||
if (key.includes('ethnicity')) {
|
|
||||||
newValue = convertEthnicity(value, direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (direction === TO) {
|
|
||||||
converted[key.replace('demographics_', '')] = newValue || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (direction === FROM) {
|
|
||||||
converted[`demographics_${key}`] = newValue || '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return converted;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
19
src/account-settings/hoc.jsx
Normal file
19
src/account-settings/hoc.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const withNavigate = Component => {
|
||||||
|
const WrappedComponent = props => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return <Component {...props} navigate={navigate} />;
|
||||||
|
};
|
||||||
|
return WrappedComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withLocation = Component => {
|
||||||
|
const WrappedComponent = props => {
|
||||||
|
const location = useLocation();
|
||||||
|
return <Component {...props} location={location.pathname} />;
|
||||||
|
};
|
||||||
|
return WrappedComponent;
|
||||||
|
};
|
||||||
38
src/account-settings/hoc.test.jsx
Normal file
38
src/account-settings/hoc.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { withLocation, withNavigate } from './hoc';
|
||||||
|
|
||||||
|
const mockedNavigator = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
useNavigate: () => mockedNavigator,
|
||||||
|
useLocation: () => ({
|
||||||
|
pathname: '/current-location',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/prop-types
|
||||||
|
const MockComponent = ({ navigate, location }) => (
|
||||||
|
// eslint-disable-next-line react/button-has-type, react/prop-types
|
||||||
|
<button data-testid="btn" onClick={() => navigate('/some-route')}>{location}</button>
|
||||||
|
);
|
||||||
|
const WrappedComponent = withNavigate(withLocation(MockComponent));
|
||||||
|
|
||||||
|
test('Provide Navigation to Component', () => {
|
||||||
|
render(
|
||||||
|
<WrappedComponent />,
|
||||||
|
);
|
||||||
|
const btn = screen.getByTestId('btn');
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(mockedNavigator).toHaveBeenCalledWith('/some-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Provide Location Pathname to Component', () => {
|
||||||
|
render(
|
||||||
|
<WrappedComponent />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('btn').textContent).toContain('/current-location');
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
export { default } from './AccountSettingsPage';
|
export { default } from './AccountSettingsPage';
|
||||||
export { default as reducer } from './data/reducers';
|
export { default as reducer } from './data/reducers';
|
||||||
export { default as saga } from './data/sagas';
|
export { default as saga } from './data/sagas';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { connect, useDispatch } from 'react-redux';
|
import { connect, useDispatch } from 'react-redux';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
ActionRow,
|
ActionRow,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Form,
|
Form,
|
||||||
ModalDialog,
|
ModalDialog,
|
||||||
StatefulButton,
|
StatefulButton,
|
||||||
} from '@edx/paragon';
|
} from '@openedx/paragon';
|
||||||
|
|
||||||
import { closeForm, saveSettingsReset } from '../data/actions';
|
import { closeForm, saveSettingsReset } from '../data/actions';
|
||||||
import { nameChangeSelector } from '../data/selectors';
|
import { nameChangeSelector } from '../data/selectors';
|
||||||
@@ -21,35 +21,35 @@ import { nameChangeSelector } from '../data/selectors';
|
|||||||
import { requestNameChange, requestNameChangeFailure, requestNameChangeReset } from './data/actions';
|
import { requestNameChange, requestNameChangeFailure, requestNameChangeReset } from './data/actions';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function NameChangeModal({
|
const NameChangeModal = ({
|
||||||
targetFormId,
|
targetFormId,
|
||||||
errors,
|
errors,
|
||||||
formValues,
|
formValues,
|
||||||
intl,
|
|
||||||
saveState,
|
saveState,
|
||||||
}) {
|
}) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { push } = useHistory();
|
const navigate = useNavigate();
|
||||||
const { username } = getAuthenticatedUser();
|
const { username } = getAuthenticatedUser();
|
||||||
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
|
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
|
||||||
const [confirmedWarning, setConfirmedWarning] = useState(false);
|
const [confirmedWarning, setConfirmedWarning] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
function resetLocalState() {
|
const resetLocalState = useCallback(() => {
|
||||||
setConfirmedWarning(false);
|
setConfirmedWarning(false);
|
||||||
dispatch(requestNameChangeReset());
|
dispatch(requestNameChangeReset());
|
||||||
}
|
}, [dispatch]);
|
||||||
|
|
||||||
function handleChange(e) {
|
const handleChange = (e) => {
|
||||||
setVerifiedNameInput(e.target.value);
|
setVerifiedNameInput(e.target.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleClose() {
|
const handleClose = useCallback(() => {
|
||||||
resetLocalState();
|
resetLocalState();
|
||||||
dispatch(closeForm(targetFormId));
|
dispatch(closeForm(targetFormId));
|
||||||
dispatch(saveSettingsReset());
|
dispatch(saveSettingsReset());
|
||||||
}
|
}, [dispatch, resetLocalState, targetFormId]);
|
||||||
|
|
||||||
function handleSubmit(e) {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (saveState === 'pending') {
|
if (saveState === 'pending') {
|
||||||
@@ -64,14 +64,14 @@ function NameChangeModal({
|
|||||||
const draftProfileName = targetFormId === 'name' ? formValues.name : null;
|
const draftProfileName = targetFormId === 'name' ? formValues.name : null;
|
||||||
dispatch(requestNameChange(username, draftProfileName, verifiedNameInput));
|
dispatch(requestNameChange(username, draftProfileName, verifiedNameInput));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveState === 'complete') {
|
if (saveState === 'complete') {
|
||||||
handleClose();
|
handleClose();
|
||||||
push('/id-verification');
|
navigate(`/id-verification?next=${encodeURIComponent('account/settings')}`);
|
||||||
}
|
}
|
||||||
}, [saveState]);
|
}, [handleClose, navigate, saveState]);
|
||||||
|
|
||||||
function renderErrors() {
|
function renderErrors() {
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) {
|
||||||
@@ -183,7 +183,7 @@ function NameChangeModal({
|
|||||||
|
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
NameChangeModal.propTypes = {
|
NameChangeModal.propTypes = {
|
||||||
targetFormId: PropTypes.string.isRequired,
|
targetFormId: PropTypes.string.isRequired,
|
||||||
@@ -193,11 +193,10 @@ NameChangeModal.propTypes = {
|
|||||||
verified_name: PropTypes.string,
|
verified_name: PropTypes.string,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
saveState: PropTypes.string,
|
saveState: PropTypes.string,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
NameChangeModal.defaultProps = {
|
NameChangeModal.defaultProps = {
|
||||||
saveState: null,
|
saveState: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(nameChangeSelector)(injectIntl(NameChangeModal));
|
export default connect(nameChangeSelector)(NameChangeModal);
|
||||||
|
|||||||
56
src/account-settings/name-change/data/service.test.js
Normal file
56
src/account-settings/name-change/data/service.test.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { handleRequestError } from '../../data/utils';
|
||||||
|
|
||||||
|
import { postNameChange } from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('../../data/utils');
|
||||||
|
|
||||||
|
describe('postNameChange', () => {
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts a name change request successfully', async () => {
|
||||||
|
const mockResponse = { data: { success: true, updated: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postNameChange('New Name');
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalled();
|
||||||
|
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/api/user/v1/accounts/name_change/',
|
||||||
|
{ name: 'New Name' },
|
||||||
|
{ headers: { Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleRequestError and throws when request fails', async () => {
|
||||||
|
const mockError = new Error('Request failed');
|
||||||
|
mockPost.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
handleRequestError.mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(postNameChange('Bad Name')).rejects.toThrow('Request failed');
|
||||||
|
|
||||||
|
expect(handleRequestError).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
export { default } from './NameChange';
|
export { default } from './NameChange';
|
||||||
export { default as reducer } from './data/reducers';
|
export { default as reducer } from './data/reducers';
|
||||||
export { default as saga } from './data/sagas';
|
export { default as saga } from './data/sagas';
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
'account.settings.name.change.id.name.label': {
|
'account.settings.name.change.id.name.label': {
|
||||||
id: 'account.settings.name.change.id.name.label',
|
id: 'account.settings.name.change.id.name.label',
|
||||||
defaultMessage: 'Enter your name as it appears on your government-issued ID.',
|
defaultMessage: 'Enter your name as it appears on your identification card.',
|
||||||
description: 'Form label instructing the user to enter the name on their ID.',
|
description: 'Form label instructing the user to enter the name on their ID.',
|
||||||
},
|
},
|
||||||
'account.settings.name.change.id.name.placeholder': {
|
'account.settings.name.change.id.name.placeholder': {
|
||||||
id: 'account.settings.name.change.id.name.placeholder',
|
id: 'account.settings.name.change.id.name.placeholder',
|
||||||
defaultMessage: 'Enter the name on your government ID',
|
defaultMessage: 'Enter the name on your photo ID',
|
||||||
description: 'Form label instructing the user to enter the name on their ID.',
|
description: 'Form label instructing the user to enter the name on their ID.',
|
||||||
},
|
},
|
||||||
'account.settings.name.change.error.valid.name': {
|
'account.settings.name.change.error.valid.name': {
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import React from 'react';
|
/* eslint-disable no-import-assign */
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import configureStore from 'redux-mock-store';
|
import configureStore from 'redux-mock-store';
|
||||||
import {
|
import {
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { createMemoryHistory } from 'history';
|
|
||||||
|
|
||||||
import * as auth from '@edx/frontend-platform/auth';
|
import * as auth from '@edx/frontend-platform/auth';
|
||||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||||
ReactDOM.createPortal = node => node;
|
jest.mock('react-dom', () => ({
|
||||||
|
...jest.requireActual('react-dom'),
|
||||||
|
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||||
|
}));
|
||||||
|
|
||||||
import NameChange from '../NameChange'; // eslint-disable-line import/first
|
import NameChange from '../NameChange'; // eslint-disable-line import/first
|
||||||
|
|
||||||
@@ -27,10 +28,6 @@ jest.mock('react-redux', () => ({
|
|||||||
jest.mock('@edx/frontend-platform/auth');
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ nameChangeSelector: () => ({}) })));
|
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ nameChangeSelector: () => ({}) })));
|
||||||
|
|
||||||
const history = createMemoryHistory();
|
|
||||||
|
|
||||||
const IntlNameChange = injectIntl(NameChange);
|
|
||||||
|
|
||||||
const mockStore = configureStore();
|
const mockStore = configureStore();
|
||||||
|
|
||||||
describe('NameChange', () => {
|
describe('NameChange', () => {
|
||||||
@@ -38,7 +35,7 @@ describe('NameChange', () => {
|
|||||||
let store = {};
|
let store = {};
|
||||||
|
|
||||||
const reduxWrapper = children => (
|
const reduxWrapper = children => (
|
||||||
<Router history={history}>
|
<Router>
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<Provider store={store}>{children}</Provider>
|
<Provider store={store}>{children}</Provider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
@@ -55,7 +52,6 @@ describe('NameChange', () => {
|
|||||||
verified_name: 'edX Verified',
|
verified_name: 'edX Verified',
|
||||||
},
|
},
|
||||||
saveState: null,
|
saveState: null,
|
||||||
intl: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
||||||
@@ -70,9 +66,9 @@ describe('NameChange', () => {
|
|||||||
afterEach(() => jest.clearAllMocks());
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it('renders populated input after clicking continue if verified_name in form data', async () => {
|
it('renders populated input after clicking continue if verified_name in form data', async () => {
|
||||||
const getInput = () => screen.queryByPlaceholderText('Enter the name on your government ID');
|
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...props} />));
|
render(reduxWrapper(<NameChange {...props} />));
|
||||||
expect(getInput()).toBeNull();
|
expect(getInput()).toBeNull();
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
@@ -82,14 +78,14 @@ describe('NameChange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders empty input after clicking continue if verified_name not in form data', async () => {
|
it('renders empty input after clicking continue if verified_name not in form data', async () => {
|
||||||
const getInput = () => screen.queryByPlaceholderText('Enter the name on your government ID');
|
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
|
||||||
const formProps = {
|
const formProps = {
|
||||||
...props,
|
...props,
|
||||||
formValues: {
|
formValues: {
|
||||||
name: 'edx edx',
|
name: 'edx edx',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
render(reduxWrapper(<IntlNameChange {...formProps} />));
|
render(reduxWrapper(<NameChange {...formProps} />));
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
fireEvent.click(continueButton);
|
fireEvent.click(continueButton);
|
||||||
@@ -107,12 +103,12 @@ describe('NameChange', () => {
|
|||||||
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
|
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
|
||||||
};
|
};
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...props} />));
|
render(reduxWrapper(<NameChange {...props} />));
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
fireEvent.click(continueButton);
|
fireEvent.click(continueButton);
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('Enter the name on your government ID');
|
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
|
||||||
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
||||||
|
|
||||||
const submitButton = screen.getByText('Continue');
|
const submitButton = screen.getByText('Continue');
|
||||||
@@ -134,12 +130,12 @@ describe('NameChange', () => {
|
|||||||
targetFormId: 'name',
|
targetFormId: 'name',
|
||||||
};
|
};
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...formProps} />));
|
render(reduxWrapper(<NameChange {...formProps} />));
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
fireEvent.click(continueButton);
|
fireEvent.click(continueButton);
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('Enter the name on your government ID');
|
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
|
||||||
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
||||||
|
|
||||||
const submitButton = screen.getByText('Continue');
|
const submitButton = screen.getByText('Continue');
|
||||||
@@ -150,12 +146,12 @@ describe('NameChange', () => {
|
|||||||
it('does not dispatch action while pending', async () => {
|
it('does not dispatch action while pending', async () => {
|
||||||
props.saveState = 'pending';
|
props.saveState = 'pending';
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...props} />));
|
render(reduxWrapper(<NameChange {...props} />));
|
||||||
|
|
||||||
const continueButton = screen.getByText('Continue');
|
const continueButton = screen.getByText('Continue');
|
||||||
fireEvent.click(continueButton);
|
fireEvent.click(continueButton);
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('Enter the name on your government ID');
|
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
|
||||||
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
||||||
|
|
||||||
const submitButton = screen.getByText('Continue');
|
const submitButton = screen.getByText('Continue');
|
||||||
@@ -166,7 +162,7 @@ describe('NameChange', () => {
|
|||||||
it('routes to IDV when name change request is successful', async () => {
|
it('routes to IDV when name change request is successful', async () => {
|
||||||
props.saveState = 'complete';
|
props.saveState = 'complete';
|
||||||
|
|
||||||
render(reduxWrapper(<IntlNameChange {...props} />));
|
render(reduxWrapper(<NameChange {...props} />));
|
||||||
expect(history.location.pathname).toEqual('/id-verification');
|
expect(window.location.pathname).toEqual('/id-verification');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink } from '@edx/paragon';
|
import { Hyperlink } from '@openedx/paragon';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ const ConfirmationAlert = (props) => {
|
|||||||
|
|
||||||
const technicalSupportLink = (
|
const technicalSupportLink = (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
destination="https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-"
|
destination={getConfig().PASSWORD_RESET_SUPPORT_LINK}
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="account.settings.editable.field.password.reset.button.confirmation.support.link"
|
id="account.settings.editable.field.password.reset.button.confirmation.support.link"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import { StatefulButton } from '@edx/paragon';
|
import { StatefulButton } from '@openedx/paragon';
|
||||||
|
|
||||||
import { resetPassword } from './data/actions';
|
import { resetPassword } from './data/actions';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -10,7 +9,9 @@ import ConfirmationAlert from './ConfirmationAlert';
|
|||||||
import RequestInProgressAlert from './RequestInProgressAlert';
|
import RequestInProgressAlert from './RequestInProgressAlert';
|
||||||
|
|
||||||
const ResetPassword = (props) => {
|
const ResetPassword = (props) => {
|
||||||
const { email, intl, status } = props;
|
const { email, status } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<h6 aria-level="3">
|
<h6 aria-level="3">
|
||||||
@@ -51,7 +52,6 @@ const ResetPassword = (props) => {
|
|||||||
|
|
||||||
ResetPassword.propTypes = {
|
ResetPassword.propTypes = {
|
||||||
email: PropTypes.string,
|
email: PropTypes.string,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
resetPassword: PropTypes.func.isRequired,
|
resetPassword: PropTypes.func.isRequired,
|
||||||
status: PropTypes.string,
|
status: PropTypes.string,
|
||||||
};
|
};
|
||||||
@@ -68,4 +68,4 @@ export default connect(
|
|||||||
{
|
{
|
||||||
resetPassword,
|
resetPassword,
|
||||||
},
|
},
|
||||||
)(injectIntl(ResetPassword));
|
)(ResetPassword);
|
||||||
|
|||||||
65
src/account-settings/reset-password/data/service.test.js
Normal file
65
src/account-settings/reset-password/data/service.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import formurlencoded from 'form-urlencoded';
|
||||||
|
import { handleRequestError } from '../../data/utils';
|
||||||
|
|
||||||
|
import { postResetPassword } from './service';
|
||||||
|
|
||||||
|
jest.mock('@edx/frontend-platform');
|
||||||
|
jest.mock('@edx/frontend-platform/auth');
|
||||||
|
jest.mock('form-urlencoded');
|
||||||
|
jest.mock('../../data/utils');
|
||||||
|
|
||||||
|
describe('postResetPassword', () => {
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
getConfig.mockReturnValue({
|
||||||
|
LMS_BASE_URL: 'http://testserver',
|
||||||
|
});
|
||||||
|
|
||||||
|
getAuthenticatedHttpClient.mockReturnValue({
|
||||||
|
post: mockPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
formurlencoded.mockImplementation(obj => `encoded:${JSON.stringify(obj)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts reset password request with email', async () => {
|
||||||
|
const mockResponse = { data: { success: true, email_sent: true } };
|
||||||
|
mockPost.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await postResetPassword('user@example.com');
|
||||||
|
|
||||||
|
expect(getConfig).toHaveBeenCalled();
|
||||||
|
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||||
|
expect(formurlencoded).toHaveBeenCalledWith({ email: 'user@example.com' });
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'http://testserver/password_reset/',
|
||||||
|
'encoded:{"email":"user@example.com"}',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleRequestError and throws when request fails', async () => {
|
||||||
|
const mockError = new Error('Reset password failed');
|
||||||
|
mockPost.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
handleRequestError.mockImplementation(() => {
|
||||||
|
throw mockError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(postResetPassword('bad@example.com')).rejects.toThrow('Reset password failed');
|
||||||
|
|
||||||
|
expect(handleRequestError).toHaveBeenCalledWith(mockError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
export { default } from './ResetPassword';
|
export { default } from './ResetPassword';
|
||||||
export { default as reducer } from './data/reducers';
|
export { default as reducer } from './data/reducers';
|
||||||
export { RESET_PASSWORD } from './data/actions';
|
export { RESET_PASSWORD } from './data/actions';
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { AsyncActionType } from '../data/utils';
|
|||||||
|
|
||||||
export const FETCH_SITE_LANGUAGES = new AsyncActionType('SITE_LANGUAGE', 'FETCH_SITE_LANGUAGES');
|
export const FETCH_SITE_LANGUAGES = new AsyncActionType('SITE_LANGUAGE', 'FETCH_SITE_LANGUAGES');
|
||||||
|
|
||||||
export const fetchSiteLanguages = () => ({
|
export const fetchSiteLanguages = handleNavigation => ({
|
||||||
type: FETCH_SITE_LANGUAGES.BASE,
|
type: FETCH_SITE_LANGUAGES.BASE,
|
||||||
|
payload: { handleNavigation },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchSiteLanguagesBegin = () => ({
|
export const fetchSiteLanguagesBegin = () => ({
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ const siteLanguageList = [
|
|||||||
name: 'Español (Latinoamérica)',
|
name: 'Español (Latinoamérica)',
|
||||||
released: true,
|
released: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: 'fa-ir',
|
||||||
|
name: 'فارسی',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: 'fr',
|
code: 'fr',
|
||||||
name: 'Français',
|
name: 'Français',
|
||||||
@@ -69,6 +74,61 @@ const siteLanguageList = [
|
|||||||
name: '中文 (简体)',
|
name: '中文 (简体)',
|
||||||
released: true,
|
released: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: 'pt-pt',
|
||||||
|
name: 'Português',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'it-it',
|
||||||
|
name: 'Italian',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'de-de',
|
||||||
|
name: 'German',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'hi',
|
||||||
|
name: 'Hindi',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'fr-ca',
|
||||||
|
name: 'French (CA)',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'te',
|
||||||
|
name: 'తెలుగు',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'da',
|
||||||
|
name: 'dansk',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'el',
|
||||||
|
name: 'Ελληνικά',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'es-es',
|
||||||
|
name: 'Español (España)',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'sw',
|
||||||
|
name: 'Kiswahili',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'tr-tr',
|
||||||
|
name: 'Türkçe (Türkiye)',
|
||||||
|
released: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default siteLanguageList;
|
export default siteLanguageList;
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import {
|
|||||||
import { getSiteLanguageList } from './service';
|
import { getSiteLanguageList } from './service';
|
||||||
import { handleFailure } from '../data/utils';
|
import { handleFailure } from '../data/utils';
|
||||||
|
|
||||||
function* handleFetchSiteLanguages() {
|
function* handleFetchSiteLanguages(action) {
|
||||||
try {
|
try {
|
||||||
yield put(fetchSiteLanguagesBegin());
|
yield put(fetchSiteLanguagesBegin());
|
||||||
const siteLanguageList = yield call(getSiteLanguageList);
|
const siteLanguageList = yield call(getSiteLanguageList);
|
||||||
yield put(fetchSiteLanguagesSuccess(siteLanguageList));
|
yield put(fetchSiteLanguagesSuccess(siteLanguageList));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
yield call(handleFailure, e, fetchSiteLanguagesFailure);
|
yield call(handleFailure, e, action.payload.handleNavigation, fetchSiteLanguagesFailure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ export async function patchPreferences(username, params) {
|
|||||||
|
|
||||||
export async function postSetLang(code) {
|
export async function postSetLang(code) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const url = `${getConfig().LMS_BASE_URL}/i18n/setlang/`;
|
||||||
formData.append('language', code);
|
formData.append('language', code);
|
||||||
|
|
||||||
await getAuthenticatedHttpClient()
|
await getAuthenticatedHttpClient()
|
||||||
.post(`${getConfig().LMS_BASE_URL}/i18n/setlang/`, formData, {
|
.post(url, formData, requestConfig);
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user