Compare commits
1171 Commits
open-relea
...
renovate/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e46c8a2f | ||
|
|
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 | ||
|
|
51b758a18f | ||
|
|
30bd145bdd | ||
|
|
1f15802bc6 | ||
|
|
5ba476a570 | ||
|
|
dfb13c4286 | ||
|
|
aada46f6eb | ||
|
|
8c57140640 | ||
|
|
9e967ba1ea | ||
|
|
8239209cd7 | ||
|
|
65bb042443 | ||
|
|
4dd7529799 | ||
|
|
e6684f5048 | ||
|
|
e276d6a5c4 | ||
|
|
36f1d1dbfb | ||
|
|
6c324e85fc | ||
|
|
16b5d066ff | ||
|
|
6e45abbe8b | ||
|
|
b3e6335396 | ||
|
|
08a2da5459 | ||
|
|
094361c689 | ||
|
|
b7b33ef597 | ||
|
|
afa808ff5d | ||
|
|
5279f2a9c9 | ||
|
|
4cc00fd7e3 | ||
|
|
8815626411 | ||
|
|
db319b6cdf | ||
|
|
50edcb1c50 | ||
|
|
d6519bc825 | ||
|
|
b54aeb9446 | ||
|
|
bb1bd6e648 | ||
|
|
7df9f92dd8 | ||
|
|
3627915985 | ||
|
|
9fe1a04a0a | ||
|
|
7455821500 | ||
|
|
817980be00 | ||
|
|
7d4e31f69d | ||
|
|
34dde09ccc | ||
|
|
aee4e44f8c | ||
|
|
7381cfd3b6 | ||
|
|
1d0bd3986c | ||
|
|
1c0dc36907 | ||
|
|
ac0ab9daea | ||
|
|
0d45d17cd3 | ||
|
|
a6d265b885 | ||
|
|
3508bc6c34 | ||
|
|
28de621fc7 | ||
|
|
e6df5e77ae | ||
|
|
8bc5c1fae8 | ||
|
|
6e48c9d2d1 | ||
|
|
d3469d648f | ||
|
|
cc65ffc96f | ||
|
|
5640fb95c2 | ||
|
|
7bbb889258 | ||
|
|
53b59231cb | ||
|
|
8fb25fd89b | ||
|
|
15d2bf60f9 | ||
|
|
135826bc52 | ||
|
|
d7251e6aec | ||
|
|
0b0846fb00 | ||
|
|
b1cd1b1995 | ||
|
|
50c468857a | ||
|
|
c940d3463c | ||
|
|
376deba866 | ||
|
|
37d0e6e0fb | ||
|
|
9261711d4a | ||
|
|
3e42d42ad7 | ||
|
|
7de4edc002 | ||
|
|
2936498b02 | ||
|
|
44d26c444b | ||
|
|
b1c1c6502d | ||
|
|
21dda3f25b | ||
|
|
f87b5040a3 | ||
|
|
0dc1df07d4 | ||
|
|
efa682092f | ||
|
|
0d9e6f8b87 | ||
|
|
26d2b50859 | ||
|
|
3eb63cd624 | ||
|
|
ba0774c5c4 | ||
|
|
ac47d0b180 | ||
|
|
02038b8ac9 | ||
|
|
458f9f7e3d | ||
|
|
c7d9c270f9 | ||
|
|
d0ecbbfb8a | ||
|
|
22db0d9202 | ||
|
|
34a142f55f | ||
|
|
6e00915f98 | ||
|
|
4cfa1707de | ||
|
|
a721887886 | ||
|
|
9cdbb93bf3 | ||
|
|
f9e7519e26 | ||
|
|
ff8d5a4d09 | ||
|
|
f14c71c4fb | ||
|
|
43caac8430 | ||
|
|
ee1ecb8ab9 | ||
|
|
020aa84986 | ||
|
|
24459daf6d | ||
|
|
587533703e | ||
|
|
866746d1c6 | ||
|
|
4c618a55c0 | ||
|
|
c9f6cf708e | ||
|
|
5f314ee65f | ||
|
|
7e35b23b36 | ||
|
|
842bd11d89 | ||
|
|
b1e11dfb36 | ||
|
|
aa57b69924 | ||
|
|
e7769b37e9 | ||
|
|
fcc7b26c28 | ||
|
|
16d844528d | ||
|
|
223234f623 | ||
|
|
d8a1c0ca8c | ||
|
|
1da8f630eb | ||
|
|
228eec0afa | ||
|
|
cf62b4b82c | ||
|
|
0184c1fa25 | ||
|
|
8ed103b2ad | ||
|
|
84a9de44a5 | ||
|
|
84df0a0b3e | ||
|
|
a3917ae550 | ||
|
|
bfd6a07a2c | ||
|
|
1df570989b | ||
|
|
b7e433876e | ||
|
|
1669d577f6 | ||
|
|
d1ca7decce | ||
|
|
79a43ae713 | ||
|
|
9d0b315714 | ||
|
|
4b2bc11378 | ||
|
|
fc7ce6b91e | ||
|
|
39a25fe5bc | ||
|
|
307cb1541b | ||
|
|
03e026ce4e | ||
|
|
a29876aff0 | ||
|
|
ab77246015 | ||
|
|
572b05e7f1 | ||
|
|
3e4de47ba6 | ||
|
|
6c6cedd422 | ||
|
|
43694921ca | ||
|
|
06ded1e66e | ||
|
|
aba1bb3382 | ||
|
|
d67b880028 | ||
|
|
29692add53 | ||
|
|
7f53bf32ca | ||
|
|
add22d9756 | ||
|
|
bef9bf76fd | ||
|
|
bf6b2fb8b8 | ||
|
|
a77cd6d91a | ||
|
|
9b3f222191 | ||
|
|
90f2ed8393 | ||
|
|
95c53ad380 | ||
|
|
3023cd3d55 | ||
|
|
50f85674b1 | ||
|
|
9437ee36f3 | ||
|
|
463944012c | ||
|
|
2d297aa7be | ||
|
|
1ab5901d24 | ||
|
|
ba678d92f7 | ||
|
|
884651a702 | ||
|
|
a152c631da | ||
|
|
fbe91ce7e4 | ||
|
|
13681c1360 | ||
|
|
43435f8ff3 | ||
|
|
830fb05819 | ||
|
|
f62bd5ad76 | ||
|
|
e2f9edd623 | ||
|
|
0b63613736 | ||
|
|
940828ff18 | ||
|
|
296f68f7dd | ||
|
|
e59ada660c | ||
|
|
c123daacd6 | ||
|
|
7440cd367f | ||
|
|
1d639c4a57 | ||
|
|
6db789d6ac | ||
|
|
4bbff91ad7 | ||
|
|
24e32cd0c5 | ||
|
|
d1548b7287 | ||
|
|
d1de13469f | ||
|
|
62418db3ba | ||
|
|
24f70972d8 | ||
|
|
a7c783888f | ||
|
|
00b1bf58a5 | ||
|
|
b2c08193f7 | ||
|
|
2bbe7dc3f1 | ||
|
|
4d93fd6c0f | ||
|
|
613229bbd1 | ||
|
|
39db13435f | ||
|
|
994f22ab41 | ||
|
|
579654c092 | ||
|
|
6094509679 | ||
|
|
82170f383f | ||
|
|
addc3640cc | ||
|
|
b9c0069117 | ||
|
|
11c31ba216 | ||
|
|
ccd2a5c074 | ||
|
|
ff2e86fa13 | ||
|
|
f71033232e | ||
|
|
1ff20786ff | ||
|
|
232bead0c9 | ||
|
|
bd0c879a51 | ||
|
|
a52e1ed2a5 | ||
|
|
b8356bc962 | ||
|
|
240e589310 | ||
|
|
152d3230d1 | ||
|
|
95bd1cc66f | ||
|
|
5db6d2b319 | ||
|
|
368bc00321 | ||
|
|
fa4c5ef872 | ||
|
|
01f7180bb9 | ||
|
|
ce79213b21 | ||
|
|
3870732da8 | ||
|
|
60d7a469a7 | ||
|
|
921107354c | ||
|
|
9ea6120ded | ||
|
|
31493af69a | ||
|
|
732cf11d96 | ||
|
|
42c1c49a85 | ||
|
|
976652539c | ||
|
|
1e0b273579 | ||
|
|
5995434b97 | ||
|
|
2a854d53b6 | ||
|
|
695a9e4297 | ||
|
|
8a79046f66 | ||
|
|
27b2d3f853 | ||
|
|
11a6d6b0ee | ||
|
|
0fd470ad5e | ||
|
|
251c799e18 | ||
|
|
69b902081e | ||
|
|
b30012fd7b | ||
|
|
8dcf228faa | ||
|
|
3db3f87375 | ||
|
|
6b51999890 | ||
|
|
65b6b30f01 | ||
|
|
00be4845b8 | ||
|
|
4a3270d0a8 | ||
|
|
fa91f25ad3 | ||
|
|
f7bb06e109 | ||
|
|
340ec87522 | ||
|
|
9e6a74c633 | ||
|
|
7c05188e5f | ||
|
|
a19c37fa85 | ||
|
|
600a2b8fe2 | ||
|
|
402dbae44f | ||
|
|
75fddb9b69 | ||
|
|
6fff8630d4 | ||
|
|
06b4fc641e | ||
|
|
19d5411402 | ||
|
|
14160eaeb3 | ||
|
|
b06138f96d | ||
|
|
01d0b3645b | ||
|
|
0a4dcb4eaa | ||
|
|
37e338df0f | ||
|
|
6a0e27c816 | ||
|
|
289341dd91 | ||
|
|
45f9a15885 | ||
|
|
f982187be6 | ||
|
|
9d0d03c402 | ||
|
|
8602561e55 | ||
|
|
b310574f18 | ||
|
|
73badaf916 | ||
|
|
f02cc43078 | ||
|
|
bd5c2343be | ||
|
|
6349d487e4 | ||
|
|
9c6137e668 | ||
|
|
2cf01270d7 | ||
|
|
403df8926d | ||
|
|
4bd96c70af | ||
|
|
d2a835f560 | ||
|
|
d0b5d54d0a | ||
|
|
26299eed65 | ||
|
|
648bea8d84 | ||
|
|
7409f02056 | ||
|
|
f5dd409816 | ||
|
|
6ddac11dc0 | ||
|
|
7019aea4fb | ||
|
|
5424434599 | ||
|
|
8ca9dc78a9 | ||
|
|
30e25b96bb | ||
|
|
1d01abc7da | ||
|
|
917152df22 | ||
|
|
961c0feb78 | ||
|
|
7d57d86729 | ||
|
|
34c5de1340 | ||
|
|
81d604d046 | ||
|
|
e6f7e83cf5 | ||
|
|
a970e17070 | ||
|
|
f471ae0aa7 | ||
|
|
b9efe6faee | ||
|
|
2dbccec1f1 | ||
|
|
9f38b975d9 | ||
|
|
ae355cefcf | ||
|
|
d63dfc929f | ||
|
|
64be9edeac | ||
|
|
5f4f82eae1 | ||
|
|
c8c7352549 | ||
|
|
88206e4282 | ||
|
|
d8e23b1a02 | ||
|
|
5db21d2483 | ||
|
|
526d6114f2 | ||
|
|
0cc38e2dc6 | ||
|
|
380ca7c816 | ||
|
|
bcb20234ab | ||
|
|
87ff50ace8 | ||
|
|
b409aff6b4 | ||
|
|
2b67d037bf | ||
|
|
4137996a91 | ||
|
|
1319bd6377 | ||
|
|
a579e86e98 | ||
|
|
74bb2fb45f | ||
|
|
11dbbad20b | ||
|
|
a0227f1dbc | ||
|
|
f6a7a6063c | ||
|
|
0aa02687e6 | ||
|
|
303f6a5d3f | ||
|
|
b262f42c8d | ||
|
|
b92794b72c | ||
|
|
3f754fa114 | ||
|
|
bf274e5186 | ||
|
|
be9cf70c5c | ||
|
|
c00ea15920 | ||
|
|
5a8bd309e7 | ||
|
|
d83ea54272 | ||
|
|
eae18d9c63 | ||
|
|
a18da61cec | ||
|
|
80d5fd2a34 | ||
|
|
d4e9ba0420 | ||
|
|
5add376c31 | ||
|
|
559c9aa1a9 | ||
|
|
ea8a6d29d0 | ||
|
|
4689482137 | ||
|
|
d48be79e53 | ||
|
|
ed94cc68e3 | ||
|
|
05740b37ff | ||
|
|
b2c8164cd1 | ||
|
|
47b369f797 | ||
|
|
88239f2700 | ||
|
|
602c4b484c | ||
|
|
45ec573ff9 | ||
|
|
e0befb8b60 | ||
|
|
9f4d944670 | ||
|
|
60d26649dd | ||
|
|
55f25c73ca | ||
|
|
29dbdf6ad0 | ||
|
|
5bfa834563 | ||
|
|
5e3af50e3b | ||
|
|
e30a20b185 | ||
|
|
2743e05890 | ||
|
|
71c0563e3a | ||
|
|
5d8d327a48 | ||
|
|
15ba7c087e | ||
|
|
6df7ad243b | ||
|
|
594d3ff9f1 | ||
|
|
7bb9d09dae | ||
|
|
0e6233b693 | ||
|
|
22087d2d2c | ||
|
|
64798cdc80 | ||
|
|
f53ba967e5 | ||
|
|
2deb47d542 | ||
|
|
1b9dd3bdf5 | ||
|
|
fd3c8ede6d | ||
|
|
c1fdace72d | ||
|
|
4ca4d55796 | ||
|
|
2da606bf6f | ||
|
|
997a3c0b98 | ||
|
|
f627257a1c | ||
|
|
9aa2a816b4 | ||
|
|
fc54dd528f | ||
|
|
53fc1b325c | ||
|
|
48b02cd2de | ||
|
|
490274b2ed | ||
|
|
10dc9aabde | ||
|
|
74ec75781e | ||
|
|
c428d3044f | ||
|
|
d9777fe48e | ||
|
|
6b7ab05dd5 | ||
|
|
ce79cd7f5a | ||
|
|
b8ab0a2150 | ||
|
|
56569c717c | ||
|
|
45511860c2 | ||
|
|
11f7d56e75 | ||
|
|
f19380ebac | ||
|
|
5eb43871c7 | ||
|
|
a2388bffc2 | ||
|
|
ebe6af0913 | ||
|
|
fd6ba7847a | ||
|
|
f051905da1 | ||
|
|
5eddb35e0b | ||
|
|
0196245c13 | ||
|
|
0c53a29094 | ||
|
|
60643f6215 | ||
|
|
b0aa91fc98 | ||
|
|
09813fa689 | ||
|
|
b05a40d0aa | ||
|
|
1a76468587 | ||
|
|
93ef8d2b04 | ||
|
|
98efe2649a | ||
|
|
80cc8f156f | ||
|
|
1364ca5711 | ||
|
|
c337e03a4d |
56
.env
56
.env
@@ -1,17 +1,39 @@
|
||||
ACCESS_TOKEN_COOKIE_NAME=null
|
||||
BASE_URL=null
|
||||
CREDENTIALS_BASE_URL=null
|
||||
CSRF_TOKEN_API_PATH=null
|
||||
ECOMMERCE_BASE_URL=null
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=null
|
||||
LMS_BASE_URL=null
|
||||
LOGIN_URL=null
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=null
|
||||
NODE_ENV=null
|
||||
ORDER_HISTORY_URL=null
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
SUPPORT_URL=null
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
ACCOUNT_PROFILE_URL=''
|
||||
BASE_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
FAVICON_URL=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=''
|
||||
LMS_BASE_URL=''
|
||||
LOGIN_URL=''
|
||||
LOGO_TRADEMARK_URL=''
|
||||
LOGO_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
SHOW_PUSH_CHANNEL=''
|
||||
SHOW_EMAIL_CHANNEL=''
|
||||
LOGOUT_URL=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
NODE_ENV='production'
|
||||
ORDER_HISTORY_URL=''
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
STUDIO_BASE_URL=''
|
||||
SUPPORT_URL=''
|
||||
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,20 +1,40 @@
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
ACCOUNT_PROFILE_URL='http://localhost:1995'
|
||||
BASE_URL='localhost:1997'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:5335'
|
||||
NODE_ENV='development'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=1997
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=localhost
|
||||
STUDIO_BASE_URL=''
|
||||
SUPPORT_URL='http://localhost:18000/support'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
# Temporary, Remove this once we are ready to release the feature.
|
||||
COACHING_ENABLED=true
|
||||
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={}
|
||||
|
||||
30
.env.test
30
.env.test
@@ -2,17 +2,35 @@ ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:1997'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
NODE_ENV=null
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:5335'
|
||||
NODE_ENV=''
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PUBLISHER_BASE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=localhost
|
||||
STUDIO_BASE_URL=''
|
||||
SUPPORT_URL='http://localhost:18000/support'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
COACHING_ENABLED=''
|
||||
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/
|
||||
__mocks__/
|
||||
__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');
|
||||
|
||||
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
|
||||
|
||||
29
.github/workflows/ci.yml
vendored
Normal file
29
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
npm-test:
|
||||
- i18n_extract
|
||||
- lint
|
||||
- test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- run: make requirements
|
||||
- run: make test NPM_TESTS=build
|
||||
- run: make test NPM_TESTS=${{ matrix.npm-test }}
|
||||
- name: Coverage
|
||||
if: matrix.npm-test == 'test'
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
10
.github/workflows/commitlint.yml
vendored
Normal file
10
.github/workflows/commitlint.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Run commitlint on the commit messages in a pull request.
|
||||
|
||||
name: Lint Commit Messages
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
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 }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,3 +16,5 @@ temp/babel-plugin-react-intl
|
||||
*~
|
||||
/temp
|
||||
/.vscode
|
||||
/module.config.js
|
||||
src/i18n/messages/
|
||||
15
.travis.yml
15
.travis.yml
@@ -1,15 +0,0 @@
|
||||
language: node_js
|
||||
node_js: 12
|
||||
before_install:
|
||||
- npm install -g npm@6
|
||||
install:
|
||||
- npm ci
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run i18n_extract
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
- npm run is-es5
|
||||
after_success:
|
||||
- codecov
|
||||
@@ -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
|
||||
52
Makefile
Executable file → Normal file
52
Makefile
Executable file → Normal file
@@ -1,17 +1,29 @@
|
||||
transifex_resource = frontend-app-account
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
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 .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
|
||||
requirements:
|
||||
npm install
|
||||
NPM_TESTS=build i18n_extract lint test
|
||||
|
||||
.PHONY: test
|
||||
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
||||
|
||||
.PHONY: test.npm.*
|
||||
test.npm.%: validate-no-uncommitted-package-lock-changes
|
||||
test -d node_modules || $(MAKE) requirements
|
||||
npm run $(*)
|
||||
|
||||
.PHONY: requirements
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements: ## install ci requirements
|
||||
npm ci
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -29,20 +41,18 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
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:
|
||||
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.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
227
README.rst
227
README.rst
@@ -1,55 +1,224 @@
|
||||
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
|
||||
|
||||
####################
|
||||
frontend-app-account
|
||||
====================
|
||||
####################
|
||||
|
||||
This is a micro-frontend application responsible for the display and updating of a user's account information. Please tag **@edx/arch-team** on any PRs or issues.
|
||||
|ci-badge| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
********
|
||||
Purpose
|
||||
********
|
||||
|
||||
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||
This is a micro-frontend application responsible for the display and updating of a user's account information.
|
||||
|
||||
- Start devstack
|
||||
- Log in (http://localhost:18000/login)
|
||||
What is the domain of this MFE?
|
||||
|
||||
Start the development server
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
In this MFE: Private user settings UIs. Public facing profile is in a `separate MFE (Profile) <https://github.com/openedx/frontend-app-profile>`_
|
||||
|
||||
In this project, install requirements and start the development server by running:
|
||||
- Account settings page
|
||||
- IDV (Identity Verification)
|
||||
|
||||
.. code:: bash
|
||||
***************
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
npm install
|
||||
npm start # The server will run on port 1997
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
Once the dev server is up visit http://localhost:1997.
|
||||
`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.
|
||||
|
||||
Configuration and Deployment
|
||||
----------------------------
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
This MFE is configured via node environment variables supplied at build time. See the .env file for the list of required environment variables. Example build syntax with a single environment variable:
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
|
||||
|
||||
Plugins
|
||||
=======
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Environment Variables/Setup Notes
|
||||
=================================
|
||||
|
||||
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:
|
||||
|
||||
``SUPPORT_URL``
|
||||
|
||||
Example: ``https://support.example.com``
|
||||
|
||||
The fully-qualified URL to the support page in the target environment.
|
||||
|
||||
``PASSWORD_RESET_SUPPORT_LINK``
|
||||
|
||||
Examples:
|
||||
|
||||
- ``https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-``
|
||||
|
||||
- ``mailto:support@example.com``
|
||||
|
||||
The fully-qualified URL to the support page or email to request the support from in the target environment.
|
||||
|
||||
``ENABLE_ACCOUNT_DELETION``
|
||||
|
||||
Example: ``'false'`` | ``''`` (empty strings are true)
|
||||
|
||||
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.
|
||||
|
||||
Example build syntax with a single environment variable:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
|
||||
|
||||
For more information see the document: `Configuration documentation`_
|
||||
|
||||
For more information see the document: `Micro-frontend applications in Open
|
||||
edX <https://github.com/edx/edx-developer-docs/blob/5191e800bf16cf42f25c58c58f983bdaf7f9305d/docs/micro-frontends-in-open-edx.rst>`__.
|
||||
.. _Configuration documentation: https://openedx.github.io/frontend-platform/module-Config.html
|
||||
|
||||
Notes
|
||||
-----
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
The production Webpack configuration for this repo uses `Purgecss <https://www.purgecss.com/>`__ to remove unused CSS from the production css file. In ``webpack.prod.config.js`` the Purgecss plugin is configured to scan directories to determine what css selectors should remain. Currently the src/ directory is scanned along with all ``@edx/frontend-component*`` node modules and ``@edx/paragon``. **If you add and use a component in this repo that relies on HTML classes or ids for styling you must add it to the Purgecss configuration or it will be unstyled in the production build.**
|
||||
.. code-block::
|
||||
|
||||
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-account.svg?branch=master
|
||||
:target: https://travis-ci.org/edx/frontend-app-account
|
||||
|
||||
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
|
||||
===========
|
||||
|
||||
None
|
||||
|
||||
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.
|
||||
There may be a replacement for IDV coming down the pipe, so that may be DEPRed.
|
||||
|
||||
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/openedx/edx-developer-docs/actions/workflows/ci.yml/badge.svg
|
||||
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
|
||||
:alt: Continuous Integration
|
||||
.. |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
|
||||
:target: @edx/frontend-app-account
|
||||
.. |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"
|
||||
BIN
docs/images/localhost_preview.png
Normal file
BIN
docs/images/localhost_preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -1,7 +1,7 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFiles: [
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,7 +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: {}
|
||||
owner: edx/arch-team
|
||||
openedx-release: {ref: master}
|
||||
39017
package-lock.json
generated
39017
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
108
package.json
Executable file → Normal file
108
package.json
Executable file → Normal file
@@ -6,44 +6,51 @@
|
||||
"license": "AGPL-3.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/frontend-app-account.git"
|
||||
"url": "git+https://github.com/openedx/frontend-app-account.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint",
|
||||
"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",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"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": {
|
||||
"access": "public"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"dependencies": {
|
||||
"@edx/frontend-component-footer": "10.0.9",
|
||||
"@edx/frontend-component-header": "2.0.5",
|
||||
"@edx/frontend-platform": "1.1.14",
|
||||
"@edx/paragon": "7.1.5",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.8.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.8.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.9",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"classnames": "2.2.6",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^8.0.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "0.2.6",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@tensorflow-models/blazeface": "0.1.0",
|
||||
"@tensorflow/tfjs-converter": "4.22.0",
|
||||
"@tensorflow/tfjs-core": "4.22.0",
|
||||
"bowser": "2.14.1",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.48.0",
|
||||
"font-awesome": "4.7.0",
|
||||
"form-urlencoded": "4.0.1",
|
||||
"formdata-polyfill": "3.0.19",
|
||||
"history": "4.10.1",
|
||||
"form-urlencoded": "6.1.6",
|
||||
"formdata-polyfill": "4.0.10",
|
||||
"jslib-html5-camera-photo": "3.3.4",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lodash.findindex": "4.6.0",
|
||||
@@ -52,37 +59,36 @@
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.omit": "4.5.0",
|
||||
"lodash.pick": "4.4.0",
|
||||
"lodash.pickby": "4.6.0",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"memoize-one": "5.1.1",
|
||||
"newrelic": "5.13.1",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.10.2",
|
||||
"react-dom": "16.10.2",
|
||||
"react-redux": "7.1.3",
|
||||
"react-router": "5.1.2",
|
||||
"react-router-dom": "5.1.2",
|
||||
"react-router-hash-link": "1.2.2",
|
||||
"react-scrollspy": "3.4.2",
|
||||
"react-transition-group": "4.3.0",
|
||||
"redux": "4.0.5",
|
||||
"redux-devtools-extension": "2.13.8",
|
||||
"long": "5.3.2",
|
||||
"memoize-one": "^6.0.0",
|
||||
"prop-types": "15.8.1",
|
||||
"qs": "6.15.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"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-transition-group": "4.4.5",
|
||||
"redux": "4.2.1",
|
||||
"redux-devtools-extension": "2.13.9",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-saga": "1.1.3",
|
||||
"redux-thunk": "2.3.0",
|
||||
"reselect": "4.0.0",
|
||||
"universal-cookie": "4.0.3"
|
||||
"redux-saga": "1.4.2",
|
||||
"redux-thunk": "2.4.2",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"reselect": "^5.1.1",
|
||||
"universal-cookie": "7.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "2.0.6",
|
||||
"codecov": "3.6.5",
|
||||
"enzyme": "3.10.0",
|
||||
"enzyme-adapter-react-16": "1.15.2",
|
||||
"es-check": "5.0.0",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.0.9",
|
||||
"purgecss-webpack-plugin": "1.6.0",
|
||||
"react-test-renderer": "16.8.6",
|
||||
"reactifex": "1.1.1",
|
||||
"redux-mock-store": "1.5.4"
|
||||
"@edx/browserslist-config": "1.5.1",
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "1.5.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,148 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Account | edX</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<title>Account | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
|
||||
type="image/x-icon"
|
||||
/>
|
||||
<% if (process.env.OPTIMIZELY_PROJECT_ID) { %>
|
||||
<script src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"></script>
|
||||
<% } %>
|
||||
</head>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base"
|
||||
"config:base",
|
||||
"schedule:weekly",
|
||||
":automergeLinters",
|
||||
":automergeMinor",
|
||||
":automergeTesters",
|
||||
":enableVulnerabilityAlerts",
|
||||
":rebaseStalePrs",
|
||||
":semanticCommits",
|
||||
":updateNotScheduled"
|
||||
],
|
||||
"patch": {
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDepTypes": [
|
||||
"devDependencies"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"lockFileMaintenance",
|
||||
"minor",
|
||||
"patch",
|
||||
"pin"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"timezone": "America/New_York"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { getConfig, history, getQueryParameters } from '@edx/frontend-platform';
|
||||
import { getConfig, getQueryParameters } from '@edx/frontend-platform';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -13,50 +13,72 @@ import {
|
||||
getCountryList,
|
||||
getLanguageList,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import {
|
||||
Container, Hyperlink, Icon, Alert,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Error, WarningFilled } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from './AccountSettingsPage.messages';
|
||||
import { fetchSettings, saveSettings, updateDraft } from './data/actions';
|
||||
import {
|
||||
fetchSettings,
|
||||
saveMultipleSettings,
|
||||
saveSettings,
|
||||
updateDraft,
|
||||
beginNameChange,
|
||||
} from './data/actions';
|
||||
import { accountSettingsPageSelector } from './data/selectors';
|
||||
import PageLoading from './PageLoading';
|
||||
import Alert from './Alert';
|
||||
import JumpNav from './JumpNav';
|
||||
import DeleteAccount from './delete-account';
|
||||
import EditableField from './EditableField';
|
||||
import EditableSelectField from './EditableSelectField';
|
||||
import ResetPassword from './reset-password';
|
||||
import NameChange from './name-change';
|
||||
import ThirdPartyAuth from './third-party-auth';
|
||||
import BetaLanguageBanner from './BetaLanguageBanner';
|
||||
import EmailField from './EmailField';
|
||||
import OneTimeDismissibleAlert from './OneTimeDismissibleAlert';
|
||||
import DOBModal from './DOBForm';
|
||||
import {
|
||||
YEAR_OF_BIRTH_OPTIONS,
|
||||
EDUCATION_LEVELS,
|
||||
GENDER_OPTIONS,
|
||||
COUNTRY_WITH_STATES,
|
||||
COPPA_COMPLIANCE_YEAR,
|
||||
WORK_EXPERIENCE_OPTIONS,
|
||||
getStatesList,
|
||||
FIELD_LABELS,
|
||||
} from './data/constants';
|
||||
import { fetchSiteLanguages } from './site-language';
|
||||
import CoachingToggle from './coaching/CoachingToggle';
|
||||
import { fetchNotificationPreferences } from '../notification-preferences/data/thunks';
|
||||
import NotificationSettings from '../notification-preferences/NotificationSettings';
|
||||
import { withLocation, withNavigate } from './hoc';
|
||||
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
|
||||
|
||||
class AccountSettingsPage extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
// If there is a "duplicate_provider" query parameter, that's the backend's
|
||||
// way of telling us that the provider account the user tried to link is already linked
|
||||
// to another Open edX account. We use this to display a message to that effect, and remove the
|
||||
// parameter from the URL.
|
||||
const duplicateTpaProvider = getQueryParameters().duplicate_provider;
|
||||
if (duplicateTpaProvider !== undefined) {
|
||||
history.replace(history.location.pathname);
|
||||
}
|
||||
this.state = {
|
||||
duplicateTpaProvider,
|
||||
};
|
||||
|
||||
this.navLinkRefs = {
|
||||
'#basic-information': React.createRef(),
|
||||
'#profile-information': React.createRef(),
|
||||
'#social-media': React.createRef(),
|
||||
'#notifications': React.createRef(),
|
||||
'#site-preferences': React.createRef(),
|
||||
'#linked-accounts': React.createRef(),
|
||||
'#delete-account': React.createRef(),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchNotificationPreferences();
|
||||
this.props.fetchSettings();
|
||||
this.props.fetchSiteLanguages();
|
||||
this.props.fetchSiteLanguages(this.props.navigate);
|
||||
sendTrackingLogEvent('edx.user.settings.viewed', {
|
||||
page: 'account',
|
||||
visibility: null,
|
||||
@@ -64,6 +86,20 @@ class AccountSettingsPage extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.loading && !prevProps.loaded && this.props.loaded) {
|
||||
const locationHash = global.location.hash;
|
||||
// Check for the locationHash in the URL and then scroll to it if it is in the
|
||||
// NavLinks list
|
||||
if (typeof locationHash !== 'string') {
|
||||
return;
|
||||
}
|
||||
if (Object.keys(this.navLinkRefs).includes(locationHash) && this.navLinkRefs[locationHash].current) {
|
||||
window.scrollTo(0, this.navLinkRefs[locationHash].current.offsetTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: We need 'locale' for the memoization in getLocalizedTimeZoneOptions. Don't remove it!
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
getLocalizedTimeZoneOptions = memoize((timeZoneOptions, countryTimeZoneOptions, locale) => {
|
||||
@@ -88,7 +124,15 @@ class AccountSettingsPage extends React.Component {
|
||||
countryOptions: [{
|
||||
value: '',
|
||||
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
|
||||
}].concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))),
|
||||
}].concat(
|
||||
this.removeDisabledCountries(
|
||||
getCountryList(locale).map(({ code, name }) => ({
|
||||
value: code,
|
||||
label: name,
|
||||
disabled: this.isDisabledCountry(code),
|
||||
})),
|
||||
),
|
||||
),
|
||||
stateOptions: [{
|
||||
value: '',
|
||||
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
|
||||
@@ -109,8 +153,83 @@ class AccountSettingsPage extends React.Component {
|
||||
value: key,
|
||||
label: this.props.intl.formatMessage(messages[`account.settings.field.gender.options.${key || 'empty'}`]),
|
||||
})),
|
||||
workExperienceOptions: WORK_EXPERIENCE_OPTIONS.map(key => ({
|
||||
value: key,
|
||||
label: key === '' ? this.props.intl.formatMessage(messages['account.settings.field.work.experience.options.empty']) : key,
|
||||
})),
|
||||
}));
|
||||
|
||||
canDeleteAccount = () => {
|
||||
const { committedValues } = this.props;
|
||||
return !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country);
|
||||
};
|
||||
|
||||
removeDisabledCountries = (countryList) => {
|
||||
const { countriesCodesList, committedValues } = this.props;
|
||||
const committedCountry = committedValues?.country;
|
||||
|
||||
if (!countriesCodesList.length) {
|
||||
return countryList;
|
||||
}
|
||||
return countryList.filter(({ value }) => value === committedCountry || countriesCodesList.find(x => x === value));
|
||||
};
|
||||
|
||||
handleEditableFieldChange = (name, value) => {
|
||||
this.props.updateDraft(name, value);
|
||||
};
|
||||
|
||||
handleSubmit = (formId, values) => {
|
||||
if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { formValues } = this.props;
|
||||
let extendedProfileObject = {};
|
||||
|
||||
if ('extended_profile' in formValues && formValues.extended_profile.some((field) => field.field_name === formId)) {
|
||||
extendedProfileObject = {
|
||||
extended_profile: formValues.extended_profile.map(field => (field.field_name === formId
|
||||
? { ...field, field_value: values }
|
||||
: field)),
|
||||
};
|
||||
}
|
||||
this.props.saveSettings(formId, values, extendedProfileObject);
|
||||
};
|
||||
|
||||
handleSubmitProfileName = (formId, values) => {
|
||||
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
|
||||
this.props.saveMultipleSettings([
|
||||
{
|
||||
formId,
|
||||
commitValues: values,
|
||||
},
|
||||
{
|
||||
formId: 'useVerifiedNameForCerts',
|
||||
commitValues: this.props.formValues.useVerifiedNameForCerts,
|
||||
},
|
||||
], formId);
|
||||
} else {
|
||||
this.props.saveSettings(formId, values);
|
||||
}
|
||||
};
|
||||
|
||||
handleSubmitVerifiedName = (formId, values) => {
|
||||
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
|
||||
this.props.saveSettings('useVerifiedNameForCerts', this.props.formValues.useVerifiedNameForCerts);
|
||||
}
|
||||
if (values !== this.props.committedValues?.verified_name) {
|
||||
this.props.beginNameChange(formId);
|
||||
} else {
|
||||
this.props.saveSettings(formId, values);
|
||||
}
|
||||
};
|
||||
|
||||
isDisabledCountry = (country) => {
|
||||
const { countriesCodesList } = this.props;
|
||||
|
||||
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
|
||||
};
|
||||
|
||||
isEditable(fieldName) {
|
||||
return !this.props.staticFields.includes(fieldName);
|
||||
}
|
||||
@@ -121,28 +240,27 @@ class AccountSettingsPage extends React.Component {
|
||||
return Boolean(this.props.profileDataManager);
|
||||
}
|
||||
|
||||
handleEditableFieldChange = (name, value) => {
|
||||
this.props.updateDraft(name, value);
|
||||
};
|
||||
|
||||
handleSubmit = (formId, values) => {
|
||||
this.props.saveSettings(formId, values);
|
||||
};
|
||||
|
||||
renderDuplicateTpaProviderMessage() {
|
||||
if (!this.state.duplicateTpaProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If there is a "duplicate_provider" query parameter, that's the backend's
|
||||
// way of telling us that the provider account the user tried to link is already linked
|
||||
// to another user account on the platform. We use this to display a message to that effect,
|
||||
// and remove the parameter from the URL.
|
||||
this.props.navigate(this.props.location, { replace: true });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert className="alert alert-danger" role="alert">
|
||||
<Alert variant="danger">
|
||||
<FormattedMessage
|
||||
id="account.settings.message.duplicate.tpa.provider"
|
||||
defaultMessage="The {provider} account you selected is already linked to another edX account."
|
||||
description="alert message informing the user that the third-party account they attempted to link is already linked to another edX account"
|
||||
defaultMessage="The {provider} account you selected is already linked to another {siteName} account."
|
||||
description="alert message informing the user that the third-party account they attempted to link is already linked to another account"
|
||||
values={{
|
||||
provider: <b>{this.state.duplicateTpaProvider}</b>,
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
@@ -157,7 +275,7 @@ class AccountSettingsPage extends React.Component {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert className="alert alert-primary" role="alert">
|
||||
<Alert variant="info">
|
||||
<FormattedMessage
|
||||
id="account.settings.message.managed.settings"
|
||||
defaultMessage="Your profile settings are managed by {managerTitle}. Contact your administrator or {support} for help."
|
||||
@@ -180,6 +298,160 @@ class AccountSettingsPage extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderFullNameHelpText = (status, proctoredExamId) => {
|
||||
if (!this.props.verifiedNameHistory) {
|
||||
return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text']);
|
||||
}
|
||||
|
||||
let messageString = 'account.settings.field.full.name.help.text';
|
||||
|
||||
if (status === 'submitted') {
|
||||
messageString += '.submitted';
|
||||
if (proctoredExamId) {
|
||||
messageString += '.proctored';
|
||||
}
|
||||
} else {
|
||||
messageString += '.default';
|
||||
}
|
||||
|
||||
if (!this.props.committedValues.useVerifiedNameForCerts) {
|
||||
messageString += '.certificate';
|
||||
}
|
||||
|
||||
return this.props.intl.formatMessage(messages[messageString]);
|
||||
};
|
||||
|
||||
renderVerifiedNameSuccessMessage = (verifiedName, created) => {
|
||||
const dateValue = new Date(created).valueOf();
|
||||
const id = `dismissedVerifiedNameSuccessMessage-${verifiedName}-${dateValue}`;
|
||||
|
||||
return (
|
||||
<OneTimeDismissibleAlert
|
||||
id={id}
|
||||
variant="success"
|
||||
icon={CheckCircle}
|
||||
header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message.header'])}
|
||||
body={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message'])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderVerifiedNameFailureMessage = (verifiedName, created) => {
|
||||
const dateValue = new Date(created).valueOf();
|
||||
const id = `dismissedVerifiedNameFailureMessage-${verifiedName}-${dateValue}`;
|
||||
|
||||
return (
|
||||
<OneTimeDismissibleAlert
|
||||
id={id}
|
||||
variant="danger"
|
||||
icon={Error}
|
||||
header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message.header'])}
|
||||
body={
|
||||
(
|
||||
<div className="d-flex flex-row">
|
||||
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderVerifiedNameSubmittedMessage = (willCertNameChange) => (
|
||||
<Alert
|
||||
variant="warning"
|
||||
icon={WarningFilled}
|
||||
>
|
||||
<Alert.Heading>
|
||||
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.header'])}
|
||||
</Alert.Heading>
|
||||
<p>
|
||||
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message'])}{' '}
|
||||
{
|
||||
willCertNameChange
|
||||
&& this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.certificate'])
|
||||
}
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
renderVerifiedNameMessage = verifiedNameRecord => {
|
||||
const {
|
||||
created,
|
||||
status,
|
||||
profile_name: profileName,
|
||||
verified_name: verifiedName,
|
||||
proctored_exam_attempt_id: proctoredExamId,
|
||||
} = verifiedNameRecord;
|
||||
let willCertNameChange = false;
|
||||
|
||||
if (
|
||||
(
|
||||
// User submitted a profile name change, and uses their profile name on certificates
|
||||
this.props.committedValues.name !== profileName
|
||||
&& !this.props.committedValues.useVerifiedNameForCerts
|
||||
)
|
||||
|| (
|
||||
// User submitted a verified name change, and uses their verified name on certificates
|
||||
this.props.committedValues.name === profileName
|
||||
&& this.props.committedValues.useVerifiedNameForCerts
|
||||
)
|
||||
) {
|
||||
willCertNameChange = true;
|
||||
}
|
||||
|
||||
if (proctoredExamId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return this.renderVerifiedNameSuccessMessage(verifiedName, created);
|
||||
case 'denied':
|
||||
return this.renderVerifiedNameFailureMessage(verifiedName, created);
|
||||
case 'submitted':
|
||||
return this.renderVerifiedNameSubmittedMessage(willCertNameChange);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
renderVerifiedNameIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return (<Icon src={CheckCircle} className="ml-1" style={{ height: '18px', width: '18px', color: 'green' }} />);
|
||||
case 'submitted':
|
||||
return (<Icon src={WarningFilled} className="ml-1" style={{ height: '18px', width: '18px', color: 'yellow' }} />);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
renderVerifiedNameHelpText = (status, proctoredExamId) => {
|
||||
let messageStr = 'account.settings.field.name.verified.help.text';
|
||||
|
||||
// add additional string based on status
|
||||
if (status === 'approved') {
|
||||
messageStr += '.verified';
|
||||
} else if (status === 'submitted') {
|
||||
messageStr += '.submitted';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// add additional string if verified name came from a proctored exam attempt
|
||||
if (proctoredExamId) {
|
||||
messageStr += '.proctored';
|
||||
}
|
||||
|
||||
// add additional string based on certificate name use
|
||||
if (this.props.committedValues.useVerifiedNameForCerts) {
|
||||
messageStr += '.certificate';
|
||||
}
|
||||
|
||||
return this.props.intl.formatMessage(messages[messageStr]);
|
||||
};
|
||||
|
||||
renderEmptyStaticFieldMessage() {
|
||||
if (this.isManagedProfile()) {
|
||||
return this.props.intl.formatMessage(messages['account.settings.static.field.empty'], {
|
||||
@@ -189,8 +461,15 @@ class AccountSettingsPage extends React.Component {
|
||||
return this.props.intl.formatMessage(messages['account.settings.static.field.empty.no.admin']);
|
||||
}
|
||||
|
||||
renderNameChangeModal() {
|
||||
if (this.props.nameChangeModal && this.props.nameChangeModal.formId) {
|
||||
return <NameChange targetFormId={this.props.nameChangeModal.formId} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderSecondaryEmailField(editableFieldProps) {
|
||||
if (!Boolean(this.props.formValues.secondary_email_enabled)) {
|
||||
if (!this.props.formValues.secondary_email_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -220,10 +499,15 @@ class AccountSettingsPage extends React.Component {
|
||||
yearOfBirthOptions,
|
||||
educationLevelOptions,
|
||||
genderOptions,
|
||||
workExperienceOptions,
|
||||
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
|
||||
|
||||
// Show State field only if the country is US (could include Canada later)
|
||||
const showState = this.props.formValues.country == COUNTRY_WITH_STATES;
|
||||
const { country } = this.props.formValues;
|
||||
const showState = country === COUNTRY_WITH_STATES && !this.isDisabledCountry(country);
|
||||
const { verifiedName } = this.props;
|
||||
|
||||
const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience');
|
||||
|
||||
const timeZoneOptions = this.getLocalizedTimeZoneOptions(
|
||||
this.props.timeZoneOptions,
|
||||
@@ -233,110 +517,193 @@ class AccountSettingsPage extends React.Component {
|
||||
|
||||
const hasLinkedTPA = findIndex(this.props.tpaProviders, provider => provider.connected) >= 0;
|
||||
|
||||
// if user is under 13 and does not have cookie set
|
||||
const shouldUpdateDOB = (
|
||||
getConfig().ENABLE_COPPA_COMPLIANCE
|
||||
&& getConfig().ENABLE_DOB_UPDATE
|
||||
&& this.props.formValues.year_of_birth.toString() >= COPPA_COMPLIANCE_YEAR.toString()
|
||||
&& !localStorage.getItem('submittedDOB')
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="account-section" id="basic-information">
|
||||
<h2 className="section-heading">
|
||||
<>
|
||||
{ shouldUpdateDOB
|
||||
&& (
|
||||
<DOBModal
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
)}
|
||||
<div className="account-section pt-3 mb-5" id="basic-information" ref={this.navLinkRefs['#basic-information']}>
|
||||
{
|
||||
this.props.mostRecentVerifiedName
|
||||
&& this.renderVerifiedNameMessage(this.props.mostRecentVerifiedName)
|
||||
}
|
||||
{localStorage.getItem('submittedDOB')
|
||||
&& (
|
||||
<OneTimeDismissibleAlert
|
||||
id="updated-dob"
|
||||
variant="success"
|
||||
icon={CheckCircle}
|
||||
header={this.props.intl.formatMessage(messages['account.settings.field.dob.form.success'])}
|
||||
body=""
|
||||
/>
|
||||
)}
|
||||
|
||||
<h2 className="section-heading h4 mb-3">
|
||||
{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}
|
||||
</h2>
|
||||
<p>{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
|
||||
{this.renderManagedProfileMessage()}
|
||||
|
||||
{this.renderNameChangeModal()}
|
||||
|
||||
<EditableField
|
||||
name="username"
|
||||
type="text"
|
||||
value={this.props.formValues.username}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.username'])}
|
||||
helpText={this.props.intl.formatMessage(messages['account.settings.field.username.help.text'])}
|
||||
helpText={this.props.intl.formatMessage(
|
||||
messages['account.settings.field.username.help.text'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
isEditable={false}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
<EditableField
|
||||
name="name"
|
||||
type="text"
|
||||
value={this.props.formValues.name}
|
||||
value={
|
||||
verifiedName?.status === 'submitted'
|
||||
&& this.props.formValues.pending_name_change
|
||||
? this.props.formValues.pending_name_change
|
||||
: this.props.formValues.name
|
||||
}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
|
||||
emptyLabel={
|
||||
this.isEditable('name') ?
|
||||
this.props.intl.formatMessage(messages['account.settings.field.full.name.empty']) :
|
||||
this.renderEmptyStaticFieldMessage()
|
||||
this.isEditable('name')
|
||||
? this.props.intl.formatMessage(messages['account.settings.field.full.name.empty'])
|
||||
: this.renderEmptyStaticFieldMessage()
|
||||
}
|
||||
helpText={this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])}
|
||||
isEditable={this.isEditable('name')}
|
||||
{...editableFieldProps}
|
||||
helpText={
|
||||
verifiedName
|
||||
? this.renderFullNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)
|
||||
: this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])
|
||||
}
|
||||
isEditable={
|
||||
verifiedName
|
||||
? this.isEditable('verifiedName') && this.isEditable('name')
|
||||
: this.isEditable('name')
|
||||
}
|
||||
isGrayedOut={
|
||||
verifiedName && !this.isEditable('verifiedName')
|
||||
}
|
||||
onChange={this.handleEditableFieldChange}
|
||||
onSubmit={this.handleSubmitProfileName}
|
||||
/>
|
||||
{verifiedName
|
||||
&& (
|
||||
<EditableField
|
||||
name="verified_name"
|
||||
type="text"
|
||||
value={this.props.formValues.verified_name}
|
||||
label={
|
||||
(
|
||||
<div className="d-flex">
|
||||
{this.props.intl.formatMessage(messages['account.settings.field.name.verified'])}
|
||||
{
|
||||
this.renderVerifiedNameIcon(verifiedName.status)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
helpText={this.renderVerifiedNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)}
|
||||
isEditable={this.isEditable('verifiedName')}
|
||||
isGrayedOut={!this.isEditable('verifiedName')}
|
||||
onChange={this.handleEditableFieldChange}
|
||||
onSubmit={this.handleSubmitVerifiedName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EmailField
|
||||
name="email"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.email'])}
|
||||
emptyLabel={
|
||||
this.isEditable('email') ?
|
||||
this.props.intl.formatMessage(messages['account.settings.field.email.empty']) :
|
||||
this.renderEmptyStaticFieldMessage()
|
||||
this.isEditable('email')
|
||||
? this.props.intl.formatMessage(messages['account.settings.field.email.empty'])
|
||||
: this.renderEmptyStaticFieldMessage()
|
||||
}
|
||||
value={this.props.formValues.email}
|
||||
confirmationMessageDefinition={messages['account.settings.field.email.confirmation']}
|
||||
helpText={this.props.intl.formatMessage(messages['account.settings.field.email.help.text'])}
|
||||
helpText={this.props.intl.formatMessage(
|
||||
messages['account.settings.field.email.help.text'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
isEditable={this.isEditable('email')}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
{this.renderSecondaryEmailField(editableFieldProps)}
|
||||
<ResetPassword email={this.props.formValues.email} />
|
||||
<EditableField
|
||||
name="year_of_birth"
|
||||
type="select"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
|
||||
value={this.props.formValues.year_of_birth}
|
||||
options={yearOfBirthOptions}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
<EditableField
|
||||
{(!getConfig().ENABLE_COPPA_COMPLIANCE)
|
||||
&& (
|
||||
<EditableSelectField
|
||||
name="year_of_birth"
|
||||
type="select"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
|
||||
value={this.props.formValues.year_of_birth}
|
||||
options={yearOfBirthOptions}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
)}
|
||||
<EditableSelectField
|
||||
name="country"
|
||||
type="select"
|
||||
value={this.props.formValues.country}
|
||||
options={countryOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
|
||||
emptyLabel={
|
||||
this.isEditable('country') ?
|
||||
this.props.intl.formatMessage(messages['account.settings.field.country.empty']) :
|
||||
this.renderEmptyStaticFieldMessage()
|
||||
this.isEditable('country')
|
||||
? this.props.intl.formatMessage(messages['account.settings.field.country.empty'])
|
||||
: this.renderEmptyStaticFieldMessage()
|
||||
}
|
||||
isEditable={this.isEditable('country')}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
{showState &&
|
||||
<EditableField
|
||||
{showState
|
||||
&& (
|
||||
<EditableSelectField
|
||||
name="state"
|
||||
type="select"
|
||||
value={this.props.formValues.state}
|
||||
options={stateOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.state'])}
|
||||
emptyLabel={
|
||||
this.isEditable('state') ?
|
||||
this.props.intl.formatMessage(messages['account.settings.field.state.empty']) :
|
||||
this.renderEmptyStaticFieldMessage()
|
||||
this.isEditable('state')
|
||||
? this.props.intl.formatMessage(messages['account.settings.field.state.empty'])
|
||||
: this.renderEmptyStaticFieldMessage()
|
||||
}
|
||||
isEditable={this.isEditable('state')}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="account-section" id="profile-information">
|
||||
<h2 className="section-heading">
|
||||
<div className="account-section pt-3 mb-5" id="profile-information" ref={this.navLinkRefs['#profile-information']}>
|
||||
<h2 className="section-heading h4 mb-3">
|
||||
{this.props.intl.formatMessage(messages['account.settings.section.profile.information'])}
|
||||
</h2>
|
||||
|
||||
<EditableField
|
||||
<EditableSelectField
|
||||
name="level_of_education"
|
||||
type="select"
|
||||
value={this.props.formValues.level_of_education}
|
||||
options={educationLevelOptions}
|
||||
options={getConfig().ENABLE_COPPA_COMPLIANCE
|
||||
? educationLevelOptions.filter(option => option.value !== 'el')
|
||||
: educationLevelOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.education.empty'])}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
<EditableField
|
||||
<EditableSelectField
|
||||
name="gender"
|
||||
type="select"
|
||||
value={this.props.formValues.gender}
|
||||
@@ -345,7 +712,19 @@ class AccountSettingsPage extends React.Component {
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.gender.empty'])}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
<EditableField
|
||||
{hasWorkExperience
|
||||
&& (
|
||||
<EditableSelectField
|
||||
name="work_experience"
|
||||
type="select"
|
||||
value={this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience')?.field_value}
|
||||
options={workExperienceOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.work.experience'])}
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.work.experience.empty'])}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
)}
|
||||
<EditableSelectField
|
||||
name="language_proficiencies"
|
||||
type="select"
|
||||
value={this.props.formValues.language_proficiencies}
|
||||
@@ -354,21 +733,19 @@ class AccountSettingsPage extends React.Component {
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
{getConfig().COACHING_ENABLED &&
|
||||
this.props.formValues.coaching.eligible_for_coaching &&
|
||||
<CoachingToggle
|
||||
name="coaching"
|
||||
phone_number={this.props.formValues.phone_number}
|
||||
coaching={this.props.formValues.coaching}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="account-section" id="social-media">
|
||||
<h2 className="section-heading">
|
||||
<AdditionalProfileFieldsSlot />
|
||||
</div>
|
||||
<div className="account-section pt-3 mb-6" id="social-media">
|
||||
<h2 className="section-heading h4 mb-3">
|
||||
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
|
||||
</h2>
|
||||
<p>{this.props.intl.formatMessage(messages['account.settings.section.social.media.description'])}</p>
|
||||
<p>
|
||||
{this.props.intl.formatMessage(
|
||||
messages['account.settings.section.social.media.description'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
|
||||
<EditableField
|
||||
name="social_link_linkedin"
|
||||
@@ -387,22 +764,25 @@ class AccountSettingsPage extends React.Component {
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
<EditableField
|
||||
name="social_link_twitter"
|
||||
name="social_link_x"
|
||||
type="text"
|
||||
value={this.props.formValues.social_link_twitter}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter.empty'])}
|
||||
value={this.props.formValues.social_link_x}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.xTwitter'])}
|
||||
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.xTwitter.empty'])}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="account-section" id="site-preferences">
|
||||
<h2 className="section-heading">
|
||||
<div className="border border-light-700" />
|
||||
<div className="mt-6" id="notifications" ref={this.navLinkRefs['#notifications']}>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<div className="account-section mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
|
||||
<h2 className="section-heading h4 mb-3">
|
||||
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
||||
</h2>
|
||||
|
||||
<BetaLanguageBanner />
|
||||
<EditableField
|
||||
<EditableSelectField
|
||||
name="siteLanguage"
|
||||
type="select"
|
||||
options={this.props.siteLanguageOptions}
|
||||
@@ -411,7 +791,7 @@ class AccountSettingsPage extends React.Component {
|
||||
helpText={this.props.intl.formatMessage(messages['account.settings.field.site.language.help.text'])}
|
||||
{...editableFieldProps}
|
||||
/>
|
||||
<EditableField
|
||||
<EditableSelectField
|
||||
name="time_zone"
|
||||
type="select"
|
||||
value={this.props.formValues.time_zone}
|
||||
@@ -427,20 +807,27 @@ class AccountSettingsPage extends React.Component {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="account-section" id="linked-accounts">
|
||||
<h2 className="section-heading">{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts'])}</h2>
|
||||
<p>{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts.description'])}</p>
|
||||
<div className="account-section pt-3 mb-5" id="linked-accounts" ref={this.navLinkRefs['#linked-accounts']}>
|
||||
<h2 className="section-heading h4 mb-3">{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts'])}</h2>
|
||||
<p>
|
||||
{this.props.intl.formatMessage(
|
||||
messages['account.settings.section.linked.accounts.description'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<ThirdPartyAuth />
|
||||
</div>
|
||||
|
||||
<div className="account-section" id="delete-account">
|
||||
<DeleteAccount
|
||||
isVerifiedAccount={this.props.isActive}
|
||||
hasLinkedTPA={hasLinkedTPA}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</React.Fragment>
|
||||
{getConfig().ENABLE_ACCOUNT_DELETION && (
|
||||
<div className="account-section pt-3 mb-5" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
|
||||
<DeleteAccount
|
||||
isVerifiedAccount={this.props.isActive}
|
||||
hasLinkedTPA={hasLinkedTPA}
|
||||
canDeleteAccount={this.canDeleteAccount()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -468,7 +855,7 @@ class AccountSettingsPage extends React.Component {
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="page__account-settings container-fluid py-5">
|
||||
<Container className="page__account-settings py-5" size="xl">
|
||||
{this.renderDuplicateTpaProviderMessage()}
|
||||
<h1 className="mb-4">
|
||||
{this.props.intl.formatMessage(messages['account.settings.page.heading'])}
|
||||
@@ -485,7 +872,7 @@ class AccountSettingsPage extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -504,22 +891,36 @@ AccountSettingsPage.propTypes = {
|
||||
name: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
secondary_email: PropTypes.string,
|
||||
secondary_email_enabled: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
country: PropTypes.string,
|
||||
level_of_education: PropTypes.string,
|
||||
gender: PropTypes.string,
|
||||
extended_profile: PropTypes.arrayOf(PropTypes.shape({
|
||||
field_name: PropTypes.string,
|
||||
field_value: PropTypes.string,
|
||||
})),
|
||||
language_proficiencies: PropTypes.string,
|
||||
pending_name_change: PropTypes.string,
|
||||
phone_number: PropTypes.string,
|
||||
social_link_linkedin: PropTypes.string,
|
||||
social_link_facebook: PropTypes.string,
|
||||
social_link_twitter: PropTypes.string,
|
||||
social_link_x: PropTypes.string,
|
||||
time_zone: PropTypes.string,
|
||||
coaching: PropTypes.shape({
|
||||
coaching_consent: PropTypes.bool.isRequired,
|
||||
user: PropTypes.number.isRequired,
|
||||
eligible_for_coaching: PropTypes.bool.isRequired,
|
||||
}),
|
||||
state: PropTypes.string,
|
||||
useVerifiedNameForCerts: PropTypes.bool.isRequired,
|
||||
verified_name: PropTypes.string,
|
||||
}).isRequired,
|
||||
committedValues: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
useVerifiedNameForCerts: PropTypes.bool,
|
||||
verified_name: PropTypes.string,
|
||||
country: PropTypes.string,
|
||||
}),
|
||||
drafts: PropTypes.shape({}),
|
||||
formErrors: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
siteLanguage: PropTypes.shape({
|
||||
previousValue: PropTypes.string,
|
||||
draft: PropTypes.string,
|
||||
@@ -543,15 +944,58 @@ AccountSettingsPage.propTypes = {
|
||||
})),
|
||||
fetchSiteLanguages: PropTypes.func.isRequired,
|
||||
updateDraft: PropTypes.func.isRequired,
|
||||
saveMultipleSettings: PropTypes.func.isRequired,
|
||||
saveSettings: PropTypes.func.isRequired,
|
||||
fetchSettings: PropTypes.func.isRequired,
|
||||
tpaProviders: PropTypes.arrayOf(PropTypes.object),
|
||||
beginNameChange: PropTypes.func.isRequired,
|
||||
fetchNotificationPreferences: PropTypes.func.isRequired,
|
||||
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
|
||||
connected: PropTypes.bool,
|
||||
})),
|
||||
nameChangeModal: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
formId: PropTypes.string,
|
||||
}),
|
||||
PropTypes.bool,
|
||||
]),
|
||||
verifiedName: PropTypes.shape({
|
||||
verified_name: PropTypes.string,
|
||||
status: PropTypes.string,
|
||||
proctored_exam_attempt_id: PropTypes.number,
|
||||
}),
|
||||
mostRecentVerifiedName: PropTypes.shape({
|
||||
verified_name: PropTypes.string,
|
||||
status: PropTypes.string,
|
||||
proctored_exam_attempt_id: PropTypes.number,
|
||||
}),
|
||||
verifiedNameHistory: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
verified_name: PropTypes.string,
|
||||
status: PropTypes.string,
|
||||
proctored_exam_attempt_id: PropTypes.number,
|
||||
}),
|
||||
),
|
||||
navigate: PropTypes.func.isRequired,
|
||||
location: PropTypes.string.isRequired,
|
||||
countriesCodesList: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
AccountSettingsPage.defaultProps = {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadingError: null,
|
||||
committedValues: {
|
||||
useVerifiedNameForCerts: false,
|
||||
verified_name: null,
|
||||
country: '',
|
||||
},
|
||||
drafts: {},
|
||||
formErrors: {},
|
||||
siteLanguage: null,
|
||||
siteLanguageOptions: [],
|
||||
timeZoneOptions: [],
|
||||
@@ -561,11 +1005,19 @@ AccountSettingsPage.defaultProps = {
|
||||
tpaProviders: [],
|
||||
isActive: true,
|
||||
secondary_email_enabled: false,
|
||||
nameChangeModal: {} || false,
|
||||
verifiedName: null,
|
||||
mostRecentVerifiedName: {},
|
||||
verifiedNameHistory: [],
|
||||
countriesCodesList: [],
|
||||
};
|
||||
|
||||
export default connect(accountSettingsPageSelector, {
|
||||
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
|
||||
fetchNotificationPreferences,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
saveMultipleSettings,
|
||||
updateDraft,
|
||||
fetchSiteLanguages,
|
||||
})(injectIntl(AccountSettingsPage));
|
||||
beginNameChange,
|
||||
})(injectIntl(AccountSettingsPage))));
|
||||
|
||||
@@ -58,7 +58,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'account.settings.section.linked.accounts.description': {
|
||||
id: 'account.settings.section.linked.accounts.description',
|
||||
defaultMessage: 'You can link your identity accounts to simplify signing in to edX.',
|
||||
defaultMessage: 'You can link your identity accounts to simplify signing in to {siteName}.',
|
||||
description: 'The linked accounts section heading description.',
|
||||
},
|
||||
'account.settings.field.username': {
|
||||
@@ -68,7 +68,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'account.settings.field.username.help.text': {
|
||||
id: 'account.settings.field.username.help.text',
|
||||
defaultMessage: 'The name that identifies you on edX. You cannot change your username.',
|
||||
defaultMessage: 'The name that identifies you on {siteName}. You cannot change your username.',
|
||||
description: 'Help text for the account settings username field.',
|
||||
},
|
||||
'account.settings.field.full.name': {
|
||||
@@ -86,6 +86,121 @@ const messages = defineMessages({
|
||||
defaultMessage: 'The name that is used for ID verification and that appears on your certificates.',
|
||||
description: 'Help text for the account settings name field.',
|
||||
},
|
||||
'account.settings.field.full.name.help.text.default': {
|
||||
id: 'account.settings.field.full.name.help.text.default',
|
||||
defaultMessage: 'The name that appears on your public profile.',
|
||||
description: 'Help text for the account settings name field.',
|
||||
},
|
||||
'account.settings.field.full.name.help.text.default.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.',
|
||||
description: 'Help text for the account settings name field.',
|
||||
},
|
||||
'account.settings.field.name.verified': {
|
||||
id: 'account.settings.field.name.verified',
|
||||
defaultMessage: 'Verified name',
|
||||
description: 'Label for account settings verified name field.',
|
||||
},
|
||||
'account.settings.field.name.verified.help.text.verified': {
|
||||
id: 'account.settings.field.name.verified.help.text.verified',
|
||||
defaultMessage: 'This name has been verified by photo ID.',
|
||||
description: 'Help text for the account settings verified name field when the name is verified.',
|
||||
},
|
||||
'account.settings.field.name.verified.help.text.verified.proctored': {
|
||||
id: 'account.settings.field.name.verified.help.text.verified.proctored',
|
||||
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.',
|
||||
},
|
||||
'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': {
|
||||
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.',
|
||||
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': {
|
||||
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.',
|
||||
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.help.text.submitted.proctored.certificate': {
|
||||
id: 'account.settings.field.name.verified.help.text.submitted.proctored.certificate',
|
||||
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: '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': {
|
||||
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.',
|
||||
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': {
|
||||
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.',
|
||||
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': {
|
||||
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.',
|
||||
description: 'The body of the success alert indicating that a user\'s name has been verified',
|
||||
},
|
||||
'account.settings.field.name.verified.success.message.header': {
|
||||
id: 'account.settings.field.name.verified.success.message.header',
|
||||
defaultMessage: 'Your name change request is complete!',
|
||||
description: 'The header of the success alert indicating that a user\'s name has been verified',
|
||||
},
|
||||
'account.settings.field.name.verified.failure.message': {
|
||||
id: 'account.settings.field.name.verified.failure.message',
|
||||
defaultMessage: 'Your most recent identity verification attempt did not pass. Related account settings have been restored.',
|
||||
description: 'The body of the failure alert indicating that a user\'s name was not able to be verified',
|
||||
},
|
||||
'account.settings.field.name.verified.failure.message.header': {
|
||||
id: 'account.settings.field.name.verified.failure.message.header',
|
||||
defaultMessage: 'We were not able to verify your identity.',
|
||||
description: 'The header of the failure alert indicating that a user\'s name was not able to be verified',
|
||||
},
|
||||
'account.settings.field.name.verified.failure.message.help.link': {
|
||||
id: 'account.settings.field.name.verified.failure.message.help.link',
|
||||
defaultMessage: 'Learn more about ID verification',
|
||||
description: 'The text of the button displayed when a user\'s name was not able to be verified, intended to direct the user to a help article about ID verification.',
|
||||
},
|
||||
'account.settings.field.name.verified.submitted.message': {
|
||||
id: 'account.settings.field.name.verified.submitted.message',
|
||||
defaultMessage: 'Your identity verification request has been submitted and usually takes between 24 and 48 hours to complete.',
|
||||
description: 'The body of the submitted alert indicating that a user\'s name has been submitted for verification',
|
||||
},
|
||||
'account.settings.field.name.verified.submitted.message.certificate': {
|
||||
id: 'account.settings.field.name.verified.submitted.message.certificate',
|
||||
defaultMessage: 'When your request is approved, your updated name will appear on all associated certificates and public-facing records.',
|
||||
description: 'The body of the submitted alert indicating that a user\'s name will be updated on certificates.',
|
||||
},
|
||||
'account.settings.field.name.verified.submitted.message.header': {
|
||||
id: 'account.settings.field.name.verified.submitted.message.header',
|
||||
defaultMessage: 'Your name change request is almost complete!',
|
||||
description: 'The header of the submitted alert indicating that a user\'s name has been submitted for verification',
|
||||
},
|
||||
'account.settings.field.email': {
|
||||
id: 'account.settings.field.email',
|
||||
defaultMessage: 'Email address (Sign in)',
|
||||
@@ -103,7 +218,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'account.settings.field.email.help.text': {
|
||||
id: 'account.settings.field.email.help.text',
|
||||
defaultMessage: 'You receive messages from edX and course teams at this address.',
|
||||
defaultMessage: 'You receive messages from {siteName} and course teams at this address.',
|
||||
description: 'Help text for the account settings email field.',
|
||||
},
|
||||
'account.settings.field.secondary.email': {
|
||||
@@ -141,6 +256,56 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Select a year of birth',
|
||||
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': {
|
||||
id: 'account.settings.field.country',
|
||||
defaultMessage: 'Country',
|
||||
@@ -236,8 +401,8 @@ const messages = defineMessages({
|
||||
defaultMessage: 'No formal education',
|
||||
description: 'Selected by the user to describe their education.',
|
||||
},
|
||||
'account.settings.field.education.levels.o': {
|
||||
id: 'account.settings.field.education.levels.o',
|
||||
'account.settings.field.education.levels.other': {
|
||||
id: 'account.settings.field.education.levels.other',
|
||||
defaultMessage: 'Other education',
|
||||
description: 'Selected by the user if they have a type of education not described by the other choices.',
|
||||
},
|
||||
@@ -274,19 +439,20 @@ const messages = defineMessages({
|
||||
},
|
||||
'account.settings.field.language.proficiencies': {
|
||||
id: 'account.settings.field.language.proficiencies',
|
||||
defaultMessage: 'Spoken languages',
|
||||
description: 'Label for account settings spoken languages field.',
|
||||
defaultMessage: 'Spoken language',
|
||||
description: 'Label for account settings spoken language field.',
|
||||
},
|
||||
'account.settings.field.language.proficiencies.empty': {
|
||||
id: 'account.settings.field.language.proficiencies.empty',
|
||||
defaultMessage: 'Add a spoken language',
|
||||
description: 'Placeholder for empty account settings spoken languages field.',
|
||||
description: 'Placeholder for empty account settings spoken language field.',
|
||||
},
|
||||
'account.settings.field.language_proficiencies.options.empty': {
|
||||
id: 'account.settings.field.language_proficiencies.options.empty',
|
||||
defaultMessage: 'Select a Language',
|
||||
description: 'Option for an empty value on account settings spoken languages field.',
|
||||
description: 'Option for an empty value on account settings spoken language field.',
|
||||
},
|
||||
|
||||
'account.settings.field.time.zone': {
|
||||
id: 'account.settings.field.time.zone',
|
||||
defaultMessage: 'Time zone',
|
||||
@@ -325,7 +491,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'account.settings.section.social.media.description': {
|
||||
id: 'account.settings.section.social.media.description',
|
||||
defaultMessage: 'Optionally, link your personal accounts to the social media icons on your edX profile.',
|
||||
defaultMessage: 'Optionally, link your personal accounts to the social media icons on your {siteName} profile.',
|
||||
description: 'Section subheader for social media links settings',
|
||||
},
|
||||
'account.settings.field.social.platform.name.linkedin': {
|
||||
@@ -343,15 +509,15 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete My Account',
|
||||
description: 'Header for the user account deletion area',
|
||||
},
|
||||
'account.settings.field.social.platform.name.twitter': {
|
||||
id: 'account.settings.field.social.platform.name.twitter',
|
||||
defaultMessage: 'Twitter',
|
||||
description: 'Label for Twitter',
|
||||
'account.settings.field.social.platform.name.xTwitter': {
|
||||
id: 'account.settings.field.social.platform.name.xTwitter',
|
||||
defaultMessage: 'X (Twitter)',
|
||||
description: 'Label for X (Twitter)',
|
||||
},
|
||||
'account.settings.field.social.platform.name.twitter.empty': {
|
||||
id: 'account.settings.field.social.platform.name.twitter.empty',
|
||||
defaultMessage: 'Add Twitter profile',
|
||||
description: 'Placeholder for an empty Twitter field',
|
||||
'account.settings.field.social.platform.name.xTwitter.empty': {
|
||||
id: 'account.settings.field.social.platform.name.xTwitter.empty',
|
||||
defaultMessage: 'Add X profile',
|
||||
description: 'Placeholder for an empty X field',
|
||||
},
|
||||
|
||||
'account.settings.field.social.platform.name.facebook': {
|
||||
@@ -389,6 +555,26 @@ const messages = defineMessages({
|
||||
defaultMessage: 'No value set.',
|
||||
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;
|
||||
|
||||
@@ -2,20 +2,16 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
||||
function Alert(props) {
|
||||
return (
|
||||
<div className={classNames('alert d-flex align-items-start', props.className)}>
|
||||
<div>
|
||||
{props.icon}
|
||||
</div>
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
const Alert = (props) => (
|
||||
<div className={classNames('alert d-flex align-items-start', props.className)}>
|
||||
<div>
|
||||
{props.icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Alert.propTypes = {
|
||||
className: PropTypes.string,
|
||||
@@ -29,5 +25,4 @@ Alert.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
|
||||
export default Alert;
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button, Hyperlink } from '@edx/paragon';
|
||||
import { Button, Hyperlink } from '@openedx/paragon';
|
||||
|
||||
import { betaLanguageBannerSelector } from './data/selectors';
|
||||
import messages from './AccountSettingsPage.messages';
|
||||
@@ -49,6 +49,9 @@ class BetaLanguageBanner extends React.Component {
|
||||
|
||||
render() {
|
||||
const savedLanguage = this.getSiteLanguageEntry(this.context.locale);
|
||||
if (!savedLanguage) {
|
||||
return null;
|
||||
}
|
||||
const isSavedLanguageReleased = savedLanguage.released === true;
|
||||
const noPreviousLanguageSet = this.props.siteLanguage.previousValue === null;
|
||||
if (isSavedLanguageReleased || noPreviousLanguageSet) {
|
||||
@@ -65,7 +68,7 @@ class BetaLanguageBanner extends React.Component {
|
||||
})}
|
||||
</p>
|
||||
<div>
|
||||
<Button onClick={this.handleRevertLanguage} className="btn btn-primary mr-2">
|
||||
<Button onClick={this.handleRevertLanguage} className="mr-2">
|
||||
{this.props.intl.formatMessage(
|
||||
messages['account.settings.banner.beta.language.action.switch.back'],
|
||||
{ previous_language: previousLanguage.name },
|
||||
|
||||
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,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Input, StatefulButton, ValidationFormGroup } from '@edx/paragon';
|
||||
import classNames from 'classnames';
|
||||
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';
|
||||
|
||||
@@ -14,16 +16,16 @@ import {
|
||||
closeForm,
|
||||
} from './data/actions';
|
||||
import { editableFieldSelector } from './data/selectors';
|
||||
import CertificatePreference from './certificate-preference/CertificatePreference';
|
||||
|
||||
|
||||
function EditableField(props) {
|
||||
const EditableField = (props) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
emptyLabel,
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
userSuppliedValue,
|
||||
saveState,
|
||||
error,
|
||||
confirmationMessageDefinition,
|
||||
@@ -35,10 +37,11 @@ function EditableField(props) {
|
||||
onChange,
|
||||
isEditing,
|
||||
isEditable,
|
||||
intl,
|
||||
isGrayedOut,
|
||||
...others
|
||||
} = props;
|
||||
const id = `field-${name}`;
|
||||
const intl = useIntl();
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -59,29 +62,35 @@ function EditableField(props) {
|
||||
|
||||
const renderEmptyLabel = () => {
|
||||
if (isEditable) {
|
||||
return <Button onClick={handleEdit} className="btn-link p-0">{emptyLabel}</Button>;
|
||||
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();
|
||||
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) return selectedOption.label;
|
||||
if (userSuppliedValue) {
|
||||
finalValue += `: ${userSuppliedValue}`;
|
||||
}
|
||||
|
||||
return rawValue;
|
||||
return finalValue;
|
||||
};
|
||||
|
||||
const renderConfirmationMessage = () => {
|
||||
if (!confirmationMessageDefinition || !confirmationValue) return null;
|
||||
return intl.formatMessage(confirmationMessageDefinition, {
|
||||
value: confirmationValue,
|
||||
});
|
||||
if (!confirmationMessageDefinition || !confirmationValue) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span data-testid="editable-field-confirmation">
|
||||
{intl.formatMessage(confirmationMessageDefinition, {
|
||||
value: confirmationValue,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -89,83 +98,89 @@ function EditableField(props) {
|
||||
expression={isEditing ? 'editing' : 'default'}
|
||||
cases={{
|
||||
editing: (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ValidationFormGroup
|
||||
for={id}
|
||||
invalid={error != null}
|
||||
invalidMessage={error}
|
||||
helpText={helpText}
|
||||
>
|
||||
<label className="h6 d-block" htmlFor={id}>{label}</label>
|
||||
<Input
|
||||
name={name}
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
{...others}
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<p>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
className="btn-primary 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
|
||||
onClick={handleCancel}
|
||||
className="btn-outline-primary"
|
||||
<>
|
||||
<form onSubmit={handleSubmit} data-testid="editable-field-form">
|
||||
<Form.Group
|
||||
controlId={id}
|
||||
isInvalid={error != null}
|
||||
>
|
||||
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
<Form.Label size="sm" className="h6 d-block" htmlFor={id}>{label}</Form.Label>
|
||||
<Form.Control
|
||||
data-hj-suppress
|
||||
name={name}
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
data-testid="editable-field-textbox"
|
||||
{...others}
|
||||
/>
|
||||
{!!helpText && <Form.Text>{helpText}</Form.Text>}
|
||||
{error != null && <Form.Control.Feedback hasIcon={false} data-testid="editable-field-error">{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={[]}
|
||||
data-testid="editable-field-save"
|
||||
/>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={handleCancel}
|
||||
data-testid="editable-field-cancel"
|
||||
data-clicked="cancel"
|
||||
>
|
||||
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
{['name', 'verified_name'].includes(name) && (
|
||||
<CertificatePreference fieldName={name} data-testid="editable-field-certificate-preference" />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
default: (
|
||||
<div className="form-group">
|
||||
<div className="d-flex align-items-start">
|
||||
<h6 aria-level="3">{label}</h6>
|
||||
{isEditable ? (
|
||||
<Button onClick={handleEdit} className="ml-3 btn-link">
|
||||
<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'])}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p>{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>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
EditableField.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]),
|
||||
emptyLabel: PropTypes.node,
|
||||
type: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
})),
|
||||
userSuppliedValue: PropTypes.string,
|
||||
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
||||
error: PropTypes.string,
|
||||
confirmationMessageDefinition: PropTypes.shape({
|
||||
@@ -181,12 +196,11 @@ EditableField.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditing: PropTypes.bool,
|
||||
isEditable: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
isGrayedOut: PropTypes.bool,
|
||||
};
|
||||
|
||||
EditableField.defaultProps = {
|
||||
value: undefined,
|
||||
options: undefined,
|
||||
saveState: undefined,
|
||||
label: undefined,
|
||||
emptyLabel: undefined,
|
||||
@@ -196,10 +210,11 @@ EditableField.defaultProps = {
|
||||
helpText: undefined,
|
||||
isEditing: false,
|
||||
isEditable: true,
|
||||
isGrayedOut: false,
|
||||
userSuppliedValue: undefined,
|
||||
};
|
||||
|
||||
|
||||
export default connect(editableFieldSelector, {
|
||||
onEdit: openForm,
|
||||
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,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Button, StatefulButton, Input, ValidationFormGroup } from '@edx/paragon';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, StatefulButton, Form, Tooltip, OverlayTrigger,
|
||||
} from '@openedx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
@@ -16,8 +17,7 @@ import {
|
||||
} from './data/actions';
|
||||
import { editableFieldSelector } from './data/selectors';
|
||||
|
||||
|
||||
function EmailField(props) {
|
||||
const EmailField = (props) => {
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
@@ -34,9 +34,9 @@ function EmailField(props) {
|
||||
onChange,
|
||||
isEditing,
|
||||
isEditable,
|
||||
intl,
|
||||
} = props;
|
||||
const id = `field-${name}`;
|
||||
const intl = useIntl();
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -56,7 +56,9 @@ function EmailField(props) {
|
||||
};
|
||||
|
||||
const renderConfirmationMessage = () => {
|
||||
if (!confirmationMessageDefinition || !confirmationValue) return null;
|
||||
if (!confirmationMessageDefinition || !confirmationValue) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
className="alert-warning mt-n2"
|
||||
@@ -85,13 +87,15 @@ function EmailField(props) {
|
||||
|
||||
const renderEmptyLabel = () => {
|
||||
if (isEditable) {
|
||||
return <Button onClick={handleEdit} className="btn-link p-0">{emptyLabel}</Button>;
|
||||
return <Button variant="link" onClick={handleEdit} className="p-0">{emptyLabel}</Button>;
|
||||
}
|
||||
return <span className="text-muted">{emptyLabel}</span>;
|
||||
};
|
||||
|
||||
const renderValue = () => {
|
||||
if (confirmationValue) return renderConfirmationValue();
|
||||
if (confirmationValue) {
|
||||
return renderConfirmationValue();
|
||||
}
|
||||
return value || renderEmptyLabel();
|
||||
};
|
||||
|
||||
@@ -101,25 +105,26 @@ function EmailField(props) {
|
||||
cases={{
|
||||
editing: (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ValidationFormGroup
|
||||
for={id}
|
||||
invalid={error != null}
|
||||
invalidMessage={error}
|
||||
helpText={helpText}
|
||||
<Form.Group
|
||||
controlId={id}
|
||||
isInvalid={error != null}
|
||||
>
|
||||
<label className="h6 d-block" htmlFor={id}>{label}</label>
|
||||
<Input
|
||||
<Form.Label className="h6 d-block" htmlFor={id}>{label}</Form.Label>
|
||||
<Form.Control
|
||||
data-hj-suppress
|
||||
name={name}
|
||||
id={id}
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
{!!helpText && <Form.Text>{helpText}</Form.Text>}
|
||||
{error != null && <Form.Control.Feedback hasIcon={false}>{error}</Form.Control.Feedback>}
|
||||
</Form.Group>
|
||||
<p>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
className="btn-primary mr-2"
|
||||
className="mr-2"
|
||||
state={saveState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
|
||||
@@ -132,13 +137,13 @@ function EmailField(props) {
|
||||
// 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();
|
||||
if (saveState === 'pending') { e.preventDefault(); }
|
||||
}}
|
||||
disabledStates={[]}
|
||||
/>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={handleCancel}
|
||||
className="btn-outline-primary"
|
||||
>
|
||||
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
|
||||
</Button>
|
||||
@@ -150,21 +155,29 @@ function EmailField(props) {
|
||||
<div className="d-flex align-items-start">
|
||||
<h6 aria-level="3">{label}</h6>
|
||||
{isEditable ? (
|
||||
<Button onClick={handleEdit} className="ml-3 btn-link">
|
||||
<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>{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>}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
EmailField.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
@@ -186,7 +199,6 @@ EmailField.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isEditing: PropTypes.bool,
|
||||
isEditable: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
EmailField.defaultProps = {
|
||||
@@ -202,8 +214,7 @@ EmailField.defaultProps = {
|
||||
isEditable: true,
|
||||
};
|
||||
|
||||
|
||||
export default connect(editableFieldSelector, {
|
||||
onEdit: openForm,
|
||||
onCancel: closeForm,
|
||||
})(injectIntl(EmailField));
|
||||
})(EmailField);
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
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 Scrollspy from 'react-scrollspy';
|
||||
|
||||
import messages from './AccountSettingsPage.messages';
|
||||
|
||||
const JumpNav = () => {
|
||||
const intl = useIntl();
|
||||
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
|
||||
|
||||
function JumpNav({ intl }) {
|
||||
return (
|
||||
<div className="jump-nav">
|
||||
<div className={classNames('jump-nav', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
|
||||
<Scrollspy
|
||||
items={[
|
||||
'basic-information',
|
||||
'profile-information',
|
||||
'social-media',
|
||||
'notifications',
|
||||
'site-preferences',
|
||||
'linked-accounts',
|
||||
'delete-account',
|
||||
]}
|
||||
className="list-unstyled"
|
||||
currentClassName="font-weight-bold"
|
||||
offset={-64}
|
||||
>
|
||||
<li>
|
||||
<NavHashLink to="#basic-information">
|
||||
@@ -36,6 +41,11 @@ function JumpNav({ intl }) {
|
||||
{intl.formatMessage(messages['account.settings.section.social.media'])}
|
||||
</NavHashLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavHashLink to="#notifications">
|
||||
{intl.formatMessage(messages['notification.preferences.notifications.label'])}
|
||||
</NavHashLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavHashLink to="#site-preferences">
|
||||
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
|
||||
@@ -46,20 +56,17 @@ function JumpNav({ intl }) {
|
||||
{intl.formatMessage(messages['account.settings.section.linked.accounts'])}
|
||||
</NavHashLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavHashLink to="#delete-account">
|
||||
{intl.formatMessage(messages['account.settings.jump.nav.delete.account'])}
|
||||
</NavHashLink>
|
||||
</li>
|
||||
{getConfig().ENABLE_ACCOUNT_DELETION
|
||||
&& (
|
||||
<li>
|
||||
<NavHashLink to="#delete-account">
|
||||
{intl.formatMessage(messages['account.settings.jump.nav.delete.account'])}
|
||||
</NavHashLink>
|
||||
</li>
|
||||
)}
|
||||
</Scrollspy>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
JumpNav.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
|
||||
export default injectIntl(JumpNav);
|
||||
export default JumpNav;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div 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' }}>
|
||||
<FormattedMessage
|
||||
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."
|
||||
description="error message when a page does not exist"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const NotFoundPage = () => (
|
||||
<div
|
||||
className="container-fluid d-flex py-5 justify-content-center align-items-start text-center"
|
||||
data-testid="not-found-page"
|
||||
>
|
||||
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
|
||||
<FormattedMessage
|
||||
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."
|
||||
description="Error message when a page does not exist"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default NotFoundPage;
|
||||
|
||||
45
src/account-settings/OneTimeDismissibleAlert.jsx
Normal file
45
src/account-settings/OneTimeDismissibleAlert.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '@openedx/paragon';
|
||||
|
||||
const OneTimeDismissibleAlert = (props) => {
|
||||
const [dismissed, setDismissed] = useState(localStorage.getItem(props.id) !== 'true');
|
||||
|
||||
const onClose = () => {
|
||||
localStorage.setItem(props.id, 'true');
|
||||
setDismissed(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant={props.variant}
|
||||
dismissible
|
||||
icon={props.icon}
|
||||
onClose={onClose}
|
||||
show={dismissed}
|
||||
>
|
||||
<Alert.Heading>{props.header}</Alert.Heading>
|
||||
<p>
|
||||
{props.body}
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
OneTimeDismissibleAlert.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
variant: PropTypes.string,
|
||||
icon: PropTypes.func,
|
||||
header: PropTypes.string,
|
||||
body: PropTypes.string,
|
||||
};
|
||||
|
||||
OneTimeDismissibleAlert.defaultProps = {
|
||||
variant: 'success',
|
||||
icon: undefined,
|
||||
header: undefined,
|
||||
body: undefined,
|
||||
};
|
||||
|
||||
export default OneTimeDismissibleAlert;
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { TransitionReplace } from '@edx/paragon';
|
||||
|
||||
import { TransitionReplace } from '@openedx/paragon';
|
||||
|
||||
const onChildExit = (htmlNode) => {
|
||||
// If the leaving child has focus, take control and redirect it
|
||||
@@ -11,7 +10,9 @@ const onChildExit = (htmlNode) => {
|
||||
const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling;
|
||||
|
||||
// There's no replacement, do nothing.
|
||||
if (!enteringChild) return;
|
||||
if (!enteringChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all the focusable elements in the entering child and focus the first one
|
||||
const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
@@ -21,15 +22,15 @@ const onChildExit = (htmlNode) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function SwitchContent({ expression, cases, className }) {
|
||||
const SwitchContent = ({ expression, cases, className }) => {
|
||||
const getContent = (caseKey) => {
|
||||
if (cases[caseKey]) {
|
||||
if (typeof cases[caseKey] === 'string') {
|
||||
return getContent(cases[caseKey]);
|
||||
}
|
||||
return React.cloneElement(cases[caseKey], { key: caseKey });
|
||||
} else if (cases.default) {
|
||||
}
|
||||
if (cases.default) {
|
||||
if (typeof cases.default === 'string') {
|
||||
return getContent(cases.default);
|
||||
}
|
||||
@@ -47,8 +48,7 @@ function SwitchContent({ expression, cases, className }) {
|
||||
{getContent(expression)}
|
||||
</TransitionReplace>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
SwitchContent.propTypes = {
|
||||
expression: PropTypes.string,
|
||||
@@ -61,5 +61,4 @@ SwitchContent.defaultProps = {
|
||||
className: null,
|
||||
};
|
||||
|
||||
|
||||
export default SwitchContent;
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h6, .h6 {
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
line-height: 1.2;
|
||||
border: none;
|
||||
@@ -12,36 +14,37 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.jump-nav-sm {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.jump-nav {
|
||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||
padding-top: 1rem;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: .5rem;
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: 0;
|
||||
max-width: 500px;
|
||||
|
||||
.custom-control-label {
|
||||
left: 2.25rem;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
}
|
||||
.grayed-out{
|
||||
opacity: 0.6; /* Real browsers */
|
||||
filter: alpha(opacity = 60); /* MSIE */
|
||||
}
|
||||
}
|
||||
|
||||
#tooltip-email .small {
|
||||
display: block;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { connect, useDispatch } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
ActionRow,
|
||||
Form,
|
||||
ModalDialog,
|
||||
StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import {
|
||||
closeForm,
|
||||
resetDrafts,
|
||||
saveSettings,
|
||||
updateDraft,
|
||||
} from '../data/actions';
|
||||
import { certPreferenceSelector } from '../data/selectors';
|
||||
|
||||
import commonMessages from '../AccountSettingsPage.messages';
|
||||
import messages from './messages';
|
||||
|
||||
const CertificatePreference = ({
|
||||
fieldName,
|
||||
originalFullName,
|
||||
originalVerifiedName,
|
||||
saveState,
|
||||
useVerifiedNameForCerts,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const formId = 'useVerifiedNameForCerts';
|
||||
const intl = useIntl();
|
||||
|
||||
const handleCheckboxChange = () => {
|
||||
if (!checked) {
|
||||
if (fieldName === 'verified_name') {
|
||||
dispatch(updateDraft(formId, true));
|
||||
} else {
|
||||
dispatch(updateDraft(formId, false));
|
||||
}
|
||||
} else {
|
||||
setModalIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModalIsOpen(false);
|
||||
dispatch(resetDrafts());
|
||||
};
|
||||
|
||||
const handleModalChange = (e) => {
|
||||
if (e.target.value === 'fullName') {
|
||||
dispatch(updateDraft(formId, false));
|
||||
} else {
|
||||
dispatch(updateDraft(formId, true));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (saveState === 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(saveSettings(formId, useVerifiedNameForCerts));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (originalVerifiedName) {
|
||||
if (fieldName === 'verified_name') {
|
||||
setChecked(useVerifiedNameForCerts);
|
||||
} else {
|
||||
setChecked(!useVerifiedNameForCerts);
|
||||
}
|
||||
}
|
||||
}, [originalVerifiedName, fieldName, useVerifiedNameForCerts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalVerifiedName) {
|
||||
if (modalIsOpen && saveState === 'complete') {
|
||||
setModalIsOpen(false);
|
||||
dispatch(closeForm(fieldName));
|
||||
}
|
||||
}
|
||||
}, [dispatch, originalVerifiedName, fieldName, modalIsOpen, saveState]);
|
||||
|
||||
// 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}>
|
||||
{intl.formatMessage(messages['account.settings.field.name.checkbox.certificate.select'])}
|
||||
</Form.Checkbox>
|
||||
|
||||
<ModalDialog
|
||||
title={intl.formatMessage(messages['account.settings.field.name.modal.certificate.title'])}
|
||||
isOpen={modalIsOpen}
|
||||
onClose={handleCancel}
|
||||
size="lg"
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{intl.formatMessage(messages['account.settings.field.name.modal.certificate.title'])}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
|
||||
<ModalDialog.Body className="overflow-hidden">
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>
|
||||
{intl.formatMessage(messages['account.settings.field.name.modal.certificate.select'])}
|
||||
</Form.Label>
|
||||
<Form.RadioSet
|
||||
name={formId}
|
||||
value={useVerifiedNameForCerts ? 'verifiedName' : 'fullName'}
|
||||
onChange={handleModalChange}
|
||||
>
|
||||
<Form.Radio value="fullName">
|
||||
{originalFullName}{' '}
|
||||
({intl.formatMessage(messages['account.settings.field.name.modal.certificate.option.full'])})
|
||||
</Form.Radio>
|
||||
<Form.Radio value="verifiedName">
|
||||
{originalVerifiedName}{' '}
|
||||
({intl.formatMessage(messages['account.settings.field.name.modal.certificate.option.verified'])})
|
||||
</Form.Radio>
|
||||
</Form.RadioSet>
|
||||
</Form.Group>
|
||||
</ModalDialog.Body>
|
||||
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="outline-primary" disabled={saveState === 'pending'}>
|
||||
{intl.formatMessage(commonMessages['account.settings.editable.field.action.cancel'])}
|
||||
</ModalDialog.CloseButton>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
state={saveState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['account.settings.field.name.modal.certificate.button.choose']),
|
||||
}}
|
||||
disabledStates={[]}
|
||||
/>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</Form>
|
||||
</ModalDialog>
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
|
||||
CertificatePreference.propTypes = {
|
||||
fieldName: PropTypes.string.isRequired,
|
||||
originalFullName: PropTypes.string,
|
||||
originalVerifiedName: PropTypes.string,
|
||||
saveState: PropTypes.string,
|
||||
useVerifiedNameForCerts: PropTypes.bool,
|
||||
};
|
||||
|
||||
CertificatePreference.defaultProps = {
|
||||
originalFullName: '',
|
||||
originalVerifiedName: '',
|
||||
saveState: null,
|
||||
useVerifiedNameForCerts: false,
|
||||
};
|
||||
|
||||
export default connect(certPreferenceSelector)(CertificatePreference);
|
||||
22
src/account-settings/certificate-preference/data/service.js
Normal file
22
src/account-settings/certificate-preference/data/service.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { handleRequestError } from '../../data/utils';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function postVerifiedNameConfig(username, commitValues) {
|
||||
const requestConfig = { headers: { Accept: 'application/json' } };
|
||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name/config`;
|
||||
|
||||
const { useVerifiedNameForCerts } = commitValues;
|
||||
const postValues = {
|
||||
username,
|
||||
use_verified_name_for_certs: useVerifiedNameForCerts,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(requestUrl, postValues, requestConfig)
|
||||
.catch(error => handleRequestError(error));
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
36
src/account-settings/certificate-preference/messages.js
Normal file
36
src/account-settings/certificate-preference/messages.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'account.settings.field.name.checkbox.certificate.select': {
|
||||
id: 'account.settings.field.name.certificate.select',
|
||||
defaultMessage: 'If checked, this name will appear on your certificates and public-facing records.',
|
||||
description: 'Label for checkbox describing that the selected name will appear on the user‘s certificates.',
|
||||
},
|
||||
'account.settings.field.name.modal.certificate.title': {
|
||||
id: 'account.settings.field.name.modal.certificate.title',
|
||||
defaultMessage: 'Choose a preferred name for certificates and public-facing records',
|
||||
description: 'Title instructing the user to choose a preferred name.',
|
||||
},
|
||||
'account.settings.field.name.modal.certificate.select': {
|
||||
id: 'account.settings.field.name.modal.certificate.select',
|
||||
defaultMessage: 'Select a name',
|
||||
description: 'Label instructing the user to select a name.',
|
||||
},
|
||||
'account.settings.field.name.modal.certificate.option.full': {
|
||||
id: 'account.settings.field.name.modal.certificate.option.full',
|
||||
defaultMessage: 'Full Name',
|
||||
description: 'Option representing the user’s full name.',
|
||||
},
|
||||
'account.settings.field.name.modal.certificate.option.verified': {
|
||||
id: 'account.settings.field.name.modal.certificate.option.verified',
|
||||
defaultMessage: 'Verified Name',
|
||||
description: 'Option representing the user’s verified name.',
|
||||
},
|
||||
'account.settings.field.name.modal.certificate.button.choose': {
|
||||
id: 'account.settings.field.name.modal.certificate.button.choose',
|
||||
defaultMessage: 'Choose name',
|
||||
description: 'Button to confirm the user’s name choice.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,171 @@
|
||||
/* eslint-disable no-import-assign */
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
|
||||
import * as auth from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import messages from '../messages';
|
||||
|
||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||
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
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
|
||||
|
||||
const mockStore = configureStore();
|
||||
|
||||
describe('NameChange', () => {
|
||||
let props = {};
|
||||
let store = {};
|
||||
const formId = 'useVerifiedNameForCerts';
|
||||
const updateDraft = 'UPDATE_DRAFT';
|
||||
const labelText = messages['account.settings.field.name.checkbox.certificate.select'].defaultMessage;
|
||||
|
||||
const reduxWrapper = children => (
|
||||
<Router>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</IntlProvider>
|
||||
</Router>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore();
|
||||
props = {
|
||||
fieldName: 'name',
|
||||
originalFullName: 'Ed X',
|
||||
originalVerifiedName: 'edX Verified',
|
||||
saveState: null,
|
||||
useVerifiedNameForCerts: false,
|
||||
};
|
||||
|
||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
||||
patch: async () => ({
|
||||
data: { status: 200 },
|
||||
catch: () => {},
|
||||
}),
|
||||
}));
|
||||
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3 }));
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('does not render if there is no verified name', () => {
|
||||
props = {
|
||||
...props,
|
||||
originalVerifiedName: '',
|
||||
};
|
||||
|
||||
const wrapper = render(reduxWrapper(<CertificatePreference {...props} />));
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not trigger modal when checking empty checkbox, and updates draft immediately', () => {
|
||||
props = {
|
||||
...props,
|
||||
useVerifiedNameForCerts: true,
|
||||
};
|
||||
|
||||
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||
|
||||
const checkbox = screen.getByLabelText(labelText);
|
||||
expect(checkbox.checked).toEqual(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.queryByRole('radiogroup')).toBeNull();
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
payload: { name: formId, value: false },
|
||||
type: updateDraft,
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers modal when attempting to uncheck checkbox', () => {
|
||||
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||
|
||||
const checkbox = screen.getByLabelText(labelText);
|
||||
expect(checkbox.checked).toEqual(true);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
|
||||
screen.getByRole('radiogroup');
|
||||
});
|
||||
|
||||
it('updates draft when changing radio value', () => {
|
||||
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||
|
||||
const checkbox = screen.getByLabelText(labelText);
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const fullNameOption = screen.getByLabelText('Ed X (Full Name)');
|
||||
const verifiedNameOption = screen.getByLabelText('edX Verified (Verified Name)');
|
||||
expect(fullNameOption.checked).toEqual(true);
|
||||
expect(verifiedNameOption.checked).toEqual(false);
|
||||
|
||||
fireEvent.click(verifiedNameOption);
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
payload: { name: formId, value: true },
|
||||
type: updateDraft,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears draft on cancel', () => {
|
||||
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||
|
||||
const checkbox = screen.getByLabelText(labelText);
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({ type: 'RESET_DRAFTS' });
|
||||
expect(screen.queryByRole('radiogroup')).toBeNull();
|
||||
});
|
||||
|
||||
it('submits', () => {
|
||||
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||
|
||||
const checkbox = screen.getByLabelText(labelText);
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const submitButton = screen.getByText('Choose name');
|
||||
fireEvent.click(submitButton);
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
payload: { formId, commitValues: false, extendedProfile: {} },
|
||||
type: 'ACCOUNT_SETTINGS__SAVE_SETTINGS',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks box for verified name', () => {
|
||||
props = {
|
||||
...props,
|
||||
fieldName: 'verified_name',
|
||||
useVerifiedNameForCerts: true,
|
||||
};
|
||||
|
||||
render(reduxWrapper(<CertificatePreference {...props} />));
|
||||
|
||||
const checkbox = screen.getByLabelText(labelText);
|
||||
expect(checkbox.checked).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`NameChange does not render if there is no verified name 1`] = `
|
||||
{
|
||||
"asFragment": [Function],
|
||||
"baseElement": <body>
|
||||
<div />
|
||||
</body>,
|
||||
"container": <div />,
|
||||
"debug": [Function],
|
||||
"findAllByAltText": [Function],
|
||||
"findAllByDisplayValue": [Function],
|
||||
"findAllByLabelText": [Function],
|
||||
"findAllByPlaceholderText": [Function],
|
||||
"findAllByRole": [Function],
|
||||
"findAllByTestId": [Function],
|
||||
"findAllByText": [Function],
|
||||
"findAllByTitle": [Function],
|
||||
"findByAltText": [Function],
|
||||
"findByDisplayValue": [Function],
|
||||
"findByLabelText": [Function],
|
||||
"findByPlaceholderText": [Function],
|
||||
"findByRole": [Function],
|
||||
"findByTestId": [Function],
|
||||
"findByText": [Function],
|
||||
"findByTitle": [Function],
|
||||
"getAllByAltText": [Function],
|
||||
"getAllByDisplayValue": [Function],
|
||||
"getAllByLabelText": [Function],
|
||||
"getAllByPlaceholderText": [Function],
|
||||
"getAllByRole": [Function],
|
||||
"getAllByTestId": [Function],
|
||||
"getAllByText": [Function],
|
||||
"getAllByTitle": [Function],
|
||||
"getByAltText": [Function],
|
||||
"getByDisplayValue": [Function],
|
||||
"getByLabelText": [Function],
|
||||
"getByPlaceholderText": [Function],
|
||||
"getByRole": [Function],
|
||||
"getByTestId": [Function],
|
||||
"getByText": [Function],
|
||||
"getByTitle": [Function],
|
||||
"queryAllByAltText": [Function],
|
||||
"queryAllByDisplayValue": [Function],
|
||||
"queryAllByLabelText": [Function],
|
||||
"queryAllByPlaceholderText": [Function],
|
||||
"queryAllByRole": [Function],
|
||||
"queryAllByTestId": [Function],
|
||||
"queryAllByText": [Function],
|
||||
"queryAllByTitle": [Function],
|
||||
"queryByAltText": [Function],
|
||||
"queryByDisplayValue": [Function],
|
||||
"queryByLabelText": [Function],
|
||||
"queryByPlaceholderText": [Function],
|
||||
"queryByRole": [Function],
|
||||
"queryByTestId": [Function],
|
||||
"queryByText": [Function],
|
||||
"queryByTitle": [Function],
|
||||
"rerender": [Function],
|
||||
"unmount": [Function],
|
||||
}
|
||||
`;
|
||||
@@ -1,316 +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 PageLoading from '../PageLoading';
|
||||
import CoachingConsentForm from './CoachingConsentForm';
|
||||
import messages from './CoachingConsent.messages';
|
||||
import LogoSVG from '../../logo.svg';
|
||||
import { fetchSettings, saveSettings, saveMultipleSettings } 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,
|
||||
allSubmissionsComplete: false,
|
||||
};
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.declineCoaching = this.declineCoaching.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchSettings();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
/*
|
||||
When we are submitting the form, we're calling saveSettings 3 times, which causes
|
||||
multiple parallel redux flows. Because of this we can't rely on just the redux states
|
||||
being sent in through props. For instance if the coaching submission and name
|
||||
submission happen in near parallel, the coaching flow could return errors in
|
||||
formErrors and the name flow could overwrite the formErrors with an empty object.
|
||||
|
||||
To minimize disruption to the rest of the app, we're going to manage flow state from
|
||||
within this component.
|
||||
*/
|
||||
|
||||
// If a new error comes in, store it before the next redux call overwrites it.
|
||||
let allFormErrors = {};
|
||||
let allSubmissionsComplete = false;
|
||||
|
||||
// Collect new errors and add to state (will be cleared on new submission)
|
||||
const newErrorsFound = (
|
||||
this.props.formErrors !== prevProps.formErrors
|
||||
&& Object.keys(this.props.formErrors).length > 0
|
||||
);
|
||||
if (newErrorsFound) {
|
||||
allFormErrors = Object.assign({}, this.state.formErrors, this.props.formErrors);
|
||||
}
|
||||
|
||||
// Check if all values from the form have confirmation values
|
||||
if (
|
||||
this.state.formSubmitted &&
|
||||
this.props.saveState === 'complete'
|
||||
) {
|
||||
allSubmissionsComplete = true;
|
||||
}
|
||||
|
||||
// Check if all values from the decline link have confirmation values
|
||||
if (this.props.confirmationValues.coaching && this.state.declineSubmitted) {
|
||||
allSubmissionsComplete = true;
|
||||
}
|
||||
if (newErrorsFound || (allSubmissionsComplete !== prevState.allSubmissionsComplete)) {
|
||||
this.setState({
|
||||
formErrors: allFormErrors,
|
||||
allSubmissionsComplete,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.setState({
|
||||
formErrors: {},
|
||||
formSubmitted: true,
|
||||
declineSubmitted: false,
|
||||
});
|
||||
// Must store target values or they disappear before the async function can use them.
|
||||
const fullName = e.target.fullName.value;
|
||||
const phoneNumber = e.target.phoneNumber.value;
|
||||
const coachingValues = this.props.formValues.coaching;
|
||||
|
||||
// !important: The order of this data matters!
|
||||
// The order that this data is in, is the order that the saveSettings() function
|
||||
// is called.
|
||||
const settingsSubmissions = [];
|
||||
if (!this.props.profileDataManager) {
|
||||
settingsSubmissions.push({
|
||||
formId: 'name',
|
||||
commitValues: fullName,
|
||||
});
|
||||
}
|
||||
Array.prototype.push.apply(settingsSubmissions, [
|
||||
{
|
||||
formId: 'coaching',
|
||||
commitValues: {
|
||||
...coachingValues,
|
||||
phone_number: phoneNumber,
|
||||
coaching_consent: true,
|
||||
consent_form_seen: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
formId: 'phone_number',
|
||||
commitValues: phoneNumber,
|
||||
},
|
||||
]);
|
||||
this.props.saveMultipleSettings(settingsSubmissions);
|
||||
}
|
||||
|
||||
async declineCoaching(e) {
|
||||
e.preventDefault();
|
||||
this.setState({
|
||||
formErrors: {},
|
||||
declineSubmitted: true,
|
||||
formSubmitted: false,
|
||||
});
|
||||
// Must store target values or they disappear before the async function can use them.
|
||||
const coachingValues = this.props.formValues.coaching;
|
||||
this.props.saveSettings('coaching', {
|
||||
...coachingValues,
|
||||
coaching_consent: false,
|
||||
consent_form_seen: true,
|
||||
});
|
||||
}
|
||||
|
||||
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.allSubmissionsComplete) {
|
||||
currentView = VIEWS.SUCCESS;
|
||||
} else {
|
||||
currentView = VIEWS.SUCCESS_PENDING;
|
||||
}
|
||||
} else if (this.state.declineSubmitted && !formHasErrors) {
|
||||
if (this.state.allSubmissionsComplete) {
|
||||
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,
|
||||
saveState: undefined,
|
||||
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,
|
||||
saveSettings: PropTypes.func.isRequired,
|
||||
saveMultipleSettings: PropTypes.func.isRequired,
|
||||
saveState: PropTypes.string,
|
||||
profileDataManager: PropTypes.string,
|
||||
};
|
||||
|
||||
export default connect(coachingConsentPageSelector, {
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
saveMultipleSettings,
|
||||
})(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 will receive a text message confirmation.",
|
||||
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,130 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
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.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="full-name"
|
||||
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 className="w-100 btn-outline-primary" 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,
|
||||
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,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CoachingForm);
|
||||
@@ -1,99 +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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const OPEN_FORM = 'OPEN_FORM';
|
||||
export const CLOSE_FORM = 'CLOSE_FORM';
|
||||
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
|
||||
export const RESET_DRAFTS = 'RESET_DRAFTS';
|
||||
export const BEGIN_NAME_CHANGE = 'BEGIN_NAME_CHANGE';
|
||||
|
||||
// FETCH SETTINGS ACTIONS
|
||||
|
||||
@@ -25,6 +26,8 @@ export const fetchSettingsSuccess = ({
|
||||
thirdPartyAuthProviders,
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList,
|
||||
}) => ({
|
||||
type: FETCH_SETTINGS.SUCCESS,
|
||||
payload: {
|
||||
@@ -32,6 +35,8 @@ export const fetchSettingsSuccess = ({
|
||||
thirdPartyAuthProviders,
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,7 +49,6 @@ export const fetchSettingsReset = () => ({
|
||||
type: FETCH_SETTINGS.RESET,
|
||||
});
|
||||
|
||||
|
||||
// FORM STATE ACTIONS
|
||||
|
||||
export const openForm = formId => ({
|
||||
@@ -69,12 +73,15 @@ export const resetDrafts = () => ({
|
||||
type: RESET_DRAFTS,
|
||||
});
|
||||
|
||||
|
||||
export const beginNameChange = (formId) => ({
|
||||
type: BEGIN_NAME_CHANGE,
|
||||
payload: { formId },
|
||||
});
|
||||
// SAVE SETTINGS ACTIONS
|
||||
|
||||
export const saveSettings = (formId, commitValues) => ({
|
||||
export const saveSettings = (formId, commitValues, extendedProfile = {}) => ({
|
||||
type: SAVE_SETTINGS.BASE,
|
||||
payload: { formId, commitValues },
|
||||
payload: { formId, commitValues, extendedProfile },
|
||||
});
|
||||
|
||||
export const saveSettingsBegin = () => ({
|
||||
@@ -100,9 +107,9 @@ export const savePreviousSiteLanguage = previousSiteLanguage => ({
|
||||
payload: { previousSiteLanguage },
|
||||
});
|
||||
|
||||
export const saveMultipleSettings = settingsArray => ({
|
||||
export const saveMultipleSettings = (settingsArray, form = null) => ({
|
||||
type: SAVE_MULTIPLE_SETTINGS.BASE,
|
||||
payload: { settingsArray },
|
||||
payload: { settingsArray, form },
|
||||
});
|
||||
|
||||
export const saveMultipleSettingsBegin = () => ({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
export const YEAR_OF_BIRTH_OPTIONS = (() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [];
|
||||
@@ -11,6 +10,11 @@ export const YEAR_OF_BIRTH_OPTIONS = (() => {
|
||||
return years.reverse();
|
||||
})();
|
||||
|
||||
export const COPPA_COMPLIANCE_YEAR = (() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return currentYear - 13;
|
||||
})();
|
||||
|
||||
export const EDUCATION_LEVELS = [
|
||||
'',
|
||||
'p',
|
||||
@@ -21,7 +25,7 @@ export const EDUCATION_LEVELS = [
|
||||
'jhs',
|
||||
'el',
|
||||
'none',
|
||||
'o',
|
||||
'other',
|
||||
];
|
||||
|
||||
export const GENDER_OPTIONS = [
|
||||
@@ -30,6 +34,21 @@ export const GENDER_OPTIONS = [
|
||||
'm',
|
||||
'o',
|
||||
];
|
||||
export const WORK_EXPERIENCE_OPTIONS = [
|
||||
'',
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'10+',
|
||||
|
||||
];
|
||||
|
||||
export const COUNTRY_WITH_STATES = 'US';
|
||||
|
||||
@@ -112,3 +131,7 @@ const COUNTRY_STATES_MAP = {
|
||||
export function getStatesList(country) {
|
||||
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
|
||||
}
|
||||
|
||||
export const FIELD_LABELS = {
|
||||
COUNTRY: 'country',
|
||||
};
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
UPDATE_DRAFT,
|
||||
RESET_DRAFTS,
|
||||
SAVE_MULTIPLE_SETTINGS,
|
||||
BEGIN_NAME_CHANGE,
|
||||
} from './actions';
|
||||
|
||||
import { reducer as deleteAccountReducer, DELETE_ACCOUNT } from '../delete-account';
|
||||
import { reducer as siteLanguageReducer, FETCH_SITE_LANGUAGES } from '../site-language';
|
||||
import { reducer as resetPasswordReducer, RESET_PASSWORD } from '../reset-password';
|
||||
import { reducer as nameChangeReducer, REQUEST_NAME_CHANGE } from '../name-change';
|
||||
import { reducer as thirdPartyAuthReducer, DISCONNECT_AUTH } from '../third-party-auth';
|
||||
|
||||
export const defaultState = {
|
||||
@@ -31,10 +33,16 @@ export const defaultState = {
|
||||
deleteAccount: deleteAccountReducer(),
|
||||
siteLanguage: siteLanguageReducer(),
|
||||
resetPassword: resetPasswordReducer(),
|
||||
nameChange: nameChangeReducer(),
|
||||
thirdPartyAuth: thirdPartyAuthReducer(),
|
||||
nameChangeModal: false,
|
||||
verifiedName: null,
|
||||
mostRecentVerifiedName: {},
|
||||
verifiedNameHistory: {},
|
||||
countriesCodesList: [],
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action) => {
|
||||
const reducer = (state = defaultState, action = {}) => {
|
||||
let dispatcherIsOpenForm;
|
||||
|
||||
switch (action.type) {
|
||||
@@ -48,16 +56,16 @@ const reducer = (state = defaultState, action) => {
|
||||
case FETCH_SETTINGS.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
values: Object.assign({}, state.values, action.payload.values),
|
||||
values: { ...state.values, ...action.payload.values },
|
||||
// Dump the providers into thirdPartyAuth.
|
||||
thirdPartyAuth: Object.assign({}, state.thirdPartyAuth, {
|
||||
providers: action.payload.thirdPartyAuthProviders,
|
||||
}),
|
||||
thirdPartyAuth: { ...state.thirdPartyAuth, providers: action.payload.thirdPartyAuthProviders },
|
||||
profileDataManager: action.payload.profileDataManager,
|
||||
timeZones: action.payload.timeZones,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
loadingError: null,
|
||||
verifiedNameHistory: action.payload.verifiedNameHistory,
|
||||
countriesCodesList: action.payload.countriesCodesList,
|
||||
};
|
||||
case FETCH_SETTINGS.FAILURE:
|
||||
return {
|
||||
@@ -91,15 +99,14 @@ const reducer = (state = defaultState, action) => {
|
||||
saveState: null,
|
||||
errors: {},
|
||||
drafts: {},
|
||||
nameChangeModal: false,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
case UPDATE_DRAFT:
|
||||
return {
|
||||
...state,
|
||||
drafts: Object.assign({}, state.drafts, {
|
||||
[action.payload.name]: action.payload.value,
|
||||
}),
|
||||
drafts: { ...state.drafts, [action.payload.name]: action.payload.value },
|
||||
saveState: null,
|
||||
errors: {},
|
||||
};
|
||||
@@ -110,6 +117,15 @@ const reducer = (state = defaultState, action) => {
|
||||
drafts: {},
|
||||
};
|
||||
|
||||
case BEGIN_NAME_CHANGE:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'error',
|
||||
nameChangeModal: {
|
||||
formId: action.payload.formId,
|
||||
},
|
||||
};
|
||||
|
||||
case SAVE_SETTINGS.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
@@ -120,19 +136,18 @@ const reducer = (state = defaultState, action) => {
|
||||
return {
|
||||
...state,
|
||||
saveState: 'complete',
|
||||
values: Object.assign({}, state.values, action.payload.values),
|
||||
values: { ...state.values, ...action.payload.values },
|
||||
errors: {},
|
||||
confirmationValues: Object.assign(
|
||||
{},
|
||||
state.confirmationValues,
|
||||
action.payload.confirmationValues,
|
||||
),
|
||||
confirmationValues: {
|
||||
...state.confirmationValues,
|
||||
...action.payload.confirmationValues,
|
||||
},
|
||||
};
|
||||
case SAVE_SETTINGS.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'error',
|
||||
errors: Object.assign({}, state.errors, action.payload.errors),
|
||||
errors: { ...state.errors, ...action.payload.errors },
|
||||
};
|
||||
case SAVE_SETTINGS.RESET:
|
||||
return {
|
||||
@@ -161,7 +176,7 @@ const reducer = (state = defaultState, action) => {
|
||||
return {
|
||||
...state,
|
||||
saveState: 'error',
|
||||
errors: Object.assign({}, state.errors, action.payload.errors),
|
||||
errors: { ...state.errors, ...action.payload.errors },
|
||||
};
|
||||
|
||||
case FETCH_TIME_ZONES.SUCCESS:
|
||||
@@ -202,6 +217,15 @@ const reducer = (state = defaultState, action) => {
|
||||
resetPassword: resetPasswordReducer(state.resetPassword, action),
|
||||
};
|
||||
|
||||
case REQUEST_NAME_CHANGE.BEGIN:
|
||||
case REQUEST_NAME_CHANGE.SUCCESS:
|
||||
case REQUEST_NAME_CHANGE.FAILURE:
|
||||
case REQUEST_NAME_CHANGE.RESET:
|
||||
return {
|
||||
...state,
|
||||
nameChange: nameChangeReducer(state.nameChange, action),
|
||||
};
|
||||
|
||||
case DISCONNECT_AUTH.BEGIN:
|
||||
case DISCONNECT_AUTH.SUCCESS:
|
||||
case DISCONNECT_AUTH.FAILURE:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { call, put, delay, takeEvery, all } from 'redux-saga/effects';
|
||||
import {
|
||||
call, put, delay, takeEvery, all,
|
||||
} from 'redux-saga/effects';
|
||||
|
||||
import { publish } from '@edx/frontend-platform';
|
||||
import { getLocale, handleRtl, LOCALE_CHANGED } from '@edx/frontend-platform/i18n';
|
||||
@@ -23,11 +25,13 @@ import {
|
||||
saveMultipleSettingsBegin,
|
||||
saveMultipleSettingsSuccess,
|
||||
saveMultipleSettingsFailure,
|
||||
beginNameChange,
|
||||
} from './actions';
|
||||
|
||||
// Sub-modules
|
||||
import { saga as deleteAccountSaga } from '../delete-account';
|
||||
import { saga as resetPasswordSaga } from '../reset-password';
|
||||
import { saga as nameChangeSaga } from '../name-change';
|
||||
import {
|
||||
saga as siteLanguageSaga,
|
||||
patchPreferences,
|
||||
@@ -36,7 +40,12 @@ import {
|
||||
import { saga as thirdPartyAuthSaga } from '../third-party-auth';
|
||||
|
||||
// Services
|
||||
import { getSettings, patchSettings, getTimeZones } from './service';
|
||||
import {
|
||||
getSettings,
|
||||
patchSettings,
|
||||
getTimeZones,
|
||||
getVerifiedNameHistory,
|
||||
} from './service';
|
||||
|
||||
export function* handleFetchSettings() {
|
||||
try {
|
||||
@@ -44,7 +53,7 @@ export function* handleFetchSettings() {
|
||||
const { username, userId, roles: userRoles } = getAuthenticatedUser();
|
||||
|
||||
const {
|
||||
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
|
||||
thirdPartyAuthProviders, profileDataManager, timeZones, countries, ...values
|
||||
} = yield call(
|
||||
getSettings,
|
||||
username,
|
||||
@@ -52,13 +61,17 @@ export function* handleFetchSettings() {
|
||||
userId,
|
||||
);
|
||||
|
||||
if (values.country) yield put(fetchTimeZones(values.country));
|
||||
const verifiedNameHistory = yield call(getVerifiedNameHistory);
|
||||
|
||||
if (values.country) { yield put(fetchTimeZones(values.country)); }
|
||||
|
||||
yield put(fetchSettingsSuccess({
|
||||
values,
|
||||
thirdPartyAuthProviders,
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList: countries,
|
||||
}));
|
||||
} catch (e) {
|
||||
yield put(fetchSettingsFailure(e.message));
|
||||
@@ -71,8 +84,8 @@ export function* handleSaveSettings(action) {
|
||||
yield put(saveSettingsBegin());
|
||||
|
||||
const { username, userId } = getAuthenticatedUser();
|
||||
const { commitValues, formId } = action.payload;
|
||||
const commitData = { [formId]: commitValues };
|
||||
const { commitValues, formId, extendedProfile } = action.payload;
|
||||
const commitData = Object.keys(extendedProfile).length > 0 ? extendedProfile : { [formId]: commitValues };
|
||||
let savedValues = null;
|
||||
if (formId === 'siteLanguage') {
|
||||
const previousSiteLanguage = getLocale();
|
||||
@@ -91,11 +104,14 @@ export function* handleSaveSettings(action) {
|
||||
savedValues = yield call(patchSettings, username, commitData, userId);
|
||||
}
|
||||
yield put(saveSettingsSuccess(savedValues, commitData));
|
||||
if (savedValues.country) yield put(fetchTimeZones(savedValues.country));
|
||||
if (savedValues.country) { yield put(fetchTimeZones(savedValues.country)); }
|
||||
yield delay(1000);
|
||||
yield put(closeForm(action.payload.formId));
|
||||
} catch (e) {
|
||||
if (e.fieldErrors) {
|
||||
if (e.fieldErrors.name?.includes('verification')) {
|
||||
yield put(beginNameChange('name'));
|
||||
}
|
||||
yield put(saveSettingsFailure({ fieldErrors: e.fieldErrors }));
|
||||
} else {
|
||||
yield put(saveSettingsFailure(e.message));
|
||||
@@ -104,13 +120,12 @@ export function* handleSaveSettings(action) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// handles mutiple settings saved at once, in order, and stops executing on first failure.
|
||||
export function* handleSaveMultipleSettings(settings) {
|
||||
export function* handleSaveMultipleSettings(action) {
|
||||
try {
|
||||
yield put(saveMultipleSettingsBegin());
|
||||
const { username, userId } = getAuthenticatedUser();
|
||||
const { settingsArray } = settings.payload;
|
||||
const { settingsArray, form } = action.payload;
|
||||
for (let i = 0; i < settingsArray.length; i += 1) {
|
||||
const { formId, commitValues } = settingsArray[i];
|
||||
yield put(saveSettingsBegin());
|
||||
@@ -118,9 +133,16 @@ export function* handleSaveMultipleSettings(settings) {
|
||||
const savedSettings = yield call(patchSettings, username, commitData, userId);
|
||||
yield put(saveSettingsSuccess(savedSettings, commitData));
|
||||
}
|
||||
yield put(saveMultipleSettingsSuccess(settings));
|
||||
yield put(saveMultipleSettingsSuccess(action));
|
||||
if (form) {
|
||||
yield delay(1000);
|
||||
yield put(closeForm(form));
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.fieldErrors) {
|
||||
if (e.fieldErrors.name?.includes('verification')) {
|
||||
yield put(beginNameChange('name'));
|
||||
}
|
||||
yield put(saveMultipleSettingsFailure({ fieldErrors: e.fieldErrors }));
|
||||
} else {
|
||||
yield put(saveMultipleSettingsFailure(e.message));
|
||||
@@ -134,7 +156,6 @@ export function* handleFetchTimeZones(action) {
|
||||
yield put(fetchTimeZonesSuccess(response, action.payload.country));
|
||||
}
|
||||
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(FETCH_SETTINGS.BASE, handleFetchSettings);
|
||||
yield takeEvery(SAVE_SETTINGS.BASE, handleSaveSettings);
|
||||
@@ -144,6 +165,7 @@ export default function* saga() {
|
||||
deleteAccountSaga(),
|
||||
siteLanguageSaga(),
|
||||
resetPasswordSaga(),
|
||||
nameChangeSaga(),
|
||||
thirdPartyAuthSaga(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSelector, createStructuredSelector } from 'reselect';
|
||||
|
||||
import { siteLanguageOptionsSelector, siteLanguageListSelector } from '../site-language';
|
||||
import { siteLanguageListSelector, siteLanguageOptionsSelector } from '../site-language';
|
||||
import { compareVerifiedNamesByCreatedDate } from '../../utils';
|
||||
|
||||
export const storeName = 'accountSettings';
|
||||
|
||||
@@ -8,9 +8,74 @@ export const accountSettingsSelector = state => ({ ...state[storeName] });
|
||||
|
||||
const editableFieldNameSelector = (state, props) => props.name;
|
||||
|
||||
const verifiedNameSettingsSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => ({
|
||||
history: accountSettings.verifiedNameHistory.results,
|
||||
useVerifiedNameForCerts: accountSettings?.verifiedNameHistory.use_verified_name_for_certs,
|
||||
}),
|
||||
);
|
||||
|
||||
const sortedVerifiedNameHistorySelector = createSelector(
|
||||
verifiedNameSettingsSelector,
|
||||
verifiedNameSettings => {
|
||||
const { history } = verifiedNameSettings;
|
||||
|
||||
if (Array.isArray(history)) {
|
||||
return history.sort(compareVerifiedNamesByCreatedDate);
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
const mostRecentVerifiedNameSelector = createSelector(
|
||||
sortedVerifiedNameHistorySelector,
|
||||
sortedHistory => (sortedHistory.length > 0 ? sortedHistory[0] : null),
|
||||
);
|
||||
|
||||
const mostRecentApprovedVerifiedNameValueSelector = createSelector(
|
||||
sortedVerifiedNameHistorySelector,
|
||||
mostRecentVerifiedNameSelector,
|
||||
(sortedHistory, mostRecentVerifiedName) => {
|
||||
const approvedVerifiedNames = sortedHistory.filter(name => name.status === 'approved');
|
||||
const approvedVerifiedName = approvedVerifiedNames.length > 0 ? approvedVerifiedNames[0] : null;
|
||||
|
||||
let verifiedName = null;
|
||||
switch (mostRecentVerifiedName && mostRecentVerifiedName.status) {
|
||||
case 'approved':
|
||||
case 'denied':
|
||||
case 'pending':
|
||||
verifiedName = approvedVerifiedName;
|
||||
break;
|
||||
case 'submitted':
|
||||
verifiedName = mostRecentVerifiedName;
|
||||
break;
|
||||
default:
|
||||
verifiedName = null;
|
||||
}
|
||||
return verifiedName;
|
||||
},
|
||||
);
|
||||
|
||||
const valuesSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.values,
|
||||
mostRecentApprovedVerifiedNameValueSelector,
|
||||
(accountSettings, mostRecentApprovedVerifiedNameValue) => {
|
||||
let useVerifiedNameForCerts = (
|
||||
accountSettings.verifiedNameHistory?.use_verified_name_for_certs || false
|
||||
);
|
||||
|
||||
if (Object.keys(accountSettings.confirmationValues).includes('useVerifiedNameForCerts')) {
|
||||
useVerifiedNameForCerts = accountSettings.confirmationValues.useVerifiedNameForCerts;
|
||||
}
|
||||
|
||||
return {
|
||||
...accountSettings.values,
|
||||
verified_name: mostRecentApprovedVerifiedNameValue?.verified_name,
|
||||
useVerifiedNameForCerts,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const draftsSelector = createSelector(
|
||||
@@ -23,6 +88,11 @@ const previousSiteLanguageSelector = createSelector(
|
||||
accountSettings => accountSettings.previousSiteLanguage,
|
||||
);
|
||||
|
||||
const countriesSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.countriesCodesList,
|
||||
);
|
||||
|
||||
const editableFieldErrorSelector = createSelector(
|
||||
editableFieldNameSelector,
|
||||
accountSettingsSelector,
|
||||
@@ -41,16 +111,16 @@ const isEditingSelector = createSelector(
|
||||
(name, accountSettings) => accountSettings.openFormId === name,
|
||||
);
|
||||
|
||||
const confirmationValuesSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.confirmationValues,
|
||||
);
|
||||
|
||||
const errorSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.errors,
|
||||
);
|
||||
|
||||
const nameChangeModalSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.nameChangeModal,
|
||||
);
|
||||
|
||||
const saveStateSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => accountSettings.saveState,
|
||||
@@ -70,9 +140,19 @@ export const profileDataManagerSelector = createSelector(
|
||||
|
||||
export const staticFieldsSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
accountSettings => (accountSettings.profileDataManager ? ['name', 'email', 'country'] : []),
|
||||
);
|
||||
mostRecentVerifiedNameSelector,
|
||||
(accountSettings, verifiedName) => {
|
||||
const staticFields = [];
|
||||
if (accountSettings.profileDataManager) {
|
||||
staticFields.push('name', 'email', 'country');
|
||||
}
|
||||
if (verifiedName && ['submitted'].includes(verifiedName.status)) {
|
||||
staticFields.push('verifiedName');
|
||||
}
|
||||
|
||||
return staticFields;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* If there's no draft present at all (undefined), use the original committed value.
|
||||
@@ -81,13 +161,31 @@ function chooseFormValue(draft, committed) {
|
||||
return draft !== undefined ? draft : committed;
|
||||
}
|
||||
|
||||
const formValuesSelector = createSelector(
|
||||
export const formValuesSelector = createSelector(
|
||||
valuesSelector,
|
||||
draftsSelector,
|
||||
(values, drafts) => {
|
||||
const formValues = {};
|
||||
Object.entries(values).forEach(([name, value]) => {
|
||||
formValues[name] = chooseFormValue(drafts[name], value) || '';
|
||||
if (typeof value === 'boolean') {
|
||||
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 {
|
||||
formValues[name] = chooseFormValue(drafts[name], value) || '';
|
||||
}
|
||||
});
|
||||
return formValues;
|
||||
},
|
||||
@@ -95,7 +193,7 @@ const formValuesSelector = createSelector(
|
||||
|
||||
const transformTimeZonesToOptions = timeZoneArr => timeZoneArr
|
||||
.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(
|
||||
@@ -132,21 +230,37 @@ export const accountSettingsPageSelector = createSelector(
|
||||
siteLanguageOptionsSelector,
|
||||
siteLanguageSelector,
|
||||
formValuesSelector,
|
||||
valuesSelector,
|
||||
draftsSelector,
|
||||
errorSelector,
|
||||
profileDataManagerSelector,
|
||||
staticFieldsSelector,
|
||||
timeZonesSelector,
|
||||
countryTimeZonesSelector,
|
||||
activeAccountSelector,
|
||||
nameChangeModalSelector,
|
||||
mostRecentApprovedVerifiedNameValueSelector,
|
||||
mostRecentVerifiedNameSelector,
|
||||
sortedVerifiedNameHistorySelector,
|
||||
countriesSelector,
|
||||
(
|
||||
accountSettings,
|
||||
siteLanguageOptions,
|
||||
siteLanguage,
|
||||
formValues,
|
||||
committedValues,
|
||||
drafts,
|
||||
formErrors,
|
||||
profileDataManager,
|
||||
staticFields,
|
||||
timeZoneOptions,
|
||||
countryTimeZoneOptions,
|
||||
activeAccount,
|
||||
nameChangeModal,
|
||||
verifiedName,
|
||||
mostRecentVerifiedName,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList,
|
||||
) => ({
|
||||
siteLanguageOptions,
|
||||
siteLanguage,
|
||||
@@ -157,37 +271,46 @@ export const accountSettingsPageSelector = createSelector(
|
||||
countryTimeZoneOptions,
|
||||
isActive: activeAccount,
|
||||
formValues,
|
||||
committedValues,
|
||||
drafts,
|
||||
formErrors,
|
||||
profileDataManager,
|
||||
staticFields,
|
||||
tpaProviders: accountSettings.thirdPartyAuth.providers,
|
||||
nameChangeModal,
|
||||
verifiedName,
|
||||
mostRecentVerifiedName,
|
||||
verifiedNameHistory,
|
||||
countriesCodesList,
|
||||
}),
|
||||
);
|
||||
|
||||
export const coachingConsentPageSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
export const certPreferenceSelector = createSelector(
|
||||
valuesSelector,
|
||||
formValuesSelector,
|
||||
activeAccountSelector,
|
||||
profileDataManagerSelector,
|
||||
mostRecentApprovedVerifiedNameValueSelector,
|
||||
saveStateSelector,
|
||||
confirmationValuesSelector,
|
||||
errorSelector,
|
||||
(
|
||||
accountSettings,
|
||||
committedValues,
|
||||
formValues,
|
||||
activeAccount,
|
||||
profileDataManager,
|
||||
mostRecentApprovedVerifiedNameValue,
|
||||
saveState,
|
||||
confirmationValues,
|
||||
errors,
|
||||
) => ({
|
||||
loading: accountSettings.loading,
|
||||
loaded: accountSettings.loaded,
|
||||
loadingError: accountSettings.loadingError,
|
||||
isActive: activeAccount,
|
||||
profileDataManager,
|
||||
formValues,
|
||||
originalFullName: committedValues?.name || '',
|
||||
originalVerifiedName: mostRecentApprovedVerifiedNameValue?.verified_name || '',
|
||||
useVerifiedNameForCerts: formValues.useVerifiedNameForCerts || false,
|
||||
saveState,
|
||||
confirmationValues,
|
||||
formErrors: errors,
|
||||
}),
|
||||
);
|
||||
|
||||
export const nameChangeSelector = createSelector(
|
||||
accountSettingsSelector,
|
||||
formValuesSelector,
|
||||
(accountSettings, formValues) => ({
|
||||
...accountSettings.nameChange,
|
||||
formValues,
|
||||
}),
|
||||
);
|
||||
|
||||
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,15 +1,17 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import pick from 'lodash.pick';
|
||||
import omit from 'lodash.omit';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
|
||||
import { handleRequestError, unpackFieldErrors } from './utils';
|
||||
import { getThirdPartyAuthProviders } from '../third-party-auth';
|
||||
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
|
||||
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
|
||||
import { FIELD_LABELS } from './constants';
|
||||
|
||||
const SOCIAL_PLATFORMS = [
|
||||
{ id: 'twitter', key: 'social_link_twitter' },
|
||||
{ id: 'xTwitter', key: 'social_link_x' },
|
||||
{ id: 'facebook', key: 'social_link_facebook' },
|
||||
{ id: 'linkedin', key: 'social_link_linkedin' },
|
||||
];
|
||||
@@ -41,7 +43,9 @@ function packAccountCommitData(commitData) {
|
||||
|
||||
SOCIAL_PLATFORMS.forEach(({ id, key }) => {
|
||||
// Skip missing values. Empty strings are valid values and should be preserved.
|
||||
if (commitData[key] === undefined) return;
|
||||
if (commitData[key] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
packedData.social_links = [{ platform: id, social_link: commitData[key] }];
|
||||
delete packedData[key];
|
||||
@@ -149,43 +153,107 @@ export async function getProfileDataManager(username, userRoles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getVerifiedName() {
|
||||
let data;
|
||||
const client = getAuthenticatedHttpClient();
|
||||
try {
|
||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name`;
|
||||
({ data } = await client.get(requestUrl));
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getVerifiedNameHistory() {
|
||||
let data;
|
||||
const client = getAuthenticatedHttpClient();
|
||||
try {
|
||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name/history`;
|
||||
({ data } = await client.get(requestUrl));
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function postVerifiedName(data) {
|
||||
const requestConfig = { headers: { Accept: 'application/json' } };
|
||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name`;
|
||||
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(requestUrl, data, requestConfig)
|
||||
.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.
|
||||
* Currently encapsulates Account, Preferences, Coaching, and ThirdPartyAuth
|
||||
* A single function to GET everything considered a setting. Currently encapsulates Account, Preferences, and
|
||||
* ThirdPartyAuth.
|
||||
*/
|
||||
export async function getSettings(username, userRoles, userId) {
|
||||
const results = await Promise.all([
|
||||
export async function getSettings(username, userRoles) {
|
||||
const [
|
||||
account,
|
||||
preferences,
|
||||
thirdPartyAuthProviders,
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
countries,
|
||||
] = await Promise.all([
|
||||
getAccount(username),
|
||||
getPreferences(username),
|
||||
getThirdPartyAuthProviders(),
|
||||
getProfileDataManager(username, userRoles),
|
||||
getTimeZones(),
|
||||
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
|
||||
getCountryList(),
|
||||
]);
|
||||
|
||||
return {
|
||||
...results[0],
|
||||
...results[1],
|
||||
thirdPartyAuthProviders: results[2],
|
||||
profileDataManager: results[3],
|
||||
timeZones: results[4],
|
||||
coaching: results[5],
|
||||
...account,
|
||||
...preferences,
|
||||
thirdPartyAuthProviders,
|
||||
profileDataManager,
|
||||
timeZones,
|
||||
countries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// 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.
|
||||
const preferenceKeys = ['time_zone'];
|
||||
const coachingKeys = ['coaching'];
|
||||
const accountCommitValues = omit(commitValues, preferenceKeys, coachingKeys);
|
||||
const certificateKeys = ['useVerifiedNameForCerts'];
|
||||
const accountCommitValues = omit(
|
||||
commitValues,
|
||||
preferenceKeys,
|
||||
certificateKeys,
|
||||
);
|
||||
const preferenceCommitValues = pick(commitValues, preferenceKeys);
|
||||
const coachingCommitValues = pick(commitValues, coachingKeys);
|
||||
const certCommitValues = pick(commitValues, certificateKeys);
|
||||
const patchRequests = [];
|
||||
|
||||
if (!isEmpty(accountCommitValues)) {
|
||||
@@ -194,8 +262,8 @@ export async function patchSettings(username, commitValues, userId) {
|
||||
if (!isEmpty(preferenceCommitValues)) {
|
||||
patchRequests.push(patchPreferences(username, preferenceCommitValues));
|
||||
}
|
||||
if (!isEmpty(coachingCommitValues)) {
|
||||
patchRequests.push(patchCoachingPreferences(userId, coachingCommitValues));
|
||||
if (!isEmpty(certCommitValues)) {
|
||||
patchRequests.push(postVerifiedNameConfig(username, certCommitValues));
|
||||
}
|
||||
|
||||
const results = await Promise.all(patchRequests);
|
||||
|
||||
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,38 +0,0 @@
|
||||
import camelCase from 'lodash.camelcase';
|
||||
import snakeCase from 'lodash.snakecase';
|
||||
|
||||
export function modifyObjectKeys(object, modify) {
|
||||
// If the passed in object is not an object, return it.
|
||||
if (
|
||||
object === undefined ||
|
||||
object === null ||
|
||||
(typeof object !== 'object' && !Array.isArray(object))
|
||||
) {
|
||||
return object;
|
||||
}
|
||||
|
||||
if (Array.isArray(object)) {
|
||||
return object.map(value => modifyObjectKeys(value, modify));
|
||||
}
|
||||
|
||||
// Otherwise, process all its keys.
|
||||
const result = {};
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
result[modify(key)] = modifyObjectKeys(value, modify);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function camelCaseObject(object) {
|
||||
return modifyObjectKeys(object, camelCase);
|
||||
}
|
||||
|
||||
export function snakeCaseObject(object) {
|
||||
return modifyObjectKeys(object, snakeCase);
|
||||
}
|
||||
|
||||
export function convertKeyNames(object, nameMap) {
|
||||
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
|
||||
|
||||
return modifyObjectKeys(object, transformer);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
modifyObjectKeys,
|
||||
camelCaseObject,
|
||||
snakeCaseObject,
|
||||
convertKeyNames,
|
||||
} from './dataUtils';
|
||||
|
||||
describe('modifyObjectKeys', () => {
|
||||
it('should use the provided modify function to change all keys in and object and its children', () => {
|
||||
function meowKeys(key) {
|
||||
return `${key}Meow`;
|
||||
}
|
||||
|
||||
const result = modifyObjectKeys(
|
||||
{
|
||||
one: undefined,
|
||||
two: null,
|
||||
three: '',
|
||||
four: 0,
|
||||
five: NaN,
|
||||
six: [1, 2, { seven: 'woof' }],
|
||||
eight: { nine: { ten: 'bark' }, eleven: true },
|
||||
},
|
||||
meowKeys,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
oneMeow: undefined,
|
||||
twoMeow: null,
|
||||
threeMeow: '',
|
||||
fourMeow: 0,
|
||||
fiveMeow: NaN,
|
||||
sixMeow: [1, 2, { sevenMeow: 'woof' }],
|
||||
eightMeow: { nineMeow: { tenMeow: 'bark' }, elevenMeow: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('camelCaseObject', () => {
|
||||
it('should make everything camelCase', () => {
|
||||
const result = camelCaseObject({
|
||||
what_now: 'brown cow',
|
||||
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
|
||||
'dot.dot.dot': 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
whatNow: 'brown cow',
|
||||
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
|
||||
dotDotDot: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('snakeCaseObject', () => {
|
||||
it('should make everything snake_case', () => {
|
||||
const result = snakeCaseObject({
|
||||
whatNow: 'brown cow',
|
||||
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
|
||||
'dot.dot.dot': 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
what_now: 'brown cow',
|
||||
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
|
||||
dot_dot_dot: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertKeyNames', () => {
|
||||
it('should replace the specified keynames', () => {
|
||||
const result = convertKeyNames(
|
||||
{
|
||||
one: { two: { three: 'four' } },
|
||||
five: 'six',
|
||||
},
|
||||
{
|
||||
two: 'blue',
|
||||
five: 'alive',
|
||||
seven: 'heaven',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
one: { blue: { three: 'four' } },
|
||||
alive: 'six',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,3 @@
|
||||
export {
|
||||
camelCaseObject,
|
||||
convertKeyNames,
|
||||
modifyObjectKeys,
|
||||
snakeCaseObject,
|
||||
} from './dataUtils';
|
||||
export {
|
||||
AsyncActionType,
|
||||
getModuleState,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { put } from 'redux-saga/effects';
|
||||
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) {
|
||||
yield put(failureAction({ fieldErrors: error.fieldErrors }));
|
||||
}
|
||||
@@ -11,6 +10,6 @@ export default function* handleFailure(error, failureAction = null, failureRedir
|
||||
yield put(failureAction(error.message));
|
||||
}
|
||||
if (failureRedirectPath !== null) {
|
||||
history.push(failureRedirectPath);
|
||||
navigate(failureRedirectPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React from 'react';
|
||||
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
|
||||
// Messages
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import messages from './messages';
|
||||
|
||||
// Components
|
||||
import Alert from '../Alert';
|
||||
|
||||
const BeforeProceedingBanner = (props) => {
|
||||
const { instructionMessageId, intl, supportArticleUrl } = props;
|
||||
const { instructionMessageId, supportArticleUrl } = props;
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Alert
|
||||
@@ -22,13 +23,16 @@ const BeforeProceedingBanner = (props) => {
|
||||
<FormattedMessage
|
||||
id="account.settings.delete.account.before.proceeding"
|
||||
defaultMessage="Before proceeding, please {actionLink}."
|
||||
description="Error that appears if you are trying to delete your edX 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={{
|
||||
actionLink: (
|
||||
actionLink: supportArticleUrl ? (
|
||||
<Hyperlink destination={supportArticleUrl}>
|
||||
{intl.formatMessage(messages[instructionMessageId])}
|
||||
</Hyperlink>
|
||||
) : (
|
||||
intl.formatMessage(messages[instructionMessageId])
|
||||
),
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
@@ -37,8 +41,7 @@ const BeforeProceedingBanner = (props) => {
|
||||
|
||||
BeforeProceedingBanner.propTypes = {
|
||||
instructionMessageId: PropTypes.string.isRequired,
|
||||
intl: intlShape.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();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Input, Modal, ValidationFormGroup } from '@edx/paragon';
|
||||
import {
|
||||
AlertModal,
|
||||
Button, Form, ActionRow,
|
||||
} from '@openedx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import messages from './messages';
|
||||
import Alert from '../Alert';
|
||||
import PrintingInstructions from './PrintingInstructions';
|
||||
@@ -19,6 +22,8 @@ export class ConfirmationModal extends Component {
|
||||
switch (reason) {
|
||||
case 'empty-password':
|
||||
return 'account.settings.delete.account.error.no.password';
|
||||
case 'invalid-password':
|
||||
return 'account.settings.delete.account.error.invalid.password';
|
||||
default:
|
||||
return 'account.settings.delete.account.error.unable.to.delete';
|
||||
}
|
||||
@@ -31,10 +36,9 @@ export class ConfirmationModal extends Component {
|
||||
return null;
|
||||
}
|
||||
const headerMessageId = this.getShortErrorMessageId(errorType);
|
||||
const detailsMessageId =
|
||||
reason === 'empty-password'
|
||||
? null
|
||||
: 'account.settings.delete.account.error.unable.to.delete.details';
|
||||
const detailsMessageId = reason === 'empty-password'
|
||||
? null
|
||||
: 'account.settings.delete.account.error.unable.to.delete.details';
|
||||
|
||||
return (
|
||||
<Alert
|
||||
@@ -62,52 +66,71 @@ export class ConfirmationModal extends Component {
|
||||
const open = ['confirming', 'pending', 'failed'].includes(status);
|
||||
const passwordFieldId = 'passwordFieldId';
|
||||
const invalidMessage = messages[this.getShortErrorMessageId(errorType)];
|
||||
|
||||
// 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.
|
||||
const deleteAccountModalText2MessageKey = getConfig().SITE_NAME === 'edX'
|
||||
? 'account.settings.delete.account.modal.text.2.edX'
|
||||
: 'account.settings.delete.account.modal.text.2';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
<AlertModal
|
||||
isOpen={open}
|
||||
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'])}
|
||||
</h6>
|
||||
<p>{intl.formatMessage(messages['account.settings.delete.account.modal.text.2'])}</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 className="btn-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}
|
||||
/>
|
||||
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 ReactDOM from 'react-dom';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
|
||||
ReactDOM.createPortal = node => node;
|
||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||
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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Hyperlink } from '@edx/paragon';
|
||||
import { Button, Hyperlink } from '@openedx/paragon';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
@@ -24,9 +24,13 @@ import ConnectedSuccessModal from './SuccessModal';
|
||||
import BeforeProceedingBanner from './BeforeProceedingBanner';
|
||||
|
||||
export class DeleteAccount extends React.Component {
|
||||
state = {
|
||||
password: '',
|
||||
};
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
password: '',
|
||||
};
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
if (this.state.password === '') {
|
||||
@@ -55,60 +59,91 @@ export class DeleteAccount extends React.Component {
|
||||
hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
|
||||
} = this.props;
|
||||
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
|
||||
// to allow edx.org to fulfill its business requirements.
|
||||
const deleteAccountText2MessageKey = getConfig().SITE_NAME === 'edX'
|
||||
? 'account.settings.delete.account.text.2.edX'
|
||||
: '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 (
|
||||
<div>
|
||||
<h2 className="section-heading">
|
||||
<h2 className="section-heading h4 mb-3">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.header'])}
|
||||
</h2>
|
||||
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
|
||||
<p>{intl.formatMessage(messages['account.settings.delete.account.text.1'])}</p>
|
||||
<p>{intl.formatMessage(messages['account.settings.delete.account.text.2'])}</p>
|
||||
<p>
|
||||
<PrintingInstructions />
|
||||
</p>
|
||||
<p className="text-danger h6">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.text.warning'])}
|
||||
</p>
|
||||
<p>
|
||||
<Hyperlink destination="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
<p>
|
||||
<Button
|
||||
className="btn-outline-danger"
|
||||
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
{intl.formatMessage(messages['account.settings.delete.account.button'])}
|
||||
</Button>
|
||||
</p>
|
||||
{
|
||||
this.props.canDeleteAccount ? (
|
||||
<>
|
||||
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
messages['account.settings.delete.account.text.1'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
messages[deleteAccountText2MessageKey],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<PrintingInstructions />
|
||||
</p>
|
||||
<p className="text-danger h6">
|
||||
{intl.formatMessage(
|
||||
messages['account.settings.delete.account.text.warning'],
|
||||
{ siteName: getConfig().SITE_NAME },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
|
||||
</Hyperlink>
|
||||
</p>
|
||||
<p>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
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 : (
|
||||
<BeforeProceedingBanner
|
||||
instructionMessageId="account.settings.delete.account.please.activate"
|
||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
|
||||
/>
|
||||
)}
|
||||
<ConnectedConfirmationModal
|
||||
status={status}
|
||||
errorType={errorType}
|
||||
onSubmit={this.handleSubmit}
|
||||
onCancel={this.handleCancel}
|
||||
onChange={this.handlePasswordChange}
|
||||
password={this.state.password}
|
||||
/>
|
||||
|
||||
{hasLinkedTPA ? (
|
||||
<BeforeProceedingBanner
|
||||
instructionMessageId="account.settings.delete.account.please.unlink"
|
||||
supportArticleUrl="https://support.edx.org/hc/en-us/articles/207206067"
|
||||
/>
|
||||
) : null}
|
||||
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
|
||||
</>
|
||||
) : (
|
||||
<p>{intl.formatMessage(messages['account.settings.cannot.delete.account.text'])}</p>
|
||||
)
|
||||
}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +159,7 @@ DeleteAccount.propTypes = {
|
||||
errorType: PropTypes.oneOf(['empty-password', 'server']),
|
||||
hasLinkedTPA: PropTypes.bool,
|
||||
isVerifiedAccount: PropTypes.bool,
|
||||
canDeleteAccount: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
@@ -132,6 +168,7 @@ DeleteAccount.defaultProps = {
|
||||
isVerifiedAccount: true,
|
||||
status: null,
|
||||
errorType: null,
|
||||
canDeleteAccount: true,
|
||||
};
|
||||
|
||||
// Assume we're part of the accountSettings state.
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
// Testing the modals separately, they just clutter up the snapshots if included here.
|
||||
jest.mock('./ConfirmationModal');
|
||||
jest.mock('./SuccessModal');
|
||||
jest.mock('./ConfirmationModal', () => function ConfirmationModalMock() {
|
||||
return <></>;
|
||||
});
|
||||
jest.mock('./SuccessModal', () => function SuccessModalMock() {
|
||||
return <></>;
|
||||
});
|
||||
|
||||
import { DeleteAccount } from './DeleteAccount'; // eslint-disable-line import/first
|
||||
|
||||
@@ -37,6 +42,7 @@ describe('DeleteAccount', () => {
|
||||
</IntlProvider>
|
||||
))
|
||||
.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import messages from './messages';
|
||||
|
||||
const PrintingInstructions = (props) => {
|
||||
const PrintingInstructions = () => {
|
||||
const intl = useIntl();
|
||||
const actionLink = (
|
||||
<Hyperlink
|
||||
destination="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
|
||||
// TODO: What would a generic version of this link look like? Should
|
||||
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
|
||||
// We've removed the link from the default message.
|
||||
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>
|
||||
);
|
||||
|
||||
// TODO: We lack a good way of providing custom language for a particular site. This is a hack
|
||||
// to allow edx.org to mention MicroMasters certificates to fulfill its business requirements.
|
||||
if (getConfig().SITE_NAME === 'edX') {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="account.settings.delete.account.text.3.edX"
|
||||
defaultMessage="You may also lose access to verified certificates and other program credentials like MicroMasters certificates. You can make a copy of these for your records before proceeding with deletion. {actionLink}."
|
||||
description="A message in the user account deletion area warning users that deleting their account will prevent them from accessing their certificates. 'actionLink' is a HTML link with a full sentence that describes how to print a certificate."
|
||||
values={{ actionLink }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="account.settings.delete.account.text.3"
|
||||
defaultMessage="You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion, {actionLink}."
|
||||
description="A message in the user account deletion area"
|
||||
defaultMessage="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."
|
||||
description="A message in the user account deletion area warning users that deleting their account will prevent them from accessing their certificates."
|
||||
values={{ actionLink }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
PrintingInstructions.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PrintingInstructions);
|
||||
export default PrintingInstructions;
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Modal } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ModalLayer, ModalCloseButton } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const SuccessModal = (props) => {
|
||||
const { status, intl, onClose } = props;
|
||||
const intl = useIntl();
|
||||
const { status, onClose } = props;
|
||||
return (
|
||||
<Modal
|
||||
open={status === 'deleted'}
|
||||
title={intl.formatMessage(messages['account.settings.delete.account.modal.after.header'])}
|
||||
body={
|
||||
<div>
|
||||
|
||||
<ModalLayer isOpen={status === 'deleted'} onClose={onClose}>
|
||||
<div className="mw-sm p-5 bg-white mx-auto my-3">
|
||||
<h3>
|
||||
{intl.formatMessage(messages['account.settings.delete.account.modal.after.header'])}
|
||||
</h3>
|
||||
<div className="p-3">
|
||||
<p className="h6">
|
||||
{intl.formatMessage(messages['account.settings.delete.account.modal.after.text'])}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
closeText={intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}
|
||||
renderHeaderCloseButton={false}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<p>
|
||||
<ModalCloseButton className="float-right" variant="link">{intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}</ModalCloseButton>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</ModalLayer>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
SuccessModal.propTypes = {
|
||||
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
|
||||
intl: intlShape.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -35,4 +38,4 @@ SuccessModal.defaultProps = {
|
||||
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 { 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.
|
||||
ReactDOM.createPortal = node => node;
|
||||
|
||||
import { SuccessModal } from './SuccessModal'; // eslint-disable-line import/first
|
||||
|
||||
const IntlSuccessModal = injectIntl(SuccessModal);
|
||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||
jest.mock('react-dom', () => ({
|
||||
...jest.requireActual('react-dom'),
|
||||
createPortal: jest.fn(node => node), // Mock portal behavior
|
||||
}));
|
||||
|
||||
describe('SuccessModal', () => {
|
||||
let props = {};
|
||||
@@ -20,39 +19,40 @@ describe('SuccessModal', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('should match default closed success modal snapshot', () => {
|
||||
let tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} /></IntlProvider>))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
||||
tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="confirming" /></IntlProvider>))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
||||
tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="pending" /></IntlProvider>))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
||||
tree = renderer.create((
|
||||
<IntlProvider locale="en"><IntlSuccessModal {...props} status="failed" /></IntlProvider>))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
it('should match default closed success modal snapshot', async () => {
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en"><SuccessModal {...props} /></IntlProvider>)).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en"><SuccessModal {...props} status="confirming" /></IntlProvider>)).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en"><SuccessModal {...props} status="pending" /></IntlProvider>)).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create((
|
||||
<IntlProvider locale="en"><SuccessModal {...props} status="failed" /></IntlProvider>)).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('should match open success modal snapshot', () => {
|
||||
const tree = renderer
|
||||
.create((
|
||||
it('should match open success modal snapshot', async () => {
|
||||
await waitFor(() => {
|
||||
const tree = renderer.create(
|
||||
<IntlProvider locale="en">
|
||||
<IntlSuccessModal
|
||||
<SuccessModal
|
||||
{...props}
|
||||
status="deleted" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
|
||||
status="deleted"
|
||||
/>
|
||||
</IntlProvider>
|
||||
))
|
||||
.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
</IntlProvider>,
|
||||
).toJSON();
|
||||
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>
|
||||
`;
|
||||
@@ -1,439 +1,403 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ConfirmationModal should match default closed confirmation modal snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
className="fade"
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className="modal js-close-modal-on-click fade"
|
||||
onMouseDown={[Function]}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="id2"
|
||||
aria-modal={true}
|
||||
className=""
|
||||
role="dialog"
|
||||
tabIndex="-1"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
>
|
||||
<div
|
||||
className="modal-header"
|
||||
>
|
||||
<h2
|
||||
className="modal-title"
|
||||
id="id2"
|
||||
>
|
||||
Are you sure?
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="alert d-flex align-items-start alert-warning mt-n2"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
|
||||
data-icon="exclamation-triangle"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 576 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h6>
|
||||
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
|
||||
</h6>
|
||||
<p>
|
||||
If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
|
||||
<a
|
||||
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
follow the instructions for printing or downloading a certificate
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="form-group"
|
||||
>
|
||||
<label
|
||||
className="d-block"
|
||||
htmlFor="passwordFieldId"
|
||||
>
|
||||
If you still wish to continue and delete your account, please enter your account password:
|
||||
</label>
|
||||
<input
|
||||
aria-describedby=""
|
||||
className="form-control"
|
||||
id="passwordFieldId"
|
||||
name="password"
|
||||
onChange={[MockFunction]}
|
||||
type="password"
|
||||
value="fluffy bunnies"
|
||||
/>
|
||||
<strong
|
||||
className="invalid-feedback"
|
||||
id="passwordFieldId-invalid-feedback"
|
||||
>
|
||||
Unable to delete account
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Yes, Delete
|
||||
</button>
|
||||
<button
|
||||
className="btn js-close-modal-on-click btn-secondary"
|
||||
id="paragonCloseModalButton1"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
exports[`ConfirmationModal should match default closed confirmation modal snapshot 1`] = `null`;
|
||||
|
||||
exports[`ConfirmationModal should match empty password confirmation modal snapshot 1`] = `
|
||||
<div>
|
||||
[
|
||||
<div
|
||||
className="modal-backdrop show"
|
||||
role="presentation"
|
||||
/>
|
||||
data-focus-guard={true}
|
||||
style={
|
||||
{
|
||||
"height": "0px",
|
||||
"left": "1px",
|
||||
"overflow": "hidden",
|
||||
"padding": 0,
|
||||
"position": "fixed",
|
||||
"top": "1px",
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
tabIndex={0}
|
||||
/>,
|
||||
<div
|
||||
className="modal js-close-modal-on-click show d-block"
|
||||
className="pgn__modal-layer"
|
||||
data-focus-lock-disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
role="presentation"
|
||||
onScrollCapture={[Function]}
|
||||
onTouchMoveCapture={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
onWheelCapture={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="id6"
|
||||
aria-modal={true}
|
||||
className="modal-dialog"
|
||||
role="dialog"
|
||||
tabIndex="-1"
|
||||
className="pgn__modal-content-container"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
className="pgn__modal-backdrop"
|
||||
data-testid="modal-backdrop"
|
||||
onClick={[MockFunction]}
|
||||
onKeyDown={[MockFunction]}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
aria-label="Are you sure?"
|
||||
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow pgn__alert-modal"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="modal-header"
|
||||
className="pgn__modal-header"
|
||||
>
|
||||
<h2
|
||||
className="modal-title"
|
||||
id="id6"
|
||||
className="pgn__modal-title"
|
||||
>
|
||||
Are you sure?
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
className="pgn__modal-body pgn__modal-body-scroll-top pgn__modal-body-scroll-bottom"
|
||||
>
|
||||
<div>
|
||||
<div />
|
||||
<div
|
||||
className="pgn__modal-body-content"
|
||||
>
|
||||
<div
|
||||
className="alert d-flex align-items-start alert-danger mt-n2"
|
||||
className="p-3"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-exclamation-circle fa-w-16 mr-2"
|
||||
data-icon="exclamation-circle"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div
|
||||
className="alert d-flex align-items-start alert-danger mt-n2"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-circle-exclamation mr-2"
|
||||
data-icon="circle-exclamation"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={{}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
|
||||
fill="currentColor"
|
||||
style={{}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h6>
|
||||
A password is required
|
||||
</h6>
|
||||
<p
|
||||
className="text-danger"
|
||||
>
|
||||
Sorry, there was an error trying to process your request. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<h6>
|
||||
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
|
||||
</h6>
|
||||
<p>
|
||||
If you proceed, you will be unable to use this account to take courses on localhost.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="pgn__form-group"
|
||||
for="passwordFieldId"
|
||||
>
|
||||
<label
|
||||
className="pgn__form-label d-block"
|
||||
htmlFor="form-field3"
|
||||
>
|
||||
<path
|
||||
d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-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"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
If you still wish to continue and delete your account, please enter your account password:
|
||||
</label>
|
||||
<div
|
||||
className="pgn__form-control-decorator-group"
|
||||
>
|
||||
<input
|
||||
aria-describedby="form-field3-5"
|
||||
className="has-value form-control is-invalid"
|
||||
id="form-field3"
|
||||
name="password"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
type="password"
|
||||
value="fluffy bunnies"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h6>
|
||||
A password is required
|
||||
</h6>
|
||||
<p
|
||||
className="text-danger"
|
||||
</div>
|
||||
<div
|
||||
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
|
||||
feedback-for="passwordFieldId"
|
||||
id="form-field3-5"
|
||||
>
|
||||
Sorry, there was an error trying to process your request. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="alert d-flex align-items-start alert-warning mt-n2"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
|
||||
data-icon="exclamation-triangle"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 576 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h6>
|
||||
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
|
||||
</h6>
|
||||
<p>
|
||||
If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
|
||||
<a
|
||||
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
<span
|
||||
className="pgn__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
fill="none"
|
||||
focusable={false}
|
||||
height={24}
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={24}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
follow the instructions for printing or downloading a certificate
|
||||
</a>
|
||||
.
|
||||
<path
|
||||
d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</p>
|
||||
<div>
|
||||
A password is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="form-group"
|
||||
>
|
||||
<label
|
||||
className="d-block"
|
||||
htmlFor="passwordFieldId"
|
||||
>
|
||||
If you still wish to continue and delete your account, please enter your account password:
|
||||
</label>
|
||||
<input
|
||||
aria-describedby="passwordFieldId-invalid-feedback"
|
||||
className="form-control is-invalid"
|
||||
id="passwordFieldId"
|
||||
name="password"
|
||||
onChange={[MockFunction]}
|
||||
type="password"
|
||||
value="fluffy bunnies"
|
||||
/>
|
||||
<strong
|
||||
className="invalid-feedback"
|
||||
id="passwordFieldId-invalid-feedback"
|
||||
>
|
||||
A password is required
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
className="pgn__modal-footer"
|
||||
>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
<div
|
||||
className="pgn__action-row"
|
||||
>
|
||||
Yes, Delete
|
||||
</button>
|
||||
<button
|
||||
className="btn js-close-modal-on-click btn-secondary"
|
||||
id="paragonCloseModalButton5"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-link"
|
||||
disabled={false}
|
||||
onClick={[MockFunction]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={false}
|
||||
onClick={[MockFunction]}
|
||||
type="button"
|
||||
>
|
||||
Yes, Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
<div
|
||||
data-focus-guard={true}
|
||||
style={
|
||||
{
|
||||
"height": "0px",
|
||||
"left": "1px",
|
||||
"overflow": "hidden",
|
||||
"padding": 0,
|
||||
"position": "fixed",
|
||||
"top": "1px",
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
tabIndex={0}
|
||||
/>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
|
||||
<div>
|
||||
[
|
||||
<div
|
||||
className="modal-backdrop show"
|
||||
role="presentation"
|
||||
/>
|
||||
data-focus-guard={true}
|
||||
style={
|
||||
{
|
||||
"height": "0px",
|
||||
"left": "1px",
|
||||
"overflow": "hidden",
|
||||
"padding": 0,
|
||||
"position": "fixed",
|
||||
"top": "1px",
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
tabIndex={0}
|
||||
/>,
|
||||
<div
|
||||
className="modal js-close-modal-on-click show d-block"
|
||||
className="pgn__modal-layer"
|
||||
data-focus-lock-disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
role="presentation"
|
||||
onScrollCapture={[Function]}
|
||||
onTouchMoveCapture={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
onWheelCapture={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="id4"
|
||||
aria-modal={true}
|
||||
className="modal-dialog"
|
||||
role="dialog"
|
||||
tabIndex="-1"
|
||||
className="pgn__modal-content-container"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
className="pgn__modal-backdrop"
|
||||
data-testid="modal-backdrop"
|
||||
onClick={[MockFunction]}
|
||||
onKeyDown={[MockFunction]}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
aria-label="Are you sure?"
|
||||
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow pgn__alert-modal"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="modal-header"
|
||||
className="pgn__modal-header"
|
||||
>
|
||||
<h2
|
||||
className="modal-title"
|
||||
id="id4"
|
||||
className="pgn__modal-title"
|
||||
>
|
||||
Are you sure?
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
className="pgn__modal-body pgn__modal-body-scroll-top pgn__modal-body-scroll-bottom"
|
||||
>
|
||||
<div>
|
||||
<div />
|
||||
<div
|
||||
className="pgn__modal-body-content"
|
||||
>
|
||||
<div
|
||||
className="alert d-flex align-items-start alert-warning mt-n2"
|
||||
className="p-3"
|
||||
>
|
||||
<div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
|
||||
data-icon="exclamation-triangle"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 576 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<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>
|
||||
<h6>
|
||||
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
|
||||
</h6>
|
||||
<p>
|
||||
If you proceed, you will be unable to use this account to take courses on localhost.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="pgn__form-group"
|
||||
for="passwordFieldId"
|
||||
>
|
||||
<label
|
||||
className="pgn__form-label d-block"
|
||||
htmlFor="form-field1"
|
||||
>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
If you still wish to continue and delete your account, please enter your account password:
|
||||
</label>
|
||||
<div
|
||||
className="pgn__form-control-decorator-group"
|
||||
>
|
||||
<input
|
||||
className="has-value form-control"
|
||||
id="form-field1"
|
||||
name="password"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
type="password"
|
||||
value="fluffy bunnies"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h6>
|
||||
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
|
||||
</h6>
|
||||
<p>
|
||||
If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
|
||||
<a
|
||||
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
follow the instructions for printing or downloading a certificate
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="form-group"
|
||||
>
|
||||
<label
|
||||
className="d-block"
|
||||
htmlFor="passwordFieldId"
|
||||
>
|
||||
If you still wish to continue and delete your account, please enter your account password:
|
||||
</label>
|
||||
<input
|
||||
aria-describedby=""
|
||||
className="form-control"
|
||||
id="passwordFieldId"
|
||||
name="password"
|
||||
onChange={[MockFunction]}
|
||||
type="password"
|
||||
value="fluffy bunnies"
|
||||
/>
|
||||
<strong
|
||||
className="invalid-feedback"
|
||||
id="passwordFieldId-invalid-feedback"
|
||||
>
|
||||
Unable to delete account
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
className="pgn__modal-footer"
|
||||
>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
<div
|
||||
className="pgn__action-row"
|
||||
>
|
||||
Yes, Delete
|
||||
</button>
|
||||
<button
|
||||
className="btn js-close-modal-on-click btn-secondary"
|
||||
id="paragonCloseModalButton3"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-link"
|
||||
disabled={false}
|
||||
onClick={[MockFunction]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={false}
|
||||
onClick={[MockFunction]}
|
||||
type="button"
|
||||
>
|
||||
Yes, Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
<div
|
||||
data-focus-guard={true}
|
||||
style={
|
||||
{
|
||||
"height": "0px",
|
||||
"left": "1px",
|
||||
"overflow": "hidden",
|
||||
"padding": 0,
|
||||
"position": "fixed",
|
||||
"top": "1px",
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
tabIndex={0}
|
||||
/>,
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
exports[`DeleteAccount should match default section snapshot 1`] = `
|
||||
<div>
|
||||
<h2
|
||||
className="section-heading"
|
||||
className="section-heading h4 mb-3"
|
||||
>
|
||||
Delete My Account
|
||||
</h2>
|
||||
@@ -11,33 +11,23 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
||||
We're sorry to see you go!
|
||||
</p>
|
||||
<p>
|
||||
Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
|
||||
Please note: Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
|
||||
</p>
|
||||
<p>
|
||||
Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer’s or university’s system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
|
||||
Once your account is deleted, you cannot use it to take courses on localhost.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
|
||||
<a
|
||||
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
follow the instructions for printing or downloading a certificate
|
||||
</a>
|
||||
.
|
||||
</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.
|
||||
</p>
|
||||
<p
|
||||
className="text-danger h6"
|
||||
>
|
||||
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.
|
||||
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on localhost.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
||||
onClick={[Function]}
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
target="_self"
|
||||
>
|
||||
Want to change your email, name, or password instead?
|
||||
@@ -47,9 +37,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onClick={[MockFunction]}
|
||||
type="button"
|
||||
>
|
||||
Delete My Account
|
||||
@@ -61,7 +49,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
|
||||
exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
<div>
|
||||
<h2
|
||||
className="section-heading"
|
||||
className="section-heading h4 mb-3"
|
||||
>
|
||||
Delete My Account
|
||||
</h2>
|
||||
@@ -69,33 +57,23 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
We're sorry to see you go!
|
||||
</p>
|
||||
<p>
|
||||
Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
|
||||
Please note: Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
|
||||
</p>
|
||||
<p>
|
||||
Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer’s or university’s system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
|
||||
Once your account is deleted, you cannot use it to take courses on localhost.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
|
||||
<a
|
||||
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
follow the instructions for printing or downloading a certificate
|
||||
</a>
|
||||
.
|
||||
</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.
|
||||
</p>
|
||||
<p
|
||||
className="text-danger h6"
|
||||
>
|
||||
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.
|
||||
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on localhost.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
||||
onClick={[Function]}
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
target="_self"
|
||||
>
|
||||
Want to change your email, name, or password instead?
|
||||
@@ -105,9 +83,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onClick={null}
|
||||
type="button"
|
||||
>
|
||||
Delete My Account
|
||||
@@ -119,34 +95,32 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
|
||||
data-icon="exclamation-triangle"
|
||||
className="svg-inline--fa fa-triangle-exclamation mr-2"
|
||||
data-icon="triangle-exclamation"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 576 512"
|
||||
style={{}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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"
|
||||
style={Object {}}
|
||||
style={{}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
Before proceeding, please
|
||||
<a
|
||||
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
activate your account
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
Before proceeding, please
|
||||
<a
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
|
||||
target="_self"
|
||||
>
|
||||
activate your account
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,7 +129,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
|
||||
exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||
<div>
|
||||
<h2
|
||||
className="section-heading"
|
||||
className="section-heading h4 mb-3"
|
||||
>
|
||||
Delete My Account
|
||||
</h2>
|
||||
@@ -163,33 +137,23 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||
We're sorry to see you go!
|
||||
</p>
|
||||
<p>
|
||||
Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
|
||||
Please note: Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
|
||||
</p>
|
||||
<p>
|
||||
Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer’s or university’s system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
|
||||
Once your account is deleted, you cannot use it to take courses on localhost.
|
||||
</p>
|
||||
<p>
|
||||
<span>
|
||||
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
|
||||
<a
|
||||
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
follow the instructions for printing or downloading a certificate
|
||||
</a>
|
||||
.
|
||||
</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.
|
||||
</p>
|
||||
<p
|
||||
className="text-danger h6"
|
||||
>
|
||||
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.
|
||||
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on localhost.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
|
||||
onClick={[Function]}
|
||||
className="pgn__hyperlink default-link standalone-link"
|
||||
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
|
||||
target="_self"
|
||||
>
|
||||
Want to change your email, name, or password instead?
|
||||
@@ -199,9 +163,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onClick={null}
|
||||
type="button"
|
||||
>
|
||||
Delete My Account
|
||||
@@ -213,34 +175,32 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
|
||||
<div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
|
||||
data-icon="exclamation-triangle"
|
||||
className="svg-inline--fa fa-triangle-exclamation mr-2"
|
||||
data-icon="triangle-exclamation"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 576 512"
|
||||
style={{}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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"
|
||||
style={Object {}}
|
||||
style={{}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
Before proceeding, please
|
||||
<a
|
||||
href="https://support.edx.org/hc/en-us/articles/207206067"
|
||||
onClick={[Function]}
|
||||
target="_self"
|
||||
>
|
||||
unlink all social media accounts
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
Before proceeding, please
|
||||
<a
|
||||
className="pgn__hyperlink 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"
|
||||
target="_self"
|
||||
>
|
||||
unlink all social media accounts
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,311 +1,93 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SuccessModal should match default closed success modal snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
className="fade"
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className="modal js-close-modal-on-click fade"
|
||||
onMouseDown={[Function]}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="id2"
|
||||
aria-modal={true}
|
||||
className=""
|
||||
role="dialog"
|
||||
tabIndex="-1"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
>
|
||||
<div
|
||||
className="modal-header"
|
||||
>
|
||||
<h2
|
||||
className="modal-title"
|
||||
id="id2"
|
||||
>
|
||||
We're sorry to see you go! Your account will be deleted shortly.
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="h6"
|
||||
>
|
||||
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
>
|
||||
<button
|
||||
className="btn js-close-modal-on-click btn-secondary"
|
||||
id="paragonCloseModalButton1"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
exports[`SuccessModal should match default closed success modal snapshot 1`] = `null`;
|
||||
|
||||
exports[`SuccessModal should match default closed success modal snapshot 2`] = `
|
||||
<div>
|
||||
<div
|
||||
className="fade"
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className="modal js-close-modal-on-click fade"
|
||||
onMouseDown={[Function]}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="id4"
|
||||
aria-modal={true}
|
||||
className=""
|
||||
role="dialog"
|
||||
tabIndex="-1"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
>
|
||||
<div
|
||||
className="modal-header"
|
||||
>
|
||||
<h2
|
||||
className="modal-title"
|
||||
id="id4"
|
||||
>
|
||||
We're sorry to see you go! Your account will be deleted shortly.
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="h6"
|
||||
>
|
||||
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
>
|
||||
<button
|
||||
className="btn js-close-modal-on-click btn-secondary"
|
||||
id="paragonCloseModalButton3"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
exports[`SuccessModal should match default closed success modal snapshot 2`] = `null`;
|
||||
|
||||
exports[`SuccessModal should match default closed success modal snapshot 3`] = `
|
||||
<div>
|
||||
<div
|
||||
className="fade"
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className="modal js-close-modal-on-click fade"
|
||||
onMouseDown={[Function]}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="id6"
|
||||
aria-modal={true}
|
||||
className=""
|
||||
role="dialog"
|
||||
tabIndex="-1"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
>
|
||||
<div
|
||||
className="modal-header"
|
||||
>
|
||||
<h2
|
||||
className="modal-title"
|
||||
id="id6"
|
||||
>
|
||||
We're sorry to see you go! Your account will be deleted shortly.
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="h6"
|
||||
>
|
||||
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
>
|
||||
<button
|
||||
className="btn js-close-modal-on-click btn-secondary"
|
||||
id="paragonCloseModalButton5"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
exports[`SuccessModal should match default closed success modal snapshot 3`] = `null`;
|
||||
|
||||
exports[`SuccessModal should match default closed success modal snapshot 4`] = `
|
||||
<div>
|
||||
<div
|
||||
className="fade"
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className="modal js-close-modal-on-click fade"
|
||||
onMouseDown={[Function]}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="id8"
|
||||
aria-modal={true}
|
||||
className=""
|
||||
role="dialog"
|
||||
tabIndex="-1"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
>
|
||||
<div
|
||||
className="modal-header"
|
||||
>
|
||||
<h2
|
||||
className="modal-title"
|
||||
id="id8"
|
||||
>
|
||||
We're sorry to see you go! Your account will be deleted shortly.
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="h6"
|
||||
>
|
||||
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
>
|
||||
<button
|
||||
className="btn js-close-modal-on-click btn-secondary"
|
||||
id="paragonCloseModalButton7"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
exports[`SuccessModal should match default closed success modal snapshot 4`] = `null`;
|
||||
|
||||
exports[`SuccessModal should match open success modal snapshot 1`] = `
|
||||
<div>
|
||||
[
|
||||
<div
|
||||
className="modal-backdrop show"
|
||||
role="presentation"
|
||||
/>
|
||||
data-focus-guard={true}
|
||||
style={
|
||||
{
|
||||
"height": "0px",
|
||||
"left": "1px",
|
||||
"overflow": "hidden",
|
||||
"padding": 0,
|
||||
"position": "fixed",
|
||||
"top": "1px",
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
tabIndex={0}
|
||||
/>,
|
||||
<div
|
||||
className="modal js-close-modal-on-click show d-block"
|
||||
className="pgn__modal-layer"
|
||||
data-focus-lock-disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
role="presentation"
|
||||
onScrollCapture={[Function]}
|
||||
onTouchMoveCapture={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
onWheelCapture={[Function]}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="id10"
|
||||
aria-modal={true}
|
||||
className="modal-dialog"
|
||||
role="dialog"
|
||||
tabIndex="-1"
|
||||
className="pgn__modal-content-container"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
className="pgn__modal-backdrop"
|
||||
data-testid="modal-backdrop"
|
||||
onClick={[MockFunction]}
|
||||
onKeyDown={[MockFunction]}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className="mw-sm p-5 bg-white mx-auto my-3"
|
||||
>
|
||||
<h3>
|
||||
We're sorry to see you go! Your account will be deleted shortly.
|
||||
</h3>
|
||||
<div
|
||||
className="modal-header"
|
||||
className="p-3"
|
||||
>
|
||||
<h2
|
||||
className="modal-title"
|
||||
id="id10"
|
||||
<p
|
||||
className="h6"
|
||||
>
|
||||
We're sorry to see you go! Your account will be deleted shortly.
|
||||
</h2>
|
||||
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="h6"
|
||||
>
|
||||
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
>
|
||||
<p>
|
||||
<button
|
||||
className="btn js-close-modal-on-click btn-secondary"
|
||||
id="paragonCloseModalButton9"
|
||||
onBlur={[Function]}
|
||||
className="pgn__modal-close-button float-right btn btn-link"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
<div
|
||||
data-focus-guard={true}
|
||||
style={
|
||||
{
|
||||
"height": "0px",
|
||||
"left": "1px",
|
||||
"overflow": "hidden",
|
||||
"padding": 0,
|
||||
"position": "fixed",
|
||||
"top": "1px",
|
||||
"width": "1px",
|
||||
}
|
||||
}
|
||||
tabIndex={0}
|
||||
/>,
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -15,7 +15,9 @@ export function* handleDeleteAccount(action) {
|
||||
const response = yield call(postDeleteAccount, action.payload.password);
|
||||
yield put(deleteAccountSuccess(response));
|
||||
} catch (e) {
|
||||
if (typeof e.response.data === 'string') {
|
||||
if (e.response.status === 403) {
|
||||
yield put(deleteAccountFailure('invalid-password'));
|
||||
} else if (typeof e.response.data === 'string') {
|
||||
yield put(deleteAccountFailure());
|
||||
} else {
|
||||
throw e;
|
||||
|
||||
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 as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
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': {
|
||||
id: 'account.settings.delete.account.header',
|
||||
defaultMessage: 'Delete My Account',
|
||||
@@ -13,22 +18,27 @@ const messages = defineMessages({
|
||||
},
|
||||
'account.settings.delete.account.text.1': {
|
||||
id: 'account.settings.delete.account.text.1',
|
||||
defaultMessage: 'Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.',
|
||||
defaultMessage: 'Please note: Deletion of your account and personal data is permanent and cannot be undone. {siteName} will not be able to recover your account or the data that is deleted.',
|
||||
description: 'A message in the user account deletion area',
|
||||
},
|
||||
'account.settings.delete.account.text.2': {
|
||||
id: 'account.settings.delete.account.text.2',
|
||||
defaultMessage: 'Once your account is deleted, you cannot use it to take courses on {siteName}.',
|
||||
description: 'A message in the user account deletion area',
|
||||
},
|
||||
'account.settings.delete.account.text.2.edX': {
|
||||
id: 'account.settings.delete.account.text.2.edX',
|
||||
defaultMessage: 'Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer’s or university’s system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.',
|
||||
description: 'A message in the user account deletion area',
|
||||
},
|
||||
'account.settings.delete.account.text.3.link': {
|
||||
id: 'account.settings.delete.account.text.3.link',
|
||||
defaultMessage: 'follow the instructions for printing or downloading a certificate',
|
||||
description: 'This text will be a link to a technical support page; it will go in the phrase If you want to make a copy of these for your records, ______ .',
|
||||
defaultMessage: 'Follow these instructions for printing or downloading a certificate',
|
||||
description: 'This text is a link to a technical support page where users can learn how to print or download their certificates.',
|
||||
},
|
||||
'account.settings.delete.account.text.warning': {
|
||||
id: 'account.settings.delete.account.text.warning',
|
||||
defaultMessage: 'Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.',
|
||||
defaultMessage: 'Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on {siteName}.',
|
||||
description: 'A message in the user account deletion area',
|
||||
},
|
||||
'account.settings.delete.account.text.change.instead': {
|
||||
@@ -39,13 +49,18 @@ const messages = defineMessages({
|
||||
'account.settings.delete.account.button': {
|
||||
id: 'account.settings.delete.account.button',
|
||||
defaultMessage: 'Delete My Account',
|
||||
description: 'Button label to permanently delete your edX account',
|
||||
description: 'Button label to permanently delete your platform account',
|
||||
},
|
||||
'account.settings.delete.account.please.activate': {
|
||||
id: 'account.settings.delete.account.please.activate',
|
||||
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.',
|
||||
},
|
||||
'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': {
|
||||
id: 'account.settings.delete.account.please.unlink',
|
||||
defaultMessage: 'unlink all social media accounts',
|
||||
@@ -58,11 +73,16 @@ const messages = defineMessages({
|
||||
},
|
||||
'account.settings.delete.account.modal.text.1': {
|
||||
id: 'account.settings.delete.account.modal.text.1',
|
||||
defaultMessage: 'You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.',
|
||||
defaultMessage: 'You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. {siteName} will not be able to recover your account or the data that is deleted.',
|
||||
description: 'Messaging in the dialog asking user to confirm that they want to delete their entire account',
|
||||
},
|
||||
'account.settings.delete.account.modal.text.2': {
|
||||
id: 'account.settings.delete.account.modal.text.2',
|
||||
defaultMessage: 'If you proceed, you will be unable to use this account to take courses on {siteName}.',
|
||||
description: 'Messaging in the dialog asking user to confirm that they want to delete their entire account',
|
||||
},
|
||||
'account.settings.delete.account.modal.text.2.edX': {
|
||||
id: 'account.settings.delete.account.modal.text.2.edX',
|
||||
defaultMessage: 'If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer\'s or university\'s system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.',
|
||||
description: 'Messaging in the dialog asking user to confirm that they want to delete their entire account',
|
||||
},
|
||||
@@ -91,6 +111,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'A password is required',
|
||||
description: 'Error message when user has not entered their password',
|
||||
},
|
||||
'account.settings.delete.account.error.invalid.password': {
|
||||
id: 'account.settings.delete.account.error.invalid.password',
|
||||
defaultMessage: 'Password is incorrect',
|
||||
description: 'Error message when user has entered incorrect password',
|
||||
},
|
||||
'account.settings.delete.account.error.unable.to.delete.details': {
|
||||
id: 'account.settings.delete.account.error.unable.to.delete.details',
|
||||
defaultMessage: 'Sorry, there was an error trying to process your request. Please try again later.',
|
||||
|
||||
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 as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
|
||||
202
src/account-settings/name-change/NameChange.jsx
Normal file
202
src/account-settings/name-change/NameChange.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { connect, useDispatch } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
ModalDialog,
|
||||
StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { closeForm, saveSettingsReset } from '../data/actions';
|
||||
import { nameChangeSelector } from '../data/selectors';
|
||||
|
||||
import { requestNameChange, requestNameChangeFailure, requestNameChangeReset } from './data/actions';
|
||||
import messages from './messages';
|
||||
|
||||
const NameChangeModal = ({
|
||||
targetFormId,
|
||||
errors,
|
||||
formValues,
|
||||
saveState,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { username } = getAuthenticatedUser();
|
||||
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
|
||||
const [confirmedWarning, setConfirmedWarning] = useState(false);
|
||||
const intl = useIntl();
|
||||
|
||||
const resetLocalState = useCallback(() => {
|
||||
setConfirmedWarning(false);
|
||||
dispatch(requestNameChangeReset());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setVerifiedNameInput(e.target.value);
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetLocalState();
|
||||
dispatch(closeForm(targetFormId));
|
||||
dispatch(saveSettingsReset());
|
||||
}, [dispatch, resetLocalState, targetFormId]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (saveState === 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifiedNameInput) {
|
||||
dispatch(requestNameChangeFailure({
|
||||
verified_name: intl.formatMessage(messages['account.settings.name.change.error.valid.name']),
|
||||
}));
|
||||
} else {
|
||||
const draftProfileName = targetFormId === 'name' ? formValues.name : null;
|
||||
dispatch(requestNameChange(username, draftProfileName, verifiedNameInput));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState === 'complete') {
|
||||
handleClose();
|
||||
navigate(`/id-verification?next=${encodeURIComponent('account/settings')}`);
|
||||
}
|
||||
}, [handleClose, navigate, saveState]);
|
||||
|
||||
function renderErrors() {
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return (
|
||||
<>
|
||||
{Object.entries(errors).map(([key, value]) => (
|
||||
<Form.Control.Feedback type="invalid" key={key}>
|
||||
{
|
||||
key === 'general_error'
|
||||
? intl.formatMessage(messages['account.settings.name.change.error.general'])
|
||||
: value
|
||||
}
|
||||
</Form.Control.Feedback>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderTitle() {
|
||||
if (!confirmedWarning) {
|
||||
return intl.formatMessage(messages['account.settings.name.change.title.id']);
|
||||
}
|
||||
|
||||
return intl.formatMessage(messages['account.settings.name.change.title.begin']);
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
if (!confirmedWarning) {
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<p>
|
||||
{intl.formatMessage(messages['account.settings.name.change.warning.one'])}
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(messages['account.settings.name.change.warning.two'])}
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group as={Col} isInvalid={Object.keys(errors).length > 0}>
|
||||
<Form.Label>
|
||||
{intl.formatMessage(messages['account.settings.name.change.id.name.label'])}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name="verifiedName"
|
||||
placeholder={intl.formatMessage(messages['account.settings.name.change.id.name.placeholder'])}
|
||||
value={verifiedNameInput}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{renderErrors()}
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContinueButton() {
|
||||
if (!confirmedWarning) {
|
||||
return (
|
||||
<Button variant="primary" onClick={() => setConfirmedWarning(true)}>
|
||||
{intl.formatMessage(messages['account.settings.name.change.continue'])}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
state={saveState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['account.settings.name.change.continue']),
|
||||
}}
|
||||
disabledStates={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={renderTitle()}
|
||||
isOpen
|
||||
hasCloseButton={false}
|
||||
onClose={handleClose}
|
||||
>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{renderTitle()}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
|
||||
<ModalDialog.Body className="mb-3 overflow-hidden">
|
||||
{renderBody()}
|
||||
</ModalDialog.Body>
|
||||
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages['account.settings.name.change.cancel'])}
|
||||
</ModalDialog.CloseButton>
|
||||
{renderContinueButton()}
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</Form>
|
||||
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
NameChangeModal.propTypes = {
|
||||
targetFormId: PropTypes.string.isRequired,
|
||||
errors: PropTypes.shape({}).isRequired,
|
||||
formValues: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
verified_name: PropTypes.string,
|
||||
}).isRequired,
|
||||
saveState: PropTypes.string,
|
||||
};
|
||||
|
||||
NameChangeModal.defaultProps = {
|
||||
saveState: null,
|
||||
};
|
||||
|
||||
export default connect(nameChangeSelector)(NameChangeModal);
|
||||
25
src/account-settings/name-change/data/actions.js
Normal file
25
src/account-settings/name-change/data/actions.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AsyncActionType } from '../../data/utils';
|
||||
|
||||
export const REQUEST_NAME_CHANGE = new AsyncActionType('ACCOUNT_SETTINGS', 'REQUEST_NAME_CHANGE');
|
||||
|
||||
export const requestNameChange = (username, profileName, verifiedName) => ({
|
||||
type: REQUEST_NAME_CHANGE.BASE,
|
||||
payload: { username, profileName, verifiedName },
|
||||
});
|
||||
|
||||
export const requestNameChangeBegin = () => ({
|
||||
type: REQUEST_NAME_CHANGE.BEGIN,
|
||||
});
|
||||
|
||||
export const requestNameChangeSuccess = () => ({
|
||||
type: REQUEST_NAME_CHANGE.SUCCESS,
|
||||
});
|
||||
|
||||
export const requestNameChangeFailure = errors => ({
|
||||
type: REQUEST_NAME_CHANGE.FAILURE,
|
||||
payload: { errors },
|
||||
});
|
||||
|
||||
export const requestNameChangeReset = () => ({
|
||||
type: REQUEST_NAME_CHANGE.RESET,
|
||||
});
|
||||
44
src/account-settings/name-change/data/reducers.js
Normal file
44
src/account-settings/name-change/data/reducers.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { REQUEST_NAME_CHANGE } from './actions';
|
||||
|
||||
export const defaultState = {
|
||||
saveState: null,
|
||||
errors: {},
|
||||
};
|
||||
|
||||
const reducer = (state = defaultState, action = null) => {
|
||||
if (action !== null) {
|
||||
switch (action.type) {
|
||||
case REQUEST_NAME_CHANGE.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'pending',
|
||||
errors: {},
|
||||
};
|
||||
|
||||
case REQUEST_NAME_CHANGE.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'complete',
|
||||
};
|
||||
|
||||
case REQUEST_NAME_CHANGE.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'error',
|
||||
errors: action.payload.errors || { general_error: 'A technical error occurred. Please try again.' },
|
||||
};
|
||||
|
||||
case REQUEST_NAME_CHANGE.RESET:
|
||||
return {
|
||||
...state,
|
||||
saveState: null,
|
||||
errors: {},
|
||||
};
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default reducer;
|
||||
40
src/account-settings/name-change/data/sagas.js
Normal file
40
src/account-settings/name-change/data/sagas.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { put, call, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { postVerifiedName } from '../../data/service';
|
||||
|
||||
import {
|
||||
REQUEST_NAME_CHANGE,
|
||||
requestNameChangeBegin,
|
||||
requestNameChangeSuccess,
|
||||
requestNameChangeFailure,
|
||||
} from './actions';
|
||||
import { postNameChange } from './service';
|
||||
|
||||
export function* handleRequestNameChange(action) {
|
||||
let { name: profileName } = getAuthenticatedUser();
|
||||
try {
|
||||
yield put(requestNameChangeBegin());
|
||||
if (action.payload.profileName) {
|
||||
yield call(postNameChange, action.payload.profileName);
|
||||
profileName = action.payload.profileName;
|
||||
}
|
||||
yield call(postVerifiedName, {
|
||||
username: action.payload.username,
|
||||
verified_name: action.payload.verifiedName,
|
||||
profile_name: profileName,
|
||||
});
|
||||
yield put(requestNameChangeSuccess());
|
||||
} catch (err) {
|
||||
if (err.customAttributes?.httpErrorResponseData) {
|
||||
yield put(requestNameChangeFailure(JSON.parse(err.customAttributes.httpErrorResponseData)));
|
||||
} else {
|
||||
yield put(requestNameChangeFailure());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(REQUEST_NAME_CHANGE.BASE, handleRequestNameChange);
|
||||
}
|
||||
17
src/account-settings/name-change/data/service.js
Normal file
17
src/account-settings/name-change/data/service.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { handleRequestError } from '../../data/utils';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function postNameChange(name) {
|
||||
// Requests a pending name change, rather than saving the account name immediately
|
||||
const requestConfig = { headers: { Accept: 'application/json' } };
|
||||
const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/name_change/`;
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(requestUrl, { name }, requestConfig)
|
||||
.catch(error => handleRequestError(error));
|
||||
|
||||
return data;
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
5
src/account-settings/name-change/index.js
Normal file
5
src/account-settings/name-change/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export { default } from './NameChange';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
export { REQUEST_NAME_CHANGE } from './data/actions';
|
||||
56
src/account-settings/name-change/messages.js
Normal file
56
src/account-settings/name-change/messages.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'account.settings.name.change.title.id': {
|
||||
id: 'account.settings.name.change.title.id',
|
||||
defaultMessage: 'This name change requires identity verification',
|
||||
description: 'Inform the user that changing their name requires identity verification',
|
||||
},
|
||||
'account.settings.name.change.title.begin': {
|
||||
id: 'account.settings.name.change.title.begin',
|
||||
defaultMessage: 'Before we begin',
|
||||
description: 'Title before beginning the ID verification process',
|
||||
},
|
||||
'account.settings.name.change.warning.one': {
|
||||
id: 'account.settings.name.change.warning.one',
|
||||
defaultMessage: 'Warning: This action updates the name that appears on all certificates that have been earned on this account in the past and any certificates you are currently earning or will earn in the future.',
|
||||
description: 'Warning informing the user that a name change will update the name on all of their certificates.',
|
||||
},
|
||||
'account.settings.name.change.warning.two': {
|
||||
id: 'account.settings.name.change.warning.two',
|
||||
defaultMessage: 'This action cannot be undone without verifying your identity.',
|
||||
description: 'Warning informing the user that a name change cannot be undone without ID verification.',
|
||||
},
|
||||
'account.settings.name.change.id.name.label': {
|
||||
id: 'account.settings.name.change.id.name.label',
|
||||
defaultMessage: 'Enter your name as it appears on your identification card.',
|
||||
description: 'Form label instructing the user to enter the name on their ID.',
|
||||
},
|
||||
'account.settings.name.change.id.name.placeholder': {
|
||||
id: 'account.settings.name.change.id.name.placeholder',
|
||||
defaultMessage: 'Enter the name on your photo ID',
|
||||
description: 'Form label instructing the user to enter the name on their ID.',
|
||||
},
|
||||
'account.settings.name.change.error.valid.name': {
|
||||
id: 'account.settings.name.change.error.valid.name',
|
||||
defaultMessage: 'Please enter a valid name.',
|
||||
description: 'Error that appears when the user doesn’t enter a valid name.',
|
||||
},
|
||||
'account.settings.name.change.error.general': {
|
||||
id: 'account.settings.name.change.error.general',
|
||||
defaultMessage: 'A technical error occurred. Please try again.',
|
||||
description: 'Generic error message.',
|
||||
},
|
||||
'account.settings.name.change.continue': {
|
||||
id: 'account.settings.name.change.continue',
|
||||
defaultMessage: 'Continue',
|
||||
description: 'Continue button.',
|
||||
},
|
||||
'account.settings.name.change.cancel': {
|
||||
id: 'account.settings.name.change.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Cancel button.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
168
src/account-settings/name-change/test/NameChange.test.jsx
Normal file
168
src/account-settings/name-change/test/NameChange.test.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/* eslint-disable no-import-assign */
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
|
||||
import * as auth from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
|
||||
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
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ nameChangeSelector: () => ({}) })));
|
||||
|
||||
const mockStore = configureStore();
|
||||
|
||||
describe('NameChange', () => {
|
||||
let props = {};
|
||||
let store = {};
|
||||
|
||||
const reduxWrapper = children => (
|
||||
<Router>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</IntlProvider>
|
||||
</Router>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore();
|
||||
props = {
|
||||
targetFormId: 'test_form',
|
||||
errors: {},
|
||||
formValues: {
|
||||
name: 'edx edx',
|
||||
verified_name: 'edX Verified',
|
||||
},
|
||||
saveState: null,
|
||||
};
|
||||
|
||||
auth.getAuthenticatedHttpClient = jest.fn(() => ({
|
||||
patch: async () => ({
|
||||
data: { status: 200 },
|
||||
catch: () => {},
|
||||
}),
|
||||
}));
|
||||
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3, username: 'edx' }));
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders populated input after clicking continue if verified_name in form data', async () => {
|
||||
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
|
||||
|
||||
render(reduxWrapper(<NameChange {...props} />));
|
||||
expect(getInput()).toBeNull();
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
expect(getInput().value).toBe('edX Verified');
|
||||
});
|
||||
|
||||
it('renders empty input after clicking continue if verified_name not in form data', async () => {
|
||||
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
|
||||
const formProps = {
|
||||
...props,
|
||||
formValues: {
|
||||
name: 'edx edx',
|
||||
},
|
||||
};
|
||||
render(reduxWrapper(<NameChange {...formProps} />));
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
expect(getInput().value).toBe('');
|
||||
});
|
||||
|
||||
it('dispatches verifiedName on submit if targetForm is not "name"', async () => {
|
||||
const dispatchData = {
|
||||
payload: {
|
||||
profileName: null,
|
||||
username: 'edx',
|
||||
verifiedName: 'Verified Name',
|
||||
},
|
||||
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
|
||||
};
|
||||
|
||||
render(reduxWrapper(<NameChange {...props} />));
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
|
||||
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
||||
|
||||
const submitButton = screen.getByText('Continue');
|
||||
fireEvent.click(submitButton);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(dispatchData);
|
||||
});
|
||||
|
||||
it('dispatches both profileName and verifiedName on submit if the targetForm is "name"', async () => {
|
||||
const dispatchData = {
|
||||
payload: {
|
||||
profileName: 'edx edx',
|
||||
username: 'edx',
|
||||
verifiedName: 'Verified Name',
|
||||
},
|
||||
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
|
||||
};
|
||||
const formProps = {
|
||||
...props,
|
||||
targetFormId: 'name',
|
||||
};
|
||||
|
||||
render(reduxWrapper(<NameChange {...formProps} />));
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
|
||||
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
||||
|
||||
const submitButton = screen.getByText('Continue');
|
||||
fireEvent.click(submitButton);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(dispatchData);
|
||||
});
|
||||
|
||||
it('does not dispatch action while pending', async () => {
|
||||
props.saveState = 'pending';
|
||||
|
||||
render(reduxWrapper(<NameChange {...props} />));
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
|
||||
fireEvent.change(input, { target: { value: 'Verified Name' } });
|
||||
|
||||
const submitButton = screen.getByText('Continue');
|
||||
fireEvent.click(submitButton);
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes to IDV when name change request is successful', async () => {
|
||||
props.saveState = 'complete';
|
||||
|
||||
render(reduxWrapper(<NameChange {...props} />));
|
||||
expect(window.location.pathname).toEqual('/id-verification');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
@@ -12,7 +13,7 @@ const ConfirmationAlert = (props) => {
|
||||
|
||||
const technicalSupportLink = (
|
||||
<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
|
||||
id="account.settings.editable.field.password.reset.button.confirmation.support.link"
|
||||
|
||||
@@ -5,20 +5,17 @@ import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import Alert from '../Alert';
|
||||
|
||||
const RequestInProgressAlert = (props) => {
|
||||
|
||||
return (
|
||||
<Alert
|
||||
className="alert-warning mt-n2"
|
||||
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="account.settings.editable.field.password.reset.button.forbidden"
|
||||
defaultMessage="Your previous request is in progress, please try again in few moments."
|
||||
description="A message displayed when a previous password reset request is still in progress."
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
const RequestInProgressAlert = () => (
|
||||
<Alert
|
||||
className="alert-warning mt-n2"
|
||||
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="account.settings.editable.field.password.reset.button.forbidden"
|
||||
defaultMessage="Your previous request is in progress, please try again in few moments."
|
||||
description="A message displayed when a previous password reset request is still in progress."
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
export default RequestInProgressAlert;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { StatefulButton } from '@edx/paragon';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { StatefulButton } from '@openedx/paragon';
|
||||
|
||||
import { resetPassword } from './data/actions';
|
||||
import messages from './messages';
|
||||
@@ -10,7 +9,9 @@ import ConfirmationAlert from './ConfirmationAlert';
|
||||
import RequestInProgressAlert from './RequestInProgressAlert';
|
||||
|
||||
const ResetPassword = (props) => {
|
||||
const { email, intl, status } = props;
|
||||
const { email, status } = props;
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<h6 aria-level="3">
|
||||
@@ -22,7 +23,7 @@ const ResetPassword = (props) => {
|
||||
</h6>
|
||||
<p>
|
||||
<StatefulButton
|
||||
className="btn-link"
|
||||
variant="link"
|
||||
state={status}
|
||||
onClick={(e) => {
|
||||
// Swallow clicks if the state is pending.
|
||||
@@ -51,7 +52,6 @@ const ResetPassword = (props) => {
|
||||
|
||||
ResetPassword.propTypes = {
|
||||
email: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
resetPassword: PropTypes.func.isRequired,
|
||||
status: PropTypes.string,
|
||||
};
|
||||
@@ -68,4 +68,4 @@ export default connect(
|
||||
{
|
||||
resetPassword,
|
||||
},
|
||||
)(injectIntl(ResetPassword));
|
||||
)(ResetPassword);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { put, call, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
import { resetPasswordBegin, resetPasswordForbidden, resetPasswordSuccess, RESET_PASSWORD } from './actions';
|
||||
import {
|
||||
resetPasswordBegin, resetPasswordForbidden, resetPasswordSuccess, RESET_PASSWORD,
|
||||
} from './actions';
|
||||
import { postResetPassword } from './service';
|
||||
|
||||
function* handleResetPassword(action) {
|
||||
|
||||
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 as reducer } from './data/reducers';
|
||||
export { RESET_PASSWORD } from './data/actions';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user