Compare commits

...

222 Commits

Author SHA1 Message Date
renovate[bot]
55cb76612e fix(deps): update font awesome 2026-03-16 00:54:02 +00:00
edX requirements bot
a1c35f134c chore: update browserslist DB (#1421)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-16 00:53:02 +00:00
renovate[bot]
41af76f027 fix(deps): update dependency qs to v6.15.0 (#1418)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 04:52:00 +00:00
edX requirements bot
57a3b0963c chore: update browserslist DB (#1417)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-09 00:47:58 +00:00
Emad Rad
92dcdd0a98 fix: update button labels in ConfirmationModal for better localization
Close #1415
2026-03-03 15:23:57 -03:00
edX requirements bot
29f0cefc1e chore: update browserslist DB (#1414)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-02 00:50:06 +00:00
renovate[bot]
794a1d57b9 fix(deps): update dependency bowser to v2.14.1 (#1411)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 22:37:50 +00:00
edX requirements bot
fe46e8a1a6 chore: update browserslist DB (#1413)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-23 21:07:21 +00:00
renovate[bot]
a525d3c22e fix(deps): update dependency core-js to v3.48.0 (#1412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 21:00:37 +00:00
renovate[bot]
81a2c3c0d2 chore(deps): update dependency @edx/frontend-platform to v8.5.5 (#1410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 05:49:56 +00:00
renovate[bot]
0018eafdcc chore(deps): update dependency @edx/browserslist-config to v1.5.1 (#1409)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 05:49:31 +00:00
renovate[bot]
e7db2ef753 fix(deps): update dependency qs to v6.14.2 [security] (#1408)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 01:12:05 +00:00
edX requirements bot
2e986d9b74 chore: update browserslist DB (#1407)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-16 00:59:58 +00:00
Brian Smith
7c85195a27 fix(deps): regenerate package-lock.json (#1405)
* fix(deps): regenerate package-lock.json

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test: update snapshots

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-02-12 09:41:39 -05:00
edX requirements bot
b686acf5f5 chore: update browserslist DB (#1406)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-02-09 00:48:51 +00:00
Anton Melser
166deeafbd docs: Generify currently supported node version 2026-01-27 09:57:11 -03:00
renovate[bot]
f33fe7d0e5 chore(deps): update dependency @edx/frontend-platform to v8.5.4 (#1404)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 08:50:30 +00:00
edX requirements bot
e2896dbf94 chore: update browserslist DB (#1403)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-01-26 00:42:27 +00:00
renovate[bot]
f07d266a43 chore(deps): update react-router monorepo to v6.30.3 (#1402)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 06:45:57 +00:00
edX requirements bot
69cdc5f191 chore: update browserslist DB (#1392)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-01-19 00:41:31 +00:00
Awais Ansari
4f51f71acc feat: implemented notifications configurations V3 API (#1401)
* feat: implemented notifications configurations V3 API

* fix: removed default daily email cadence when email toggle is turned on
2026-01-15 18:56:02 +05:00
renovate[bot]
c70eca1fde fix(deps): update dependency qs to v6.14.1 [security] (#1400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 20:21:35 +00:00
Diana Villalvazo
39dc5bbbd2 docs: update owner/maintainer (#1398) 2026-01-13 13:27:29 -06:00
renovate[bot]
7fa61f3714 fix(deps): update dependency bowser to v2.13.1 (#1393)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 18:43:37 +00:00
Diana Villalvazo
8ce7c1599d test: fix X/twitter broken tests (#1399) 2026-01-13 13:39:44 -05:00
Stanislav
03bdcff331 feat: Change Twitter to X (#1215) 2025-12-02 21:46:31 +05:00
Awais Ansari
d73d840e93 feat: added env parse to boolean functionality (#1389) 2025-12-01 19:39:53 +05:00
edX requirements bot
d23b5f53df chore: update browserslist DB (#1388)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-12-01 00:44:00 +00:00
Awais Ansari
da98bfa021 refactor: simplify notifications channels flag logic (#1381) 2025-11-24 16:20:58 +05:00
renovate[bot]
c37640aa69 fix(deps): update dependency core-js to v3.47.0 (#1384)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 05:10:14 +00:00
renovate[bot]
ea35227389 fix(deps): update dependency bowser to v2.13.0 (#1383)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 05:10:00 +00:00
edX requirements bot
c35bf95c1c chore: update browserslist DB (#1382)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-24 00:39:08 +00:00
renovate[bot]
bd26928154 chore(deps): update react-router monorepo to v6.30.2 (#1379)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 05:47:51 +00:00
edX requirements bot
cdc8efe17b chore: update browserslist DB (#1378)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-17 00:37:06 +00:00
edX requirements bot
8b6535ea58 chore: update browserslist DB (#1372)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-10 00:37:40 +00:00
renovate[bot]
4c9498971a fix(deps): update dependency redux-saga to v1.4.2 (#1370)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 06:43:39 +00:00
edX requirements bot
a6b6a3f940 chore: update browserslist DB (#1369)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-11-03 00:37:33 +00:00
renovate[bot]
cab1a24e10 chore(deps): update dependency @edx/frontend-platform to v8.5.2 (#1367)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 06:51:56 +00:00
renovate[bot]
f6b7782d24 chore(deps): update dependency @edx/frontend-component-footer to v14.9.3 (#1366)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 06:51:47 +00:00
edX requirements bot
c7bbe8d0d1 chore: update browserslist DB (#1363)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-10-20 00:37:12 +00:00
renovate[bot]
20fd7ea13b chore(deps): update dependency @openedx/paragon to v23.14.8 (#1361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 00:33:36 -04:00
renovate[bot]
d55d38ec12 fix(deps): update dependency core-js to v3.46.0 (#1362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 04:33:28 +00:00
edX requirements bot
e77c6ee74a chore: update browserslist DB (#1360)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-10-13 00:36:37 +00:00
renovate[bot]
8cb30bedd8 chore(deps): update dependency @testing-library/jest-dom to v6.9.1 (#1359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 05:59:28 +00:00
renovate[bot]
0ab3f5f669 chore(deps): update dependency @openedx/paragon to v23.14.4 (#1358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 05:59:22 +00:00
edX requirements bot
9eaab9c2e5 chore: update browserslist DB (#1357)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-10-06 00:34:08 +00:00
renovate[bot]
ac28626b3c fix(deps): update dependency core-js to v3.45.1 (#1356)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 04:51:54 +00:00
renovate[bot]
3f90fea26c chore(deps): update dependency @openedx/paragon to v23.14.3 (#1355)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 04:51:46 +00:00
edX requirements bot
a0466852d6 chore: update browserslist DB (#1354)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-29 00:34:57 +00:00
Feanil Patel
9fad507ada Merge pull request #1353 from openedx/feanil/remove-reactifex-packages
build: remove unused reactifex packages
2025-09-26 16:14:50 -04:00
Feanil Patel
73351fa8e8 build: Drop translation pushing.
This is now handled via the openedx-translations repo so we don't need
this target that uses scripts from the deprecated reactifex repo.
2025-09-26 16:08:41 -04:00
Feanil Patel
c8528a7874 build: remove unused reactifex packages
Remove both reactifex and @edx/reactifex packages from devDependencies
as they are no longer needed. Translation extraction functionality has
been verified to work correctly without these dependencies.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 16:08:37 -04:00
bydawen
124909ab74 test: Remove support for Node 20 (#1345)
Co-authored-by: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com>
2025-09-26 10:49:44 -03:00
oleksandr.buhaienko
77f66f3afb build: Upgrade to Node 24 2025-09-26 09:17:19 -03:00
renovate[bot]
102288407f chore(deps): update dependency @testing-library/jest-dom to v6.8.0 (#1352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 01:43:38 -04:00
renovate[bot]
dd7c35497e fix(deps): update dependency form-urlencoded to v6.1.6 (#1351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 05:43:31 +00:00
edX requirements bot
8331d37b7f chore: update browserslist DB (#1350)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-22 00:37:03 +00:00
bydawen
1d12506b01 test: Add Node 24 to CI matrix (#1342) 2025-09-19 13:55:15 -04:00
renovate[bot]
c22c4ec5a6 fix(deps): update dependency @edx/frontend-platform to v8.5.1 (#1349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 05:31:26 +00:00
renovate[bot]
69fc4be952 fix(deps): update dependency @edx/frontend-component-footer to v14.9.2 (#1348)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 05:31:11 +00:00
Awais Ansari
9d946eacd8 Revert "Replace of injectIntl with useIntl() 9/10" (#1346) 2025-09-11 22:00:53 +05:00
renovate[bot]
0af0935e86 fix(deps): update dependency @edx/frontend-component-footer to v14.9.1 (#1340)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 00:58:49 -04:00
renovate[bot]
fd33842109 fix(deps): update dependency @edx/frontend-component-header to v6.6.1 (#1341)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 04:58:43 +00:00
edX requirements bot
13b5f3bc12 chore: update browserslist DB (#1339)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-08 00:35:49 +00:00
Brian Smith
502ad904ea Merge pull request #1328 from openedx/revert-1314-1295/replaceInjectIntl10of10
Revert "Replace of injectIntl with useIntl() 10/10"
2025-09-04 18:37:24 -04:00
diana-villalvazo-wgu
594ae27c0e test: do not revert increase on coverage 2025-09-04 15:13:07 -06:00
sundasnoreen12
0924cb1ba3 Revert "Replace of injectIntl with useIntl() 10/10" 2025-09-04 15:13:07 -06:00
Samuel Allan
90ee5800b4 fix: update frontend-build to fix install issues (#1335)
Earlier versions of @openedx/frontend-build used on older version of
'sharp', which caused intermittent installation issues. The version of
'sharp' was updated in @openedx/frontend-build to fix these issues, so
the frontend-build version can be updated here, to fix the issues in
this project too. See
https://github.com/openedx/frontend-build/issues/664 and
https://github.com/openedx/frontend-build/pull/665 for more information.

The frontend-build dependency was updated by:

```
npm install --package-lock-only @openedx/frontend-build
```

Private-ref: https://tasks.opencraft.com/browse/BB-9953
2025-09-04 08:49:37 -06:00
Diana Villalvazo
6be87b4a82 refactor: Replace of injectIntl with useIntl() (#1303) 2025-09-02 13:52:23 -04:00
edX requirements bot
2137e985b3 chore: update browserslist DB (#1334)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-09-01 00:41:51 +00:00
bydawen
ca93f890e1 fix: long email field on account page (#1024)
* fix: long email field on account page

* fix: Add tooltip to the email field

---------

Co-authored-by: Stanislav Lunyachek <stanislav.lunyachek@raccoongang.com>
2025-08-28 15:07:28 +05:00
Eemaan Amir
6a57622a3c test: added test cases to improve test coverage (#1330)
* test: added test cases to improve test coverage

* fix: updated the link for code coverage

* fix: fixed lint errors

* test: added test cases

* fix: fixed message id
2025-08-26 22:04:22 +05:00
renovate[bot]
3d490c3879 fix(deps): update dependency bowser to v2.12.1 (#1333)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 04:50:54 +00:00
renovate[bot]
1e09c83300 fix(deps): update dependency @fortawesome/react-fontawesome to v0.2.6 (#1332)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 04:50:43 +00:00
edX requirements bot
b660903836 chore: update browserslist DB (#1331)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-08-25 00:36:40 +00:00
renovate[bot]
3d2b8416f9 chore(deps): update dependency @testing-library/jest-dom to v6.7.0 (#1327)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 06:57:56 +00:00
renovate[bot]
4afd07201b fix(deps): update dependency @openedx/paragon to v23.14.2 (#1326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 06:57:52 +00:00
edX requirements bot
94ead51915 chore: update browserslist DB (#1317)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-08-18 00:39:58 +00:00
Brayan Cerón
587e3f2647 feat: add slot to extend the profile fields (#1254)
* feat: add ExtendedProfileFieldsSlot component

* feat: update ExtendedProfileFieldsSlot documentation and images

* fix: format code in ExtendedProfileFieldsSlot README for consistency

* feat: add error handling to ExtendedProfileFieldsSlot component

* feat: replace ExtendedProfileFieldsSlot with AdditionalProfileFieldsSlot and update documentation

* feat: add Example component for AdditionalProfileFieldsSlot and update README

* fix: update README to reflect correct slot name and widget ID for AdditionalProfileFieldsSlot

* chore: remove unused default_fields.png image
2025-08-13 12:34:12 -04:00
renovate[bot]
3394656ed2 fix(deps): update dependency @edx/frontend-platform to v8.5.0 (#1322)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 10:25:30 +00:00
renovate[bot]
66eda1a58d fix(deps): update dependency @edx/frontend-component-header to v6.6.0 (#1321)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 10:25:25 +00:00
renovate[bot]
c1ccc8c201 fix(deps): update dependency bowser to v2.12.0 (#1323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 07:37:14 +00:00
renovate[bot]
4ae65cfcee fix(deps): update dependency @openedx/paragon to v23.14.1 (#1320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 07:36:46 +00:00
sundasnoreen12
ad7e2035bc Merge pull request #1314 from WGU-Open-edX/1295/replaceInjectIntl10of10
Replace of injectIntl with useIntl() 10/10
2025-08-11 12:32:26 +05:00
sundasnoreen12
d5d67dbe14 Merge pull request #1307 from WGU-Open-edX/1294/replaceInjectIntl9of10
Replace of injectIntl with useIntl() 9/10
2025-08-11 12:30:53 +05:00
Diana Villalvazo
3423e5efea refactor: Replace of injectIntl with useIntl() (#1306) 2025-08-07 16:35:14 -04:00
Diana Villalvazo
f3d30925a8 refactor: Replace of injectIntl with useIntl() (#1305) 2025-08-07 16:31:34 -04:00
Diana Villalvazo
1ec2c3b262 refactor: Replace of injectIntl with useIntl() (#1304) 2025-08-07 16:27:49 -04:00
Diana Villalvazo
260df228fb Replace of injectIntl with useIntl() 4/10 (#1299)
* refactor: Replace of injectIntl with useIntl()

* fix: improve coverage
2025-08-07 16:24:46 -04:00
Diana Villalvazo
6b740a89c6 refactor: Replace of injectIntl with useIntl() (#1298) 2025-08-07 16:21:34 -04:00
Diana Villalvazo
03f8fdbdc3 refactor: Replace of injectIntl with useIntl() (#1297) 2025-08-07 16:18:20 -04:00
diana-villalvazo-wgu
0b86166a57 test: improve coverage 2025-08-07 13:08:04 -05:00
diana-villalvazo-wgu
46eefa7592 refactor: replace injectIntl 2025-08-07 12:40:04 -05:00
Stanislav
036b4be854 feat: Restore jump nav and content width (#994) 2025-08-07 17:30:39 +05:00
Eemaan Amir
eae0bfdca2 Merge pull request #1318 from openedx/INF-2087
chore: updated the notification section description
2025-08-07 11:29:32 +05:00
eemaanamir
05f5903cbc fix: fixed lint 2025-08-07 11:12:19 +05:00
eemaanamir
725ae950f4 chore: updated the notification section description 2025-08-04 16:46:32 +05:00
Eemaan Amir
38c4f3bad3 Merge pull request #1315 from openedx/INF-2003
chore: cleaned up course level preferences apis
2025-07-31 17:22:59 +05:00
eemaanamir
46acf2a5a4 chore: cleaned up course level preferences apis 2025-07-30 12:37:50 +05:00
wgu-jesse-stewart
e8aafef127 feat: add dev script to package.json (#1313)
* fix: add dev script to package.json

* docs: update readme with dev script
2025-07-29 09:46:38 -04:00
renovate[bot]
cf451770ed fix(deps): update dependency @fortawesome/react-fontawesome to v0.2.3 (#1312)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 06:40:00 +00:00
renovate[bot]
d9c3975f26 chore(deps): update dependency @testing-library/jest-dom to v6.6.4 (#1311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 06:39:48 +00:00
diana-villalvazo-wgu
5f42857332 refactor: Replace of injectIntl with useIntl() 2025-07-21 14:38:02 -06:00
renovate[bot]
f6babc2db9 fix(deps): update dependency core-js to v3.44.0 (#1302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 10:33:00 +00:00
renovate[bot]
67a053f3e0 fix(deps): update dependency @edx/frontend-component-header to v6.4.2 (#1301)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 07:34:41 +00:00
Diana Villalvazo
845ab30af5 refactor: Replace of injectIntl with useIntl() (#1296) 2025-07-17 16:33:42 -07:00
Muhammad Adeel Tajamul
3244ecf70b feat: added support for non editable field in notification preference (#1284) 2025-07-09 10:00:25 +05:00
renovate[bot]
a1390ebf36 fix(deps): update dependency @openedx/paragon to v23.14.0 (#1283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 07:34:04 +00:00
renovate[bot]
89f9d9511f fix(deps): update dependency @edx/frontend-component-header to v6.4.1 (#1282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 07:33:43 +00:00
edX requirements bot
bc66c74a33 chore: update browserslist DB (#1281)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-07-07 00:39:25 +00:00
Ahtisham Shahid
d760be1a53 feat: integrated notification preferences v2 API (#1280) 2025-07-04 17:00:09 +05:00
renovate[bot]
929a34a0f6 fix(deps): update dependency core-js to v3.43.0 (#1278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 06:08:09 +00:00
edX requirements bot
55fc919c6f chore: update browserslist DB (#1277)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-06-30 00:39:16 +00:00
Hassan Raza
102a93486e Merge pull request #1271 from openedx/hraza/INF-1917
feat: Add notification for discussion app
2025-06-27 19:23:28 +05:00
renovate[bot]
981ba84163 chore(deps): update dependency @openedx/frontend-build to v14.6.1 (#1275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 23:11:28 +00:00
renovate[bot]
8aa918bfb9 fix(deps): update dependency @openedx/paragon to v23.13.0 (#1276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 19:43:54 +00:00
Adam Stankiewicz
4ca2ab9f4e fix: use Container component with size='xl' (#1274) 2025-06-19 10:07:11 -04:00
Brian Smith
c01f1854ee feat!: add design tokens support (#1272)
BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
2025-06-18 12:35:37 -04:00
Hassan Raza
987484d205 feat: Add notification for discussion app 2025-06-17 14:47:36 +05:00
renovate[bot]
f86496468e fix(deps): update dependency @openedx/paragon to v22.20.2 (#1269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 05:45:50 +00:00
renovate[bot]
e916ba29b9 fix(deps): update dependency @edx/frontend-platform to v8.4.0 (#1268)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 05:45:34 +00:00
edX requirements bot
6410ce1d8f chore: update browserslist DB (#1267)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-06-16 00:37:49 +00:00
Diana Villalvazo
d0eebfa0ea fix: untranslated user facing texts for i18n (#1245)
Co-authored-by: diana-villalvazo-wgu <dianaximena.villalva@wgu.edu>
2025-06-10 11:57:54 -04:00
renovate[bot]
77daf2fbad fix(deps): update dependency @edx/frontend-component-footer to v14.9.0 (#1264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 05:53:40 +00:00
renovate[bot]
354426037e fix(deps): update dependency @edx/frontend-platform to v8.3.9 (#1263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 05:53:31 +00:00
edX requirements bot
b9af9ed700 chore: update browserslist DB (#1261)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-06-09 00:38:12 +00:00
renovate[bot]
2342eaae82 fix(deps): update dependency @edx/frontend-component-footer to v14.8.0 (#1259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 05:42:07 +00:00
renovate[bot]
a1484264fb fix(deps): update dependency @edx/frontend-platform to v8.3.8 (#1258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 05:42:01 +00:00
edX requirements bot
cff4a76b0c chore: update browserslist DB (#1257)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-06-02 00:37:24 +00:00
renovate[bot]
3e2e8095b4 fix(deps): update dependency @openedx/paragon to v22.18.1 (#1256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 01:54:28 -04:00
renovate[bot]
ebd63a13a9 fix(deps): update react-router monorepo to v6.30.1 (#1255)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 05:54:21 +00:00
Muhammad Adeel Tajamul
fd0d08daa1 feat: added immediate option in email cadence dropdown (#1251)
* feat: added immediate option in email cadence dropdown

* chore: added tests
2025-05-20 15:51:07 +05:00
renovate[bot]
6ae4c2d68b fix(deps): update dependency @edx/frontend-platform to v8.3.7 (#1252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 13:56:27 +00:00
renovate[bot]
95331d1b10 fix(deps): update dependency @edx/frontend-platform to v8.3.6 (#1250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 07:20:20 +00:00
renovate[bot]
7c63b66d8e fix(deps): update dependency @edx/frontend-component-footer to v14.7.2 (#1249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 07:20:07 +00:00
edX requirements bot
810f506e52 chore: update browserslist DB (#1248)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-05-19 00:37:19 +00:00
edX requirements bot
33fd669c8f chore: update browserslist DB (#1247)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-05-12 00:37:27 +00:00
Muhammad Adeel Tajamul
5c5204fb17 chore: temporarily disabled notification preferences course dropdown (#1246) 2025-05-08 18:18:08 +05:00
renovate[bot]
95db89a9dd chore(deps): update dependency @openedx/frontend-build to v14.6.0 (#1244)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 05:28:19 +00:00
renovate[bot]
81a878a658 fix(deps): update dependency @edx/frontend-component-footer to v14.7.1 (#1243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 05:28:06 +00:00
edX requirements bot
b502de846a chore: update browserslist DB (#1242)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-05-05 00:37:13 +00:00
renovate[bot]
39f0123820 fix(deps): update dependency @edx/openedx-atlas to ^0.7.0 (#1240)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-28 02:05:08 -04:00
renovate[bot]
7ee70193c0 fix(deps): update dependency @edx/frontend-component-footer to v14.7.0 (#1239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-28 06:05:00 +00:00
Awais Ansari
0e1574dba7 chore: updated NODE_ENV string in .env (#1238) 2025-04-25 15:21:20 +05:00
Brian Smith
0d45ae6599 feat: import FooterSlot from component package instead of slot package (#1230) 2025-04-24 12:18:00 -04:00
Brian Smith
78246cf26b feat: standardize slot ids (#1237) 2025-04-24 07:28:24 -04:00
renovate[bot]
ca193563ec fix(deps): update dependency @edx/frontend-component-header to v6.4.0 (#1236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 16:13:09 -04:00
Awais Ansari
d0efd35e66 Revert "fix: removed extra api call and strict mode (#1234)" (#1235)
This reverts commit 375b704eef.
2025-04-23 15:42:58 -04:00
sundasnoreen12
375b704eef fix: removed extra api call and strict mode (#1234)
* fix: removed extra api call and strict mode

* fix: added default values
2025-04-23 22:13:06 +05:00
Hassan Raza
ae121358db fix: Update ORA notification display title (#1233) 2025-04-23 16:09:29 +05:00
renovate[bot]
92b7c58af7 fix(deps): update dependency long to v5.3.2 (#1232)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 05:46:58 +00:00
renovate[bot]
a4097fe6fc fix(deps): update dependency @openedx/frontend-slot-footer to v1.2.1 (#1231)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 05:46:49 +00:00
Awais Ansari
397f688300 fix: scrolling issue for active menu item 2025-04-16 15:29:09 +05:00
Awais Ansari
8bd4b1b9a8 chore: updated package-lock file 2025-04-16 15:29:09 +05:00
Awais Ansari
54d029c181 test: updated test case snapshots 2025-04-16 15:29:09 +05:00
Hassan Raza
e6a4636147 fix: Update course preference title for ora submission (#1222) 2025-04-16 15:29:09 +05:00
sundasnoreen12
efb4162926 test: added test cases 2025-04-16 15:29:09 +05:00
sundasnoreen12
6061232e10 refactor: refactor code 2025-04-16 15:29:09 +05:00
sundasnoreen12
ba6b8c8f9b refactor: refactor code 2025-04-16 15:29:09 +05:00
sundasnoreen12
9c16ba0075 fix: fixed course level preference issue 2025-04-16 15:29:09 +05:00
sundasnoreen12
02b987909b fix: fixed varaible name 2025-04-16 15:29:09 +05:00
sundasnoreen12
1bcc54bb05 fix: added changes for restricted country 2025-04-16 15:29:09 +05:00
sundasnoreen12
5a5b0b905b fix: removed unused selector 2025-04-16 15:29:09 +05:00
sundasnoreen12
44ed49c7d2 refactor: refactored code 2025-04-16 15:29:09 +05:00
sundasnoreen12
386baa3840 fix: changed frequency from never to daily on email preference change 2025-04-16 15:29:09 +05:00
Awais Ansari
f1a56ad6bc fix: updated notifications section url (#1185)
* fix: updated notifiations section url

* fix: updated test cases
2025-04-16 15:29:09 +05:00
Awais Ansari
465bb9f7a0 feat: added notification preferences settings at account level (#1159)
* feat: added notification preferences settings at account level

* fix: fixed test cases

* feat: added api for account notification type

* fix: fixed test cases and label

* test: added update account preference test case

* fix: fixed issue to update email cadence for account notification type

* refactor: updated time

* fix: fixed mixed cadence issue

* fix: fixed border issue when no preferences

* refactor: refactor code

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com>
2025-04-16 15:29:09 +05:00
Muhammad Adeel Tajamul
f9b7525d44 refactor: moved unable to delete into component (#1177) 2025-04-16 15:29:09 +05:00
Muhammad Adeel Tajamul
c7e82295c2 feat: added feature to hide delete button for countries (#1176) 2025-04-16 15:29:09 +05:00
sundasnoreen12
e02cf28b54 fix: rebase with 2u 2025-04-16 15:29:09 +05:00
Awais Ansari
18c51e8e73 fix: translation and console errors (#1166) 2025-04-16 15:29:09 +05:00
ayesha waris
88b444e796 chore: rebase with master (#1158)
* fix: fixed support urls (#1155)

* fix(deps): update dependency @edx/frontend-component-header to v5.7.1 (#1156)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @openedx/frontend-slot-footer to v1.0.6 (#1157)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix: fixed certificates url

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-16 15:29:09 +05:00
Awais Ansari
71635b33b6 feat: added country disabling feature (#1116)
* feat: added country disabling feature

* refactor: removed isDisabledCountry additional call
2025-04-16 15:29:09 +05:00
Hunia Fatima
515890d5ef feat: upgrade react to v18 (#1220) 2025-04-04 11:29:11 -04:00
renovate[bot]
c2960e1232 chore(deps): update dependency @openedx/frontend-build to v14.4.1 (#1223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 06:44:07 +00:00
Brian Smith
17e12f7f87 chore(deps): update @openedx dependencies to versions that support React 18 (#1218)
* chore(deps): update `@openedx` dependencies to versions that support React 18

* fix(tests): update `JumpNav` tests

* update JumpNav tests to use RTL render/screen instead of `react-test-renderer` snapshots
* update test names to reflect "optional information" not appearing in either scenario
  * the only difference in the snapshot ffc39868e9/src/account-settings/test/__snapshots__/JumpNav.test.jsx.snap is the delete account link

* chore(tests): update snapshots

* chore: fix lint
2025-03-25 12:22:13 -04:00
renovate[bot]
ffc39868e9 fix(deps): update react-router monorepo to v6.30.0 (#1217)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 07:32:44 +00:00
renovate[bot]
9ba01af816 fix(deps): update dependency qs to v6.14.0 (#1216)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 07:32:39 +00:00
Sarina Canelake
fc222cc76c Update README with Tutor instructions; update outdated edx.rtd.io links (#1203)
* docs: remove Devstack mentions from README instructions

* docs: Update edx.rtd.io links to point at new homes

* docs: Apply code review suggestions

Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>

---------

Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>
2025-03-21 15:15:04 +00:00
renovate[bot]
99a39568de fix(deps): update dependency @openedx/frontend-plugin-framework to v1.6.0 (#1210)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 06:23:54 +00:00
renovate[bot]
9ce3cbfddd fix(deps): update dependency long to v5.3.1 (#1211)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 06:23:47 +00:00
renovate[bot]
55a72b3f6e fix(deps): update dependency core-js to v3.41.0 (#1207)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-10 07:26:24 +00:00
renovate[bot]
597004c82e fix(deps): update dependency @openedx/frontend-slot-footer to v1.1.0 (#1206)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-10 07:25:41 +00:00
renovate[bot]
3458b6f410 chore(deps): update dependency @openedx/frontend-build to v14.3.2 (#1204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-10 05:52:22 +00:00
renovate[bot]
b70b4a796f fix(deps): update dependency @openedx/frontend-plugin-framework to v1.5.0 (#1200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 06:16:01 +00:00
renovate[bot]
d76945d2f2 fix(deps): update dependency @edx/frontend-platform to v8.2.1 (#1199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 06:15:48 +00:00
renovate[bot]
7ee34f7d9a chore(deps): update dependency @openedx/frontend-build to v14.3.1 (#1196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 06:00:19 +00:00
renovate[bot]
96c619cab8 fix(deps): update dependency @edx/frontend-component-header to v5.8.3 (#1195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 06:00:08 +00:00
renovate[bot]
9eda5e588c chore(deps): update dependency @edx/browserslist-config to v1.5.0 (#1191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 05:26:57 +00:00
Feanil Patel
3aff0e03e0 Merge pull request #1190 from openedx/feanil-patch-1
docs: Update catalog-info.yaml
2025-02-14 11:12:57 -05:00
Feanil Patel
852358f243 docs: Update catalog-info.yaml
This team was replace/renamed at some point but the catalog file was not updated.
2025-02-14 11:09:12 -05:00
Feanil Patel
5ffd17db4c Merge pull request #1186 from salman2013/salman/update-catalog-info-file
Update catalog-info-file for release data.
2025-01-24 09:40:22 -05:00
renovate[bot]
6f3c9616d6 fix(deps): update dependency long to v5.2.4 (#1188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-20 05:37:47 +00:00
renovate[bot]
5f4812ed47 fix(deps): update dependency @edx/frontend-platform to v8.1.5 (#1187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-20 05:33:41 +00:00
salman2013
d694b5c428 chore: update catalog-info-file for release data and remove openedx.yaml 2025-01-14 15:21:34 +05:00
renovate[bot]
6b73130e9b fix(deps): update font awesome to v6.7.2 (#1183)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-06 07:08:39 +00:00
renovate[bot]
d608f3947e fix(deps): update dependency @edx/frontend-platform to v8.1.4 (#1182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-06 07:08:29 +00:00
renovate[bot]
db83838d5e fix(deps): update dependency @openedx/paragon to v22.13.0 (#1180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-30 08:07:33 +00:00
renovate[bot]
004cdf35f1 fix(deps): update dependency core-js to v3.39.0 (#1181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-30 08:07:20 +00:00
renovate[bot]
6cb015e49d chore(deps): update dependency @edx/browserslist-config to v1.4.0 (#1179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-23 06:14:39 +00:00
renovate[bot]
c5ae2c40d7 fix(deps): update dependency @openedx/frontend-slot-footer to v1.0.7 (#1178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-23 06:14:32 +00:00
renovate[bot]
46edd0cb70 fix(deps): update dependency @edx/frontend-platform to v8.1.3 (#1175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-16 06:16:09 +00:00
renovate[bot]
2c6b5c34a9 fix(deps): update dependency @edx/frontend-component-header to v5.8.2 (#1174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-16 06:15:57 +00:00
renovate[bot]
b000d2e048 fix(deps): update dependency @edx/frontend-component-header to v5.8.1 (#1173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-09 05:35:05 +00:00
renovate[bot]
437fba16fe chore(deps): update dependency @openedx/frontend-build to v14.2.2 (#1172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-09 05:34:44 +00:00
renovate[bot]
c4038b4085 fix(deps): update dependency @openedx/paragon to v22.10.0 (#1170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-02 07:12:14 +00:00
renovate[bot]
496ff21015 fix(deps): update dependency @openedx/frontend-plugin-framework to v1.4.1 (#1169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-02 07:11:53 +00:00
renovate[bot]
23e16ac6e0 fix(deps): update dependency @edx/frontend-component-header to v5.8.0 (#1163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-27 09:42:18 +00:00
renovate[bot]
d8c5762ee2 chore(deps): update dependency @openedx/frontend-build to v14.2.0 (#1162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-27 09:42:02 +00:00
sundasnoreen12
4d2d520234 Merge pull request #1165 from openedx/sundas/INF-1655
fix: fixed url break issue
2024-11-27 14:38:48 +05:00
sundasnoreen12
b094722772 fix: fixed url break issue 2024-11-26 17:01:27 +05:00
renovate[bot]
a9f061f10c fix(deps): update dependency qs to v6.13.1 (#1161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 06:51:21 +00:00
renovate[bot]
4eb5d4effc fix(deps): update dependency @edx/frontend-component-header to v5.7.2 (#1160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 06:49:32 +00:00
renovate[bot]
925bcce81c fix(deps): update dependency @openedx/frontend-slot-footer to v1.0.6 (#1157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-11 07:12:06 +00:00
renovate[bot]
f44e03c7c8 fix(deps): update dependency @edx/frontend-component-header to v5.7.1 (#1156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-11 07:11:39 +00:00
ayesha waris
999988dd88 fix: fixed support urls (#1155) 2024-11-08 14:12:27 +05:00
renovate[bot]
7ec1226965 fix(deps): update dependency @openedx/frontend-plugin-framework to v1.4.0 2024-11-04 06:24:25 +00:00
renovate[bot]
5b00275371 fix(deps): update dependency @edx/frontend-component-header to v5.7.0 2024-11-04 06:24:10 +00:00
renovate[bot]
30c1158775 fix(deps): update dependency universal-cookie to v7.2.2 2024-11-04 05:09:20 +00:00
renovate[bot]
c196b60b39 chore(deps): update dependency @testing-library/jest-dom to v6.6.3 2024-11-04 05:09:08 +00:00
Bilal Qamar
3022a267b1 test: Remove support for Node 18 (#1112) 2024-10-31 15:14:19 -04:00
renovate[bot]
19aacdfaf9 chore(deps): update dependency redux-mock-store to v1.5.5 2024-10-28 04:23:38 +00:00
131 changed files with 13503 additions and 4006 deletions

8
.env
View File

@@ -12,10 +12,11 @@ LOGIN_URL=''
LOGO_TRADEMARK_URL=''
LOGO_URL=''
LOGO_WHITE_URL=''
SHOW_PUSH_CHANNEL=''
SHOW_EMAIL_CHANNEL=''
LOGOUT_URL=''
MARKETING_SITE_BASE_URL=''
NODE_ENV=''
NODE_ENV='production'
ORDER_HISTORY_URL=''
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
@@ -32,4 +33,7 @@ APP_ID=
MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK=''
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'
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={}

View File

@@ -28,9 +28,13 @@ 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://support.edx.org/hc/en-us/articles/207206067'
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={}

View File

@@ -24,10 +24,13 @@ SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_COPPA_COMPLIANCE=''
ENABLE_ACCOUNT_DELETION=''
SHOW_PUSH_CHANNEL=''
SHOW_EMAIL_CHANNEL=''
ENABLE_DOB_UPDATE=''
MARKETING_EMAILS_OPT_IN=''
APP_ID=
MFE_CONFIG_API_URL=
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'
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={}

View File

@@ -3,3 +3,4 @@ dist/
node_modules/
__mocks__/
__snapshots__/
src/i18n/messages/

View File

@@ -20,5 +20,5 @@ Include a link to the sandbox for design changes or screenshot for before and af
#### Post-merge Checklist
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it.
* [ ] Deploy the changes to prod after verifying on stage or ask **@jacobo-dominguez-wgu** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.

View File

@@ -13,12 +13,11 @@ jobs:
- i18n_extract
- lint
- test
node: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
node-version-file: '.nvmrc'
- run: make requirements
- run: make test NPM_TESTS=build
- run: make test NPM_TESTS=${{ matrix.npm-test }}

2
.nvmrc
View File

@@ -1 +1 @@
20
24

View File

@@ -1,5 +1,3 @@
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
@@ -19,6 +17,11 @@ test.npm.%: validate-no-uncommitted-package-lock-changes
npm run $(*)
.PHONY: requirements
precommit:
npm run lint
npm audit
requirements: ## install ci requirements
npm ci

View File

@@ -25,40 +25,13 @@ Getting Started
Prerequisites
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
`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.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Installation
============
This MFE is bundled with `Devstack <https://github.com/openedx/devstack>`_, see the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ section for setup instructions.
1. Install Devstack using the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ instructions.
2. Start up Devstack, if it's not already started.
3. Log in to Devstack (http://localhost:18000/login )
4. Within this project, install requirements and start the development server:
.. code-block::
npm install
npm start # The server will run on port 1997
5. Once the dev server is up, visit http://localhost:1997 to access the MFE
.. image:: ./docs/images/localhost_preview.png
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
Plugins
=======
@@ -69,7 +42,7 @@ The parts of this MFE that can be customized in that manner are documented `here
Environment Variables/Setup Notes
=================================
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
This MFE is configured via the ``frontend-platform`` configuration module. For more information on MFE configuration see the `Configuration documentation`_.
The account settings micro-frontend also supports the following additional variable:
@@ -102,8 +75,9 @@ Example build syntax with a single environment variable:
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
For more information see the document: `Micro-frontend applications in Open
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
For more information see the document: `Configuration documentation`_
.. _Configuration documentation: https://openedx.github.io/frontend-platform/module-Config.html
Cloning and Startup
===================
@@ -115,9 +89,9 @@ Cloning and Startup
``git clone https://github.com/openedx/frontend-app-account.git``
2. Use node v18.x.
2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts support node 18.
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>`_.
@@ -130,6 +104,12 @@ Cloning and Startup
``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
=========================
@@ -238,7 +218,7 @@ Please do not report security issues in public. Please email security@openedx.or
: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

View File

@@ -12,7 +12,8 @@ metadata:
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:edx-infinity
owner: jacobo-dominguez-wgu
type: 'website'
lifecycle: 'production'

15
codecov.yml Normal file
View 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"

View File

@@ -1,6 +0,0 @@
# This file describes this Open edX repo, as described in OEP-2:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
nick: acct
oeps: {}
openedx-release: {ref: master}

12761
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
},
"scripts": {
"build": "fedx-scripts webpack",
"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",
@@ -29,25 +30,25 @@
],
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-platform": "8.1.2",
"@edx/openedx-atlas": "^0.6.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.2",
"@openedx/frontend-plugin-framework": "^1.2.2",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "22.9.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^7.0.0",
"@fortawesome/free-brands-svg-icons": "^7.0.0",
"@fortawesome/free-regular-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@fortawesome/react-fontawesome": "3.2.0",
"@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.11.0",
"bowser": "2.14.1",
"classnames": "2.5.1",
"core-js": "3.38.1",
"core-js": "3.48.0",
"font-awesome": "4.7.0",
"form-urlencoded": "6.1.5",
"form-urlencoded": "6.1.6",
"formdata-polyfill": "4.0.10",
"jslib-html5-camera-photo": "3.3.4",
"lodash.camelcase": "4.3.0",
@@ -60,12 +61,12 @@
"lodash.pick": "4.4.0",
"lodash.pickby": "4.6.0",
"lodash.snakecase": "4.1.1",
"long": "5.2.3",
"long": "5.3.2",
"memoize-one": "^6.0.0",
"prop-types": "15.8.1",
"qs": "6.13.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"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",
@@ -76,20 +77,18 @@
"redux": "4.2.1",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-saga": "1.3.0",
"redux-saga": "1.4.2",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"reselect": "^5.1.1",
"universal-cookie": "7.2.1"
"universal-cookie": "7.2.2"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/reactifex": "2.2.0",
"@openedx/frontend-build": "14.1.5",
"@testing-library/jest-dom": "6.6.2",
"@testing-library/react": "12.1.5",
"react-test-renderer": "17.0.2",
"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"
}
}

View File

@@ -14,7 +14,7 @@ import {
getLanguageList,
} from '@edx/frontend-platform/i18n';
import {
Hyperlink, Icon, Alert,
Container, Hyperlink, Icon, Alert,
} from '@openedx/paragon';
import { CheckCircle, Error, WarningFilled } from '@openedx/paragon/icons';
@@ -47,10 +47,13 @@ import {
COPPA_COMPLIANCE_YEAR,
WORK_EXPERIENCE_OPTIONS,
getStatesList,
FIELD_LABELS,
} from './data/constants';
import { fetchSiteLanguages } from './site-language';
import { fetchCourseList } from '../notification-preferences/data/thunks';
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) {
@@ -65,6 +68,7 @@ class AccountSettingsPage extends React.Component {
'#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(),
@@ -72,7 +76,7 @@ class AccountSettingsPage extends React.Component {
}
componentDidMount() {
this.props.fetchCourseList();
this.props.fetchNotificationPreferences();
this.props.fetchSettings();
this.props.fetchSiteLanguages(this.props.navigate);
sendTrackingLogEvent('edx.user.settings.viewed', {
@@ -120,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']),
@@ -147,11 +159,30 @@ class AccountSettingsPage extends React.Component {
})),
}));
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 = {};
@@ -193,6 +224,12 @@ class AccountSettingsPage extends React.Component {
}
};
isDisabledCountry = (country) => {
const { countriesCodesList } = this.props;
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
};
isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName);
}
@@ -466,7 +503,8 @@ class AccountSettingsPage extends React.Component {
} = 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');
@@ -695,8 +733,10 @@ class AccountSettingsPage extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
{...editableFieldProps}
/>
<AdditionalProfileFieldsSlot />
</div>
<div className="account-section pt-3 mb-5" id="social-media">
<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>
@@ -724,16 +764,19 @@ 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 pt-3 mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
<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>
@@ -775,16 +818,15 @@ class AccountSettingsPage extends React.Component {
<ThirdPartyAuth />
</div>
{getConfig().ENABLE_ACCOUNT_DELETION
&& (
{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>
)}
)}
</>
);
}
@@ -813,24 +855,24 @@ 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'])}
</h1>
<div>
<div className="row">
<div className="col-md-2">
<div className="col-md-3">
<JumpNav />
</div>
<div className="col-md-10">
<div className="col-md-9">
{loading ? this.renderLoading() : null}
{loaded ? this.renderContent() : null}
{loadingError ? this.renderError() : null}
</div>
</div>
</div>
</div>
</Container>
);
}
}
@@ -849,18 +891,21 @@ AccountSettingsPage.propTypes = {
name: PropTypes.string,
email: PropTypes.string,
secondary_email: PropTypes.string,
secondary_email_enabled: PropTypes.bool,
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.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,
state: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool.isRequired,
@@ -870,6 +915,7 @@ AccountSettingsPage.propTypes = {
name: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
verified_name: PropTypes.string,
country: PropTypes.string,
}),
drafts: PropTypes.shape({}),
formErrors: PropTypes.shape({
@@ -902,13 +948,16 @@ AccountSettingsPage.propTypes = {
saveSettings: PropTypes.func.isRequired,
fetchSettings: PropTypes.func.isRequired,
beginNameChange: PropTypes.func.isRequired,
fetchCourseList: PropTypes.func.isRequired,
fetchNotificationPreferences: PropTypes.func.isRequired,
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
connected: PropTypes.bool,
})),
nameChangeModal: PropTypes.shape({
formId: PropTypes.string,
}),
nameChangeModal: PropTypes.oneOfType([
PropTypes.shape({
formId: PropTypes.string,
}),
PropTypes.bool,
]),
verifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
@@ -928,6 +977,12 @@ AccountSettingsPage.propTypes = {
),
navigate: PropTypes.func.isRequired,
location: PropTypes.string.isRequired,
countriesCodesList: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
),
};
AccountSettingsPage.defaultProps = {
@@ -937,6 +992,7 @@ AccountSettingsPage.defaultProps = {
committedValues: {
useVerifiedNameForCerts: false,
verified_name: null,
country: '',
},
drafts: {},
formErrors: {},
@@ -949,14 +1005,15 @@ AccountSettingsPage.defaultProps = {
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
nameChangeModal: {},
nameChangeModal: {} || false,
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
countriesCodesList: [],
};
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
fetchCourseList,
fetchNotificationPreferences,
fetchSettings,
saveSettings,
saveMultipleSettings,

View File

@@ -509,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': {

View File

@@ -1,9 +1,9 @@
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Form, StatefulButton, ModalDialog, ActionRow, useToggle, Button,
} from '@openedx/paragon';
import React, { useCallback, useEffect, useState } from 'react';
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';
@@ -11,11 +11,11 @@ import { editableFieldSelector } from './data/selectors';
import { saveSettingsReset } from './data/actions';
const DOBModal = (props) => {
const intl = useIntl();
const {
saveState,
error,
onSubmit,
intl,
} = props;
const dispatch = useDispatch();
@@ -56,7 +56,7 @@ const DOBModal = (props) => {
function renderErrors() {
if (saveState === 'error' || error) {
return (
<Form.Control.Feedback type="invalid" key="general-error">
<Form.Control.Feedback type="invalid" key="general-error" data-testid="error-message">
{intl.formatMessage(messages['account.settingsfield.dob.error.general'])}
</Form.Control.Feedback>
);
@@ -72,7 +72,7 @@ const DOBModal = (props) => {
return (
<>
<Button variant="primary" onClick={open}>
<Button variant="primary" onClick={open} data-testid="open-modal-button">
{intl.formatMessage(messages['account.settings.field.dob.form.button'])}
</Button>
<ModalDialog
@@ -81,25 +81,27 @@ const DOBModal = (props) => {
onClose={handleClose}
hasCloseButton={false}
variant="default"
data-testid="dob-modal"
>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} data-testid="dob-form">
<ModalDialog.Header>
<ModalDialog.Title>
<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>{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p>
<p data-testid="help-text">{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p>
<Form.Group>
<Form.Label>
<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 => (
@@ -108,13 +110,14 @@ const DOBModal = (props) => {
</Form.Control>
</Form.Group>
<Form.Group>
<Form.Label>
<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 => (
@@ -127,7 +130,7 @@ const DOBModal = (props) => {
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
<ModalDialog.CloseButton variant="tertiary" data-testid="cancel-button">
Cancel
</ModalDialog.CloseButton>
<StatefulButton
@@ -137,6 +140,7 @@ const DOBModal = (props) => {
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
disabledStates={['unedited']}
data-testid="submit-button"
/>
</ActionRow>
</ModalDialog.Footer>
@@ -151,7 +155,6 @@ DOBModal.propTypes = {
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
error: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
DOBModal.defaultProps = {
@@ -159,4 +162,4 @@ DOBModal.defaultProps = {
error: undefined,
};
export default connect(editableFieldSelector)(injectIntl(DOBModal));
export default connect(editableFieldSelector)(DOBModal);

View File

@@ -1,8 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button, Form, StatefulButton,
} from '@openedx/paragon';
@@ -39,10 +38,10 @@ const EditableField = (props) => {
isEditing,
isEditable,
isGrayedOut,
intl,
...others
} = props;
const id = `field-${name}`;
const intl = useIntl();
const handleSubmit = (e) => {
e.preventDefault();
@@ -85,9 +84,13 @@ const EditableField = (props) => {
if (!confirmationMessageDefinition || !confirmationValue) {
return null;
}
return intl.formatMessage(confirmationMessageDefinition, {
value: confirmationValue,
});
return (
<span data-testid="editable-field-confirmation">
{intl.formatMessage(confirmationMessageDefinition, {
value: confirmationValue,
})}
</span>
);
};
return (
@@ -96,7 +99,7 @@ const EditableField = (props) => {
cases={{
editing: (
<>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} data-testid="editable-field-form">
<Form.Group
controlId={id}
isInvalid={error != null}
@@ -109,10 +112,11 @@ const EditableField = (props) => {
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}>{error}</Form.Control.Feedback>}
{error != null && <Form.Control.Feedback hasIcon={false} data-testid="editable-field-error">{error}</Form.Control.Feedback>}
{others.children}
</Form.Group>
<p>
@@ -134,16 +138,21 @@ const EditableField = (props) => {
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} />}
{['name', 'verified_name'].includes(name) && (
<CertificatePreference fieldName={name} data-testid="editable-field-certificate-preference" />
)}
</>
),
default: (
@@ -151,7 +160,7 @@ const EditableField = (props) => {
<div className="d-flex align-items-start">
<h6 aria-level="3">{label}</h6>
{isEditable ? (
<Button variant="link" onClick={handleEdit} className="ml-3">
<Button variant="link" onClick={handleEdit} className="ml-3" data-testid="editable-field-edit" data-clicked="edit">
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
</Button>
) : null}
@@ -188,7 +197,6 @@ EditableField.propTypes = {
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
isGrayedOut: PropTypes.bool,
intl: intlShape.isRequired,
};
EditableField.defaultProps = {
@@ -209,4 +217,4 @@ EditableField.defaultProps = {
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
})(injectIntl(EditableField));
})(EditableField);

View File

@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button, Form, StatefulButton,
} from '@openedx/paragon';
@@ -19,6 +18,7 @@ import { editableFieldSelector } from './data/selectors';
import CertificatePreference from './certificate-preference/CertificatePreference';
const EditableSelectField = (props) => {
const intl = useIntl();
const {
name,
label,
@@ -39,7 +39,6 @@ const EditableSelectField = (props) => {
isEditing,
isEditable,
isGrayedOut,
intl,
...others
} = props;
const id = `field-${name}`;
@@ -107,6 +106,7 @@ const EditableSelectField = (props) => {
<option
value={subOption.value}
key={`${subOption.value}-${subOption.label}`}
disabled={subOption?.disabled}
>
{subOption.label}
</option>
@@ -115,7 +115,7 @@ const EditableSelectField = (props) => {
);
}
return (
<option value={option.value} key={`${option.value}-${option.label}`}>
<option value={option.value} key={`${option.value}-${option.label}`} disabled={option?.disabled}>
{option.label}
</option>
);
@@ -226,7 +226,6 @@ EditableSelectField.propTypes = {
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
isGrayedOut: PropTypes.bool,
intl: intlShape.isRequired,
};
EditableSelectField.defaultProps = {
@@ -248,4 +247,4 @@ EditableSelectField.defaultProps = {
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
})(injectIntl(EditableSelectField));
})(EditableSelectField);

View File

@@ -1,9 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Button, StatefulButton, Form,
Button, StatefulButton, Form, Tooltip, OverlayTrigger,
} from '@openedx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
@@ -35,9 +34,9 @@ const EmailField = (props) => {
onChange,
isEditing,
isEditable,
intl,
} = props;
const id = `field-${name}`;
const intl = useIntl();
const handleSubmit = (e) => {
e.preventDefault();
@@ -162,7 +161,16 @@ const EmailField = (props) => {
</Button>
) : null}
</div>
<p data-hj-suppress>{renderValue()}</p>
<OverlayTrigger
placement="top"
overlay={(
<Tooltip id={`tooltip-${name}`} variant="light" className="d-sm-none">
{renderValue()}
</Tooltip>
)}
>
<p data-hj-suppress className="text-truncate">{renderValue()}</p>
</OverlayTrigger>
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
</div>
),
@@ -191,7 +199,6 @@ EmailField.propTypes = {
onChange: PropTypes.func.isRequired,
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
intl: intlShape.isRequired,
};
EmailField.defaultProps = {
@@ -210,4 +217,4 @@ EmailField.defaultProps = {
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
})(injectIntl(EmailField));
})(EmailField);

View File

@@ -1,35 +1,30 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { breakpoints, useWindowSize, Icon } from '@openedx/paragon';
import { OpenInNew } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import classNames from 'classnames';
import React from 'react';
import { useSelector } from 'react-redux';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';
import { Link } from 'react-router-dom';
import messages from './AccountSettingsPage.messages';
import { selectShowPreferences } from '../notification-preferences/data/selectors';
const JumpNav = ({
intl,
}) => {
const JumpNav = () => {
const intl = useIntl();
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
const showPreferences = useSelector(selectShowPreferences());
return (
<div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
<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">
@@ -46,6 +41,11 @@ const JumpNav = ({
{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'])}
@@ -65,27 +65,8 @@ const JumpNav = ({
</li>
)}
</Scrollspy>
{showPreferences && (
<>
<hr />
<Scrollspy
className="list-unstyled"
>
<li>
<Link to="/notifications" target="_blank" rel="noopener noreferrer">
<span>{intl.formatMessage(messages['notification.preferences.notifications.label'])}</span>
<Icon className="d-inline-block align-bottom ml-1" src={OpenInNew} />
</Link>
</li>
</Scrollspy>
</>
)}
</div>
);
};
JumpNav.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(JumpNav);
export default JumpNav;

View File

@@ -29,7 +29,6 @@
}
}
.custom-switch {
padding: 0;
max-width: 500px;
@@ -44,3 +43,8 @@
filter: alpha(opacity = 60); /* MSIE */
}
}
#tooltip-email .small {
display: block;
margin: 0 !important;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
@@ -8,7 +8,7 @@ import {
ModalDialog,
StatefulButton,
} from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
closeForm,
@@ -22,7 +22,6 @@ import commonMessages from '../AccountSettingsPage.messages';
import messages from './messages';
const CertificatePreference = ({
intl,
fieldName,
originalFullName,
originalVerifiedName,
@@ -33,6 +32,7 @@ const CertificatePreference = ({
const [checked, setChecked] = useState(false);
const [modalIsOpen, setModalIsOpen] = useState(false);
const formId = 'useVerifiedNameForCerts';
const intl = useIntl();
const handleCheckboxChange = () => {
if (!checked) {
@@ -155,7 +155,6 @@ const CertificatePreference = ({
};
CertificatePreference.propTypes = {
intl: intlShape.isRequired,
fieldName: PropTypes.string.isRequired,
originalFullName: PropTypes.string,
originalVerifiedName: PropTypes.string,
@@ -170,4 +169,4 @@ CertificatePreference.defaultProps = {
useVerifiedNameForCerts: false,
};
export default connect(certPreferenceSelector)(injectIntl(CertificatePreference));
export default connect(certPreferenceSelector)(CertificatePreference);

View File

@@ -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);
});
});

View File

@@ -1,6 +1,5 @@
/* eslint-disable no-import-assign */
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -11,10 +10,14 @@ import {
} from '@testing-library/react';
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import messages from '../messages';
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
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 CertificatePreference from '../CertificatePreference'; // eslint-disable-line import/first
@@ -27,8 +30,6 @@ jest.mock('react-redux', () => ({
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
const IntlCertificatePreference = injectIntl(CertificatePreference);
const mockStore = configureStore();
describe('NameChange', () => {
@@ -36,7 +37,7 @@ describe('NameChange', () => {
let store = {};
const formId = 'useVerifiedNameForCerts';
const updateDraft = 'UPDATE_DRAFT';
const labelText = 'If checked, this name will appear on your certificates and public-facing records.';
const labelText = messages['account.settings.field.name.checkbox.certificate.select'].defaultMessage;
const reduxWrapper = children => (
<Router>
@@ -54,7 +55,6 @@ describe('NameChange', () => {
originalVerifiedName: 'edX Verified',
saveState: null,
useVerifiedNameForCerts: false,
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
@@ -74,7 +74,7 @@ describe('NameChange', () => {
originalVerifiedName: '',
};
const wrapper = render(reduxWrapper(<IntlCertificatePreference {...props} />));
const wrapper = render(reduxWrapper(<CertificatePreference {...props} />));
expect(wrapper).toMatchSnapshot();
});
@@ -85,7 +85,7 @@ describe('NameChange', () => {
useVerifiedNameForCerts: true,
};
render(reduxWrapper(<IntlCertificatePreference {...props} />));
render(reduxWrapper(<CertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(false);
@@ -100,7 +100,7 @@ describe('NameChange', () => {
});
it('triggers modal when attempting to uncheck checkbox', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
render(reduxWrapper(<CertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true);
@@ -112,7 +112,7 @@ describe('NameChange', () => {
});
it('updates draft when changing radio value', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
render(reduxWrapper(<CertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
@@ -130,7 +130,7 @@ describe('NameChange', () => {
});
it('clears draft on cancel', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
render(reduxWrapper(<CertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
@@ -143,7 +143,7 @@ describe('NameChange', () => {
});
it('submits', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
render(reduxWrapper(<CertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
@@ -163,7 +163,7 @@ describe('NameChange', () => {
useVerifiedNameForCerts: true,
};
render(reduxWrapper(<IntlCertificatePreference {...props} />));
render(reduxWrapper(<CertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true);

View File

@@ -27,6 +27,7 @@ export const fetchSettingsSuccess = ({
profileDataManager,
timeZones,
verifiedNameHistory,
countriesCodesList,
}) => ({
type: FETCH_SETTINGS.SUCCESS,
payload: {
@@ -35,6 +36,7 @@ export const fetchSettingsSuccess = ({
profileDataManager,
timeZones,
verifiedNameHistory,
countriesCodesList,
},
});

View File

@@ -132,6 +132,6 @@ export function getStatesList(country) {
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
}
export const DECLINED = 'declined';
export const SELF_DESCRIBE = 'self-describe';
export const OTHER = 'other';
export const FIELD_LABELS = {
COUNTRY: 'country',
};

View File

@@ -39,6 +39,7 @@ export const defaultState = {
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: {},
countriesCodesList: [],
};
const reducer = (state = defaultState, action = {}) => {
@@ -64,6 +65,7 @@ const reducer = (state = defaultState, action = {}) => {
loaded: true,
loadingError: null,
verifiedNameHistory: action.payload.verifiedNameHistory,
countriesCodesList: action.payload.countriesCodesList,
};
case FETCH_SETTINGS.FAILURE:
return {

View File

@@ -53,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,
@@ -71,6 +71,7 @@ export function* handleFetchSettings() {
profileDataManager,
timeZones,
verifiedNameHistory,
countriesCodesList: countries,
}));
} catch (e) {
yield put(fetchSettingsFailure(e.message));

View File

@@ -88,6 +88,11 @@ const previousSiteLanguageSelector = createSelector(
accountSettings => accountSettings.previousSiteLanguage,
);
const countriesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.countriesCodesList,
);
const editableFieldErrorSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
@@ -237,6 +242,7 @@ export const accountSettingsPageSelector = createSelector(
mostRecentApprovedVerifiedNameValueSelector,
mostRecentVerifiedNameSelector,
sortedVerifiedNameHistorySelector,
countriesSelector,
(
accountSettings,
siteLanguageOptions,
@@ -254,6 +260,7 @@ export const accountSettingsPageSelector = createSelector(
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
) => ({
siteLanguageOptions,
siteLanguage,
@@ -274,6 +281,7 @@ export const accountSettingsPageSelector = createSelector(
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
}),
);

View File

@@ -1,5 +1,6 @@
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';
@@ -7,9 +8,10 @@ import isEmpty from 'lodash.isempty';
import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth';
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' },
];
@@ -186,6 +188,24 @@ export async function postVerifiedName(data) {
.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, and
* ThirdPartyAuth.
@@ -197,12 +217,14 @@ export async function getSettings(username, userRoles) {
thirdPartyAuthProviders,
profileDataManager,
timeZones,
countries,
] = await Promise.all([
getAccount(username),
getPreferences(username),
getThirdPartyAuthProviders(),
getProfileDataManager(username, userRoles),
getTimeZones(),
getCountryList(),
]);
return {
@@ -211,6 +233,7 @@ export async function getSettings(username, userRoles) {
thirdPartyAuthProviders,
profileDataManager,
timeZones,
countries,
};
}

View 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');
});
});
});

View File

@@ -1,6 +1,5 @@
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 '@openedx/paragon';
@@ -13,7 +12,8 @@ import messages from './messages';
import Alert from '../Alert';
const BeforeProceedingBanner = (props) => {
const { instructionMessageId, intl, supportArticleUrl } = props;
const { instructionMessageId, supportArticleUrl } = props;
const intl = useIntl();
return (
<Alert
@@ -41,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;

View File

@@ -1,25 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl, createIntl } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
ReactDOM.createPortal = node => node;
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
const IntlBeforeProceedingBanner = injectIntl(BeforeProceedingBanner);
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',
intl: createIntl({ locale: 'en' }),
supportArticleUrl: '',
};
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlBeforeProceedingBanner
<BeforeProceedingBanner
{...props}
/>
</IntlProvider>
@@ -31,13 +29,12 @@ describe('BeforeProceedingBanner', () => {
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',
intl: createIntl({ locale: 'en' }),
supportArticleUrl: 'http://test-support.edx',
};
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlBeforeProceedingBanner
<BeforeProceedingBanner
{...props}
/>
</IntlProvider>

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import {
AlertModal,
Button, Input, ValidationFormGroup, ActionRow,
Button, Form, ActionRow,
} from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
@@ -78,10 +78,11 @@ export class ConfirmationModal extends Component {
isOpen={open}
title={intl.formatMessage(messages['account.settings.delete.account.modal.header'])}
onClose={onCancel}
isOverflowVisible
footerNode={(
<ActionRow>
<Button variant="link" onClick={onCancel}>Cancel</Button>
<Button variant="danger" onClick={onSubmit}>Yes, Delete</Button>
<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>
)}
>
@@ -107,22 +108,26 @@ export class ConfirmationModal extends Component {
<PrintingInstructions />
</p>
</Alert>
<ValidationFormGroup
<Form.Group
for={passwordFieldId}
invalid={errorType !== null}
invalidMessage={intl.formatMessage(invalidMessage)}
isInvalid={errorType !== null}
>
<label className="d-block" htmlFor={passwordFieldId}>
<Form.Label className="d-block" htmlFor={passwordFieldId}>
{intl.formatMessage(messages['account.settings.delete.account.modal.enter.password'])}
</label>
<Input
</Form.Label>
<Form.Control
name="password"
id={passwordFieldId}
type="password"
value={password}
onChange={onChange}
/>
</ValidationFormGroup>
{errorType !== null && (
<Form.Control.Feedback type="invalid" feedback-for={passwordFieldId}>
{intl.formatMessage(invalidMessage)}
</Form.Control.Feedback>
)}
</Form.Group>
</div>
</AlertModal>

View File

@@ -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

View File

@@ -76,67 +76,74 @@ export class DeleteAccount extends React.Component {
<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'],
{ 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://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
variant="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={optInInstructionMessageId}
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email-"
/>
)}
<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={supportArticleUrl}
/>
) : 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>
);
}
@@ -152,6 +159,7 @@ DeleteAccount.propTypes = {
errorType: PropTypes.oneOf(['empty-password', 'server']),
hasLinkedTPA: PropTypes.bool,
isVerifiedAccount: PropTypes.bool,
canDeleteAccount: PropTypes.bool,
intl: intlShape.isRequired,
};
@@ -160,6 +168,7 @@ DeleteAccount.defaultProps = {
isVerifiedAccount: true,
status: null,
errorType: null,
canDeleteAccount: true,
};
// Assume we're part of the accountSettings state.

View File

@@ -1,19 +1,19 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
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
// 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://support.edx.org/hc/en-us/sections/115004173027-Receive-and-Share-edX-Certificates"
destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates"
>
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
{intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
</Hyperlink>
);
@@ -40,8 +40,4 @@ const PrintingInstructions = (props) => {
);
};
PrintingInstructions.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(PrintingInstructions);
export default PrintingInstructions;

View File

@@ -1,12 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
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 (
<ModalLayer isOpen={status === 'deleted'} onClose={onClose}>
@@ -20,7 +20,7 @@ export const SuccessModal = (props) => {
</p>
</div>
<p>
<ModalCloseButton className="float-right" variant="link">Close</ModalCloseButton>
<ModalCloseButton className="float-right" variant="link">{intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}</ModalCloseButton>
</p>
</div>
@@ -31,7 +31,6 @@ export const SuccessModal = (props) => {
SuccessModal.propTypes = {
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
@@ -39,4 +38,4 @@ SuccessModal.defaultProps = {
status: null,
};
export default injectIntl(SuccessModal);
export default SuccessModal;

View File

@@ -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();
});
});
});

View File

@@ -57,7 +57,6 @@ exports[`BeforeProceedingBanner should match the snapshot when SUPPORT_URL_TO_UN
<a
className="pgn__hyperlink default-link standalone-link"
href="http://test-support.edx"
onClick={[Function]}
target="_self"
>
unlink all social media accounts

View File

@@ -38,6 +38,7 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
role="presentation"
/>
<div
aria-label="Are you sure?"
@@ -131,30 +132,57 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
</div>
</div>
<div
className="form-group"
data-testid="validation-form-group"
className="pgn__form-group"
for="passwordFieldId"
>
<label
className="d-block"
htmlFor="passwordFieldId"
className="pgn__form-label d-block"
htmlFor="form-field3"
>
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"
<div
className="pgn__form-control-decorator-group"
>
A password is required
</strong>
<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"
/>
</div>
<div
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
feedback-for="passwordFieldId"
id="form-field3-5"
>
<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"
>
<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>
<div>
A password is required
</div>
</div>
</div>
</div>
</div>
@@ -220,15 +248,17 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
"width": "1px",
}
}
tabIndex={-1}
tabIndex={0}
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled="disabled"
data-focus-lock-disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
@@ -239,6 +269,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
role="presentation"
/>
<div
aria-label="Are you sure?"
@@ -299,30 +330,28 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
</div>
</div>
<div
className="form-group"
data-testid="validation-form-group"
className="pgn__form-group"
for="passwordFieldId"
>
<label
className="d-block"
htmlFor="passwordFieldId"
className="pgn__form-label d-block"
htmlFor="form-field1"
>
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"
<div
className="pgn__form-control-decorator-group"
>
Unable to delete account
</strong>
<input
className="has-value form-control"
id="form-field1"
name="password"
onBlur={[Function]}
onChange={[Function]}
type="password"
value="fluffy bunnies"
/>
</div>
</div>
</div>
</div>
@@ -368,7 +397,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
"width": "1px",
}
}
tabIndex={-1}
tabIndex={0}
/>,
]
`;

View File

@@ -27,8 +27,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self"
>
Want to change your email, name, or password instead?
@@ -74,8 +73,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self"
>
Want to change your email, name, or password instead?
@@ -117,8 +115,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
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-"
onClick={[Function]}
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
target="_self"
>
activate your account
@@ -156,8 +153,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self"
>
Want to change your email, name, or password instead?
@@ -199,8 +195,7 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/207206067"
onClick={[Function]}
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

View File

@@ -23,15 +23,17 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
"width": "1px",
}
}
tabIndex={-1}
tabIndex={0}
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled="disabled"
data-focus-lock-disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
@@ -42,6 +44,7 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
role="presentation"
/>
<div
className="mw-sm p-5 bg-white mx-auto my-3"
@@ -84,7 +87,7 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
"width": "1px",
}
}
tabIndex={-1}
tabIndex={0}
/>,
]
`;

View 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);
});
});

View File

@@ -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',

View File

@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useState } from 'react';
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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
@@ -25,7 +25,6 @@ const NameChangeModal = ({
targetFormId,
errors,
formValues,
intl,
saveState,
}) => {
const dispatch = useDispatch();
@@ -33,6 +32,7 @@ const NameChangeModal = ({
const { username } = getAuthenticatedUser();
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
const [confirmedWarning, setConfirmedWarning] = useState(false);
const intl = useIntl();
const resetLocalState = useCallback(() => {
setConfirmedWarning(false);
@@ -193,11 +193,10 @@ NameChangeModal.propTypes = {
verified_name: PropTypes.string,
}).isRequired,
saveState: PropTypes.string,
intl: intlShape.isRequired,
};
NameChangeModal.defaultProps = {
saveState: null,
};
export default connect(nameChangeSelector)(injectIntl(NameChangeModal));
export default connect(nameChangeSelector)(NameChangeModal);

View 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);
});
});

View File

@@ -1,6 +1,4 @@
/* eslint-disable no-import-assign */
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
@@ -11,10 +9,13 @@ import {
} from '@testing-library/react';
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
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 NameChange from '../NameChange'; // eslint-disable-line import/first
@@ -27,8 +28,6 @@ jest.mock('react-redux', () => ({
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ nameChangeSelector: () => ({}) })));
const IntlNameChange = injectIntl(NameChange);
const mockStore = configureStore();
describe('NameChange', () => {
@@ -53,7 +52,6 @@ describe('NameChange', () => {
verified_name: 'edX Verified',
},
saveState: null,
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
@@ -70,7 +68,7 @@ describe('NameChange', () => {
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(<IntlNameChange {...props} />));
render(reduxWrapper(<NameChange {...props} />));
expect(getInput()).toBeNull();
const continueButton = screen.getByText('Continue');
@@ -87,7 +85,7 @@ describe('NameChange', () => {
name: 'edx edx',
},
};
render(reduxWrapper(<IntlNameChange {...formProps} />));
render(reduxWrapper(<NameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
@@ -105,7 +103,7 @@ describe('NameChange', () => {
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
};
render(reduxWrapper(<IntlNameChange {...props} />));
render(reduxWrapper(<NameChange {...props} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
@@ -132,7 +130,7 @@ describe('NameChange', () => {
targetFormId: 'name',
};
render(reduxWrapper(<IntlNameChange {...formProps} />));
render(reduxWrapper(<NameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
@@ -148,7 +146,7 @@ describe('NameChange', () => {
it('does not dispatch action while pending', async () => {
props.saveState = 'pending';
render(reduxWrapper(<IntlNameChange {...props} />));
render(reduxWrapper(<NameChange {...props} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
@@ -164,7 +162,7 @@ describe('NameChange', () => {
it('routes to IDV when name change request is successful', async () => {
props.saveState = 'complete';
render(reduxWrapper(<IntlNameChange {...props} />));
render(reduxWrapper(<NameChange {...props} />));
expect(window.location.pathname).toEqual('/id-verification');
});
});

View File

@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { StatefulButton } from '@openedx/paragon';
import { resetPassword } from './data/actions';
@@ -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">
@@ -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);

View 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);
});
});

View File

@@ -0,0 +1,95 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { convertKeyNames, snakeCaseObject } from '@edx/frontend-platform/utils';
import { getSiteLanguageList, patchPreferences, postSetLang } from './service';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/auth');
jest.mock('@edx/frontend-platform/utils');
jest.mock('./constants', () => (['en', 'es', 'fr']));
describe('preferencesApi', () => {
const mockPatch = jest.fn();
const mockPost = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
getConfig.mockReturnValue({
LMS_BASE_URL: 'http://testserver',
});
getAuthenticatedHttpClient.mockReturnValue({
patch: mockPatch,
post: mockPost,
});
snakeCaseObject.mockImplementation(obj => obj);
convertKeyNames.mockImplementation((obj) => obj);
});
describe('getSiteLanguageList', () => {
it('returns the siteLanguageList constant', async () => {
const result = await getSiteLanguageList();
expect(result).toEqual(['en', 'es', 'fr']);
});
});
describe('patchPreferences', () => {
it('patches preferences with processed params and returns the original params', async () => {
const username = 'testuser';
const params = { prefLang: 'en', darkMode: true };
const processed = { 'pref-lang': 'en', dark_mode: true };
// Mock conversions
snakeCaseObject.mockReturnValueOnce({ pref_lang: 'en', dark_mode: true });
convertKeyNames.mockReturnValueOnce(processed);
mockPatch.mockResolvedValueOnce({ data: { success: true } });
const result = await patchPreferences(username, params);
expect(snakeCaseObject).toHaveBeenCalledWith(params);
expect(convertKeyNames).toHaveBeenCalledWith(
{ pref_lang: 'en', dark_mode: true },
{ pref_lang: 'pref-lang' },
);
expect(mockPatch).toHaveBeenCalledWith(
'http://testserver/api/user/v1/preferences/testuser',
processed,
{
headers: { 'Content-Type': 'application/merge-patch+json' },
},
);
expect(result).toEqual(params);
});
});
describe('postSetLang', () => {
it('posts language selection via FormData', async () => {
const mockResponse = { data: { success: true } };
mockPost.mockResolvedValueOnce(mockResponse);
const appendSpy = jest.spyOn(FormData.prototype, 'append');
await postSetLang('fr');
expect(appendSpy).toHaveBeenCalledWith('language', 'fr');
expect(mockPost).toHaveBeenCalledWith(
'http://testserver/i18n/setlang/',
expect.any(FormData),
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
},
);
appendSpy.mockRestore();
});
});
});

View File

@@ -11,11 +11,12 @@ import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import AccountSettingsPage from '../AccountSettingsPage';
import mockData from './mockData';
import messages from '../AccountSettingsPage.messages';
const mockDispatch = jest.fn();
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackingLogEvent: jest.fn(),
getCountryList: jest.fn(),
getCountryList: jest.fn(() => [{ code: 'US', name: 'United States' }]),
}));
jest.mock('react-redux', () => ({
@@ -25,6 +26,19 @@ jest.mock('react-redux', () => ({
jest.mock('@edx/frontend-platform/auth');
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
getConfig: jest.fn(() => ({
SITE_NAME: 'edX',
SUPPORT_URL: 'https://support.edx.org',
ENABLE_ACCOUNT_DELETION: true,
ENABLE_COPPA_COMPLIANCE: false,
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: [],
})),
getCountryList: jest.fn(() => [{ code: 'US', name: 'United States' }]),
getLanguageList: jest.fn(() => [{ code: 'en', name: 'English' }]),
}));
const IntlAccountSettingsPage = injectIntl(AccountSettingsPage);
const middlewares = [thunk];
@@ -63,14 +77,67 @@ describe('AccountSettingsPage', () => {
field_value: '',
},
],
country: 'US',
level_of_education: 'b',
gender: 'm',
language_proficiencies: 'es',
social_link_linkedin: 'https://linkedin.com/in/testuser',
social_link_facebook: '',
social_link_twitter: '',
time_zone: 'America/New_York',
state: 'NY',
secondary_email_enabled: true,
secondary_email: 'test_recovery@test.com',
year_of_birth: '1990',
},
fetchSettings: jest.fn(),
fetchSiteLanguages: jest.fn(),
fetchNotificationPreferences: jest.fn(),
saveSettings: jest.fn(),
updateDraft: jest.fn(),
beginNameChange: jest.fn(),
saveMultipleSettings: jest.fn(),
timeZoneOptions: [
{ label: 'America/New_York', value: 'America/New_York' },
],
countryTimeZoneOptions: [
{ label: 'America/New_York', value: 'America/New_York' },
],
siteLanguageOptions: [
{ label: 'English', value: 'en' },
],
tpaProviders: [
{
id: 'oa2-google-oauth2',
name: 'Google',
connected: false,
accepts_logins: true,
connectUrl: 'http://localhost:18000/auth/login/google-oauth2/',
disconnectUrl: 'http://localhost:18000/auth/disconnect/google-oauth2/',
},
],
isActive: true,
staticFields: [],
profileDataManager: null,
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
countriesCodesList: ['US'],
};
});
afterEach(() => jest.clearAllMocks());
beforeAll(() => {
global.lightningjs = {
require: jest.fn().mockImplementation((module, url) => ({ moduleName: module, url })),
};
});
afterAll(() => {
delete global.lightningjs;
});
it('renders AccountSettingsPage correctly with editing enabled', async () => {
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />));
@@ -98,4 +165,70 @@ describe('AccountSettingsPage', () => {
fireEvent.click(submitButton);
});
it('renders Account Information section with correct field values', () => {
render(reduxWrapper(<AccountSettingsPage {...props} />));
expect(screen.getByText('test_username')).toBeInTheDocument();
expect(screen.getByText('test_name')).toBeInTheDocument();
expect(screen.getByText('test_email@test.com')).toBeInTheDocument();
expect(screen.getByText('test_recovery@test.com')).toBeInTheDocument();
expect(screen.getByText('1990')).toBeInTheDocument();
});
it('renders Profile Information section with correct field values', () => {
render(reduxWrapper(<AccountSettingsPage {...props} />));
expect(screen.getByText('Bachelor\'s Degree')).toBeInTheDocument();
expect(screen.getByText('Male')).toBeInTheDocument();
expect(screen.getByText('Add work experience')).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
});
it('renders Social Media section with correct field values', () => {
render(reduxWrapper(<AccountSettingsPage {...props} />));
expect(screen.getByText('https://linkedin.com/in/testuser')).toBeInTheDocument();
expect(screen.getByText('Add Facebook profile')).toBeInTheDocument();
expect(screen.getByText(messages['account.settings.field.social.platform.name.xTwitter.empty'].defaultMessage)).toBeInTheDocument();
});
it('renders Site Preferences section with correct field values', () => {
render(reduxWrapper(<AccountSettingsPage {...props} />));
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.getByText('America/New_York')).toBeInTheDocument();
});
it('renders Delete Account section when enabled', () => {
// eslint-disable-next-line global-require
const { getConfig } = require('@edx/frontend-platform');
jest.spyOn({ getConfig }, 'getConfig').mockImplementation(() => ({
SITE_NAME: 'edX',
SUPPORT_URL: 'https://support.edx.org',
ENABLE_ACCOUNT_DELETION: true,
ENABLE_COPPA_COMPLIANCE: false,
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: [],
}));
render(reduxWrapper(<AccountSettingsPage {...props} />));
expect(screen.getByText('We\'re sorry to see you go!')).toBeInTheDocument();
});
it('does not render Delete Account section when disabled', () => {
// eslint-disable-next-line global-require
const { getConfig } = require('@edx/frontend-platform');
jest.spyOn({ getConfig }, 'getConfig').mockImplementation(() => ({
SITE_NAME: 'edX',
SUPPORT_URL: 'https://support.edx.org',
ENABLE_ACCOUNT_DELETION: false,
ENABLE_COPPA_COMPLIANCE: false,
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: [],
}));
render(reduxWrapper(<AccountSettingsPage {...props} />));
expect(screen.queryByText('We\'re sorry to see you go!')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,144 @@
import {
render, screen, fireEvent, waitFor,
} from '@testing-library/react';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act } from 'react-dom/test-utils';
import * as reactRedux from 'react-redux';
import DOBModal from '../DOBForm';
import messages from '../AccountSettingsPage.messages';
import { YEAR_OF_BIRTH_OPTIONS } from '../data/constants';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn(),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage: (message) => message.defaultMessage,
}),
}));
jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
Form: {
...jest.requireActual('@openedx/paragon').Form,
Control: {
...jest.requireActual('@openedx/paragon').Form.Control,
// eslint-disable-next-line react/prop-types
Feedback: ({ children, ...props }) => <div {...props}>{children}</div>,
},
},
}));
const mockStore = configureStore([]);
describe('DOBModal', () => {
let store;
let mockDispatch;
beforeEach(() => {
store = mockStore({
accountSettings: {
saveState: 'default',
errors: {},
openFormId: null,
confirmationValues: {},
},
});
mockDispatch = jest.fn();
jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(mockDispatch); // ✅ replaced require with import
// Mock localStorage.setItem
Object.defineProperty(window, 'localStorage', {
value: {
setItem: jest.fn(),
},
writable: true,
});
});
afterEach(() => {
jest.clearAllMocks();
});
const renderComponent = (props = {}) => render(
<Provider store={store}>
<IntlProvider locale="en">
<DOBModal
saveState="default"
error={undefined}
onSubmit={jest.fn()}
{...props}
/>
</IntlProvider>
</Provider>,
);
it('renders the modal with correct elements', async () => {
renderComponent();
const openButton = screen.getByTestId('open-modal-button');
expect(openButton).toHaveTextContent(messages['account.settings.field.dob.form.button'].defaultMessage);
fireEvent.click(openButton);
expect(screen.getByTestId('modal-title')).toHaveTextContent(messages['account.settings.field.dob.form.title'].defaultMessage);
expect(screen.getByTestId('help-text')).toHaveTextContent(messages['account.settings.field.dob.form.help.text'].defaultMessage);
expect(screen.getByTestId('month-label')).toHaveTextContent(messages['account.settings.field.dob.month'].defaultMessage);
expect(screen.getByTestId('year-label')).toHaveTextContent(messages['account.settings.field.dob.year'].defaultMessage);
expect(screen.getByTestId('month-select')).toBeInTheDocument();
expect(screen.getByTestId('year-select')).toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
});
it('enables submit button when both month and year are selected', async () => {
renderComponent();
const openButton = screen.getByTestId('open-modal-button');
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => {
const monthSelect = screen.getByTestId('month-select');
const yearSelect = screen.getByTestId('year-select');
const submitButton = screen.getByTestId('submit-button');
act(() => {
fireEvent.change(monthSelect, { target: { value: '6' } });
fireEvent.change(yearSelect, { target: { value: YEAR_OF_BIRTH_OPTIONS[0].value } });
});
expect(submitButton).not.toHaveAttribute('disabled');
}, { timeout: 2000 });
});
it('calls onSubmit with correct data when form is submitted', async () => {
const mockOnSubmit = jest.fn();
renderComponent({ onSubmit: mockOnSubmit });
const openButton = screen.getByTestId('open-modal-button');
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => {
const monthSelect = screen.getByTestId('month-select');
const yearSelect = screen.getByTestId('year-select');
const form = screen.getByTestId('dob-form');
act(() => {
fireEvent.change(monthSelect, { target: { value: '6' } });
fireEvent.change(yearSelect, { target: { value: '1990' } });
});
act(() => {
fireEvent.submit(form);
});
expect(mockOnSubmit).toHaveBeenCalledWith('extended_profile', [
{ field_name: 'DOB', field_value: '1990-6' },
]);
}, { timeout: 2000 });
});
});

View File

@@ -0,0 +1,184 @@
import React from 'react';
import {
render, screen, fireEvent, waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import EditableField from '../EditableField';
import messages from '../AccountSettingsPage.messages';
jest.mock('../data/selectors', () => ({
editableFieldSelector: () => (state, props) => ({
...state.accountSettings,
isEditing: props.isEditing,
error: props.error || state.accountSettings.errors[props.name],
confirmationValue: props.confirmationValue || state.accountSettings.confirmationValues[props.name],
}),
}));
jest.mock('../data/actions', () => ({
openForm: jest.fn((name) => ({ type: 'OPEN_FORM', payload: name })),
closeForm: jest.fn((name) => ({ type: 'CLOSE_FORM', payload: name })),
}));
// eslint-disable-next-line react/prop-types
jest.mock('../certificate-preference/CertificatePreference', () => function MockCertificatePreference({ fieldName }) {
return <div data-testid="editable-field-certificate-preference">Certificate Preference for {fieldName}</div>;
});
const mockStore = configureStore([]);
const mockOnEdit = jest.fn();
const mockOnCancel = jest.fn();
const mockOnSubmit = jest.fn();
const mockOnChange = jest.fn();
const baseState = {
accountSettings: {
errors: {},
confirmationValues: {},
saveState: 'default',
openFormId: null,
verifiedNameHistory: { results: [] },
values: {},
drafts: {},
timeZones: [],
countryTimeZones: [],
thirdPartyAuth: { providers: [] },
countriesCodesList: [],
profileDataManager: false,
nameChangeModal: {},
loading: false,
loaded: true,
loadingError: null,
},
};
const renderComponent = (props = {}, stateOverrides = {}) => {
const store = mockStore({
...baseState,
...stateOverrides,
});
return render(
<Provider store={store}>
<IntlProvider locale="en">
<EditableField
name="username"
label="Username"
type="text"
value="john_doe"
onEdit={mockOnEdit}
onCancel={mockOnCancel}
onSubmit={mockOnSubmit}
onChange={mockOnChange}
isEditing={false}
{...props}
/>
</IntlProvider>
</Provider>,
);
};
describe('EditableField', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders default state with value', () => {
renderComponent();
expect(screen.getByText('Username')).toBeInTheDocument();
expect(screen.getByText('john_doe')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Edit/i })).toBeInTheDocument();
});
it('renders empty label with edit button if no value and editable', () => {
renderComponent({ value: '', emptyLabel: 'Add value' });
expect(screen.getByRole('button', { name: 'Add value' })).toBeInTheDocument();
});
it('renders empty label as muted text if not editable', () => {
renderComponent({ value: '', emptyLabel: 'No value', isEditable: false });
expect(screen.getByText('No value')).toHaveClass('text-muted');
});
it('renders editing state with form controls', async () => {
renderComponent({ isEditing: true });
await waitFor(() => {
expect(screen.getByTestId('editable-field-textbox')).toHaveValue('john_doe');
expect(screen.getByTestId('editable-field-save')).toBeInTheDocument();
expect(screen.getByTestId('editable-field-cancel')).toBeInTheDocument();
}, { timeout: 2000 });
});
it('calls onChange when input changes', async () => {
renderComponent({ isEditing: true });
await waitFor(() => {
const input = screen.getByTestId('editable-field-textbox');
fireEvent.change(input, { target: { value: 'new_name' } });
expect(mockOnChange).toHaveBeenCalledWith('username', 'new_name');
}, { timeout: 2000 });
});
it('calls onSubmit when form is submitted', async () => {
renderComponent({ isEditing: true });
await waitFor(() => {
const form = screen.getByTestId('editable-field-form');
fireEvent.submit(form);
expect(mockOnSubmit).toHaveBeenCalledWith('username', 'john_doe');
}, { timeout: 2000 });
});
it('shows error message when error is present', async () => {
const stateOverrides = {
accountSettings: {
...baseState.accountSettings,
errors: { username: 'Invalid input' },
},
};
renderComponent({ isEditing: true, error: 'Invalid input' }, stateOverrides);
await waitFor(() => {
expect(screen.getByTestId('editable-field-error')).toHaveTextContent('Invalid input');
}, { timeout: 2000 });
});
it('shows help text in editing mode', () => {
renderComponent({ isEditing: true, helpText: 'Helpful info' });
expect(screen.getByText('Helpful info')).toBeInTheDocument();
});
it('shows confirmation message in default mode if provided', async () => {
const stateOverrides = {
accountSettings: {
...baseState.accountSettings,
confirmationValues: { username: 'done' },
},
};
renderComponent(
{
confirmationMessageDefinition: messages['account.settings.editable.field.action.save'],
confirmationValue: 'done',
},
stateOverrides,
);
await waitFor(() => {
expect(screen.getByTestId('editable-field-confirmation')).toBeInTheDocument();
}, { timeout: 2000 });
});
it('renders CertificatePreference for name fields when editing', async () => {
renderComponent({ isEditing: true, name: 'name' });
await waitFor(() => {
expect(screen.getByTestId('editable-field-certificate-preference')).toHaveTextContent('Certificate Preference for name');
}, { timeout: 2000 });
});
it('applies grayed-out class when isGrayedOut is true', () => {
renderComponent({ isGrayedOut: true });
expect(screen.getByText('john_doe')).toHaveClass('grayed-out');
});
it('appends userSuppliedValue when provided', () => {
renderComponent({ userSuppliedValue: 'extra' });
expect(screen.getByText('john_doe: extra')).toBeInTheDocument();
});
});

View File

@@ -1,10 +1,9 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import EditableSelectField from '../EditableSelectField';
@@ -17,8 +16,6 @@ jest.mock('react-redux', () => ({
jest.mock('@edx/frontend-platform/auth');
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
const IntlEditableSelectField = injectIntl(EditableSelectField);
const mockStore = configureStore();
describe('EditableSelectField', () => {
@@ -88,7 +85,7 @@ describe('EditableSelectField', () => {
afterEach(() => jest.clearAllMocks());
it('renders EditableSelectField correctly with editing disabled', () => {
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...props} />)).toJSON();
const tree = renderer.create(reduxWrapper(<EditableSelectField {...props} />)).toJSON();
expect(tree).toMatchSnapshot();
});
@@ -98,7 +95,7 @@ describe('EditableSelectField', () => {
isEditing: true,
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...props} />)).toJSON();
const tree = renderer.create(reduxWrapper(<EditableSelectField {...props} />)).toJSON();
expect(tree).toMatchSnapshot();
});
@@ -107,7 +104,7 @@ describe('EditableSelectField', () => {
...props,
error: 'This is an error message',
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...errorProps} />)).toJSON();
const tree = renderer.create(reduxWrapper(<EditableSelectField {...errorProps} />)).toJSON();
expect(tree).toMatchSnapshot();
});
@@ -126,7 +123,7 @@ describe('EditableSelectField', () => {
},
],
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithGroup} />)).toJSON();
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithGroup} />)).toJSON();
expect(tree).toMatchSnapshot();
});
@@ -140,7 +137,7 @@ describe('EditableSelectField', () => {
},
],
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithoutGroup} />)).toJSON();
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithoutGroup} />)).toJSON();
expect(tree).toMatchSnapshot();
});
@@ -163,7 +160,7 @@ describe('EditableSelectField', () => {
},
],
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithGroups} />)).toJSON();
const tree = renderer.create(reduxWrapper(<EditableSelectField {...propsWithGroups} />)).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -1,20 +1,16 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp, mergeConfig, setConfig } from '@edx/frontend-platform';
import JumpNav from '../JumpNav';
import configureStore from '../../data/configureStore';
const IntlJumpNav = injectIntl(JumpNav);
describe('JumpNav', () => {
mergeConfig({
ENABLE_ACCOUNT_DELETION: true,
});
let props = {};
let store;
beforeEach(() => {
@@ -27,9 +23,6 @@ describe('JumpNav', () => {
},
});
props = {
intl: {},
};
store = configureStore({
notificationPreferences: {
showPreferences: false,
@@ -37,41 +30,35 @@ describe('JumpNav', () => {
});
});
it('should not render Optional Information or delete account link', () => {
it('should not render delete account link', async () => {
setConfig({
ENABLE_ACCOUNT_DELETION: false,
});
const tree = renderer.create((
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<IntlJumpNav {...props} />
<JumpNav />
</AppProvider>
</IntlProvider>
))
.toJSON();
</IntlProvider>,
);
expect(tree).toMatchSnapshot();
expect(await screen.queryByText('Delete My Account')).toBeNull();
});
it('should render Optional Information and delete account link', () => {
it('should render delete account link', async () => {
setConfig({
ENABLE_ACCOUNT_DELETION: true,
});
props = {
...props,
};
const tree = renderer.create((
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<IntlJumpNav {...props} />
<JumpNav />
</AppProvider>
</IntlProvider>
))
.toJSON();
</IntlProvider>,
);
expect(tree).toMatchSnapshot();
expect(await screen.findByText('Delete My Account')).toBeVisible();
});
});

View File

@@ -172,9 +172,7 @@ exports[`EditableSelectField renders EditableSelectField correctly with editing
/>
</svg>
</span>
<div>
</div>
<div />
</div>
</div>
<p>

View File

@@ -1,184 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JumpNav should not render Optional Information or delete account link 1`] = `
<div
data-testid="redux-provider"
>
<div
data-testid="browser-router"
>
<div
className="jump-nav px-2.25 jump-nav-sm position-sticky pt-3"
>
<ul
className="list-unstyled"
style={{}}
>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#basic-information"
isActive={[Function]}
onClick={[Function]}
>
Account Information
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#profile-information"
isActive={[Function]}
onClick={[Function]}
>
Profile Information
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#social-media"
isActive={[Function]}
onClick={[Function]}
>
Social Media Links
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#site-preferences"
isActive={[Function]}
onClick={[Function]}
>
Site Preferences
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#linked-accounts"
isActive={[Function]}
onClick={[Function]}
>
Linked Accounts
</a>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`JumpNav should render Optional Information and delete account link 1`] = `
<div
data-testid="redux-provider"
>
<div
data-testid="browser-router"
>
<div
className="jump-nav px-2.25 jump-nav-sm position-sticky pt-3"
>
<ul
className="list-unstyled"
style={{}}
>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#basic-information"
isActive={[Function]}
onClick={[Function]}
>
Account Information
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#profile-information"
isActive={[Function]}
onClick={[Function]}
>
Profile Information
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#social-media"
isActive={[Function]}
onClick={[Function]}
>
Social Media Links
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#site-preferences"
isActive={[Function]}
onClick={[Function]}
>
Site Preferences
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#linked-accounts"
isActive={[Function]}
onClick={[Function]}
>
Linked Accounts
</a>
</li>
<li
className=""
>
<a
aria-current="page"
className="active"
href="/#delete-account"
isActive={[Function]}
onClick={[Function]}
>
Delete My Account
</a>
</li>
</ul>
</div>
</div>
</div>
`;

View File

@@ -6,7 +6,7 @@ const mockData = {
data: null,
values: {
username: 'test_username',
country: 'AD',
country: 'US',
accomplishments_shared: false,
name: 'test_name',
email: 'test_email@test.com',
@@ -18,8 +18,18 @@ const mockData = {
field_value: '',
},
],
gender: null,
gender: 'm',
'pref-lang': 'en',
level_of_education: 'b',
language_proficiencies: 'es',
social_link_linkedin: 'https://linkedin.com/in/testuser',
social_link_facebook: '',
social_link_twitter: '',
time_zone: 'America/New_York',
state: 'NY',
secondary_email_enabled: true,
secondary_email: 'test_recovery@test.com',
year_of_birth: '1990',
},
errors: {},
confirmationValues: {},
@@ -27,14 +37,14 @@ const mockData = {
saveState: null,
timeZones: [
{
time_zone: 'Africa/Abidjan',
description: 'Africa/Abidjan (GMT, UTC+0000)',
time_zone: 'America/New_York',
description: 'America/New_York (EST, UTC-0500)',
},
],
countryTimeZones: [
{
time_zone: 'Europe/Andorra',
description: 'Europe/Andorra (CET, UTC+0100)',
time_zone: 'America/New_York',
description: 'America/New_York (EST, UTC-0500)',
},
],
previousSiteLanguage: null,
@@ -84,7 +94,7 @@ const mockData = {
profileDataManager: null,
},
notificationPreferences: {
showPreferences: false,
showPreferences: true,
courses: {
status: 'success',
courses: [],
@@ -98,7 +108,7 @@ const mockData = {
preferences: {
status: 'idle',
updatePreferenceStatus: 'idle',
selectedCourse: null,
selectedCourse: 'account',
preferences: [],
apps: [],
nonEditable: {},

16
src/divider/Divider.jsx Normal file
View File

@@ -0,0 +1,16 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
const Divider = ({ className, ...props }) => (
<div className={classNames('divider', className)} {...props} />
);
Divider.propTypes = {
className: PropTypes.string,
};
Divider.defaultProps = {
className: undefined,
};
export default Divider;

2
src/divider/index.jsx Normal file
View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as Divider } from './Divider';

View File

@@ -1,21 +1,19 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const Head = ({ intl }) => (
<Helmet>
<title>
{intl.formatMessage(messages['account.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
Head.propTypes = {
intl: intlShape.isRequired,
const Head = () => {
const intl = useIntl();
return (
<Helmet>
<title>
{intl.formatMessage(messages['account.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
};
export default injectIntl(Head);
export default Head;

View File

@@ -6,9 +6,8 @@ import { getConfig } from '@edx/frontend-platform';
import Head from './Head';
describe('Head', () => {
const props = {};
it('should match render title tag and fivicon with the site configuration values', () => {
render(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
render(<IntlProvider locale="en"><Head /></IntlProvider>);
const helmet = Helmet.peek();
expect(helmet.title).toEqual(`Account | ${getConfig().SITE_NAME}`);
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');

View File

@@ -71,5 +71,5 @@ export function useFeedbackWrapper() {
export function useIsOnMobile() {
const windowSize = useWindowSize();
return windowSize.width <= breakpoints.small.minWidth;
return windowSize.width <= breakpoints.small.maxWidth;
}

View File

@@ -1,12 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import messages from './IdVerification.messages';
import { ERROR_REASONS } from './IdVerificationContext';
const AccessBlocked = ({ error, intl }) => {
const AccessBlocked = ({ error }) => {
const intl = useIntl();
const handleMessage = () => {
if (error === ERROR_REASONS.COURSE_ENROLLMENT) {
return <p>{intl.formatMessage(messages['id.verification.access.blocked.enrollment'])}</p>;
@@ -42,8 +43,7 @@ const AccessBlocked = ({ error, intl }) => {
};
AccessBlocked.propTypes = {
intl: intlShape.isRequired,
error: PropTypes.string.isRequired,
};
export default injectIntl(AccessBlocked);
export default AccessBlocked;

View File

@@ -1,41 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Collapsible } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './IdVerification.messages';
const CameraHelp = (props) => (
<div>
<Collapsible
styling="card"
title={props.intl.formatMessage(messages['id.verification.camera.help.sight.question'])}
className="mb-4 shadow"
defaultOpen={props.isOpen}
>
<p>
{props.intl.formatMessage(messages[`id.verification.camera.help.sight.answer.${props.isPortrait ? 'portrait' : 'id'}`])}
</p>
</Collapsible>
<Collapsible
styling="card"
title={props.intl.formatMessage(messages[`id.verification.camera.help.difficulty.question.${props.isPortrait ? 'portrait' : 'id'}`])}
className="mb-4 shadow"
defaultOpen={props.isOpen}
>
<p>
{props.intl.formatMessage(
messages['id.verification.camera.help.difficulty.answer'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
</Collapsible>
</div>
);
const CameraHelp = (props) => {
const intl = useIntl();
return (
<div>
<Collapsible
styling="card"
title={intl.formatMessage(messages['id.verification.camera.help.sight.question'])}
className="mb-4 shadow"
defaultOpen={props.isOpen}
>
<p>
{intl.formatMessage(messages[`id.verification.camera.help.sight.answer.${props.isPortrait ? 'portrait' : 'id'}`])}
</p>
</Collapsible>
<Collapsible
styling="card"
title={intl.formatMessage(messages[`id.verification.camera.help.difficulty.question.${props.isPortrait ? 'portrait' : 'id'}`])}
className="mb-4 shadow"
defaultOpen={props.isOpen}
>
<p>
{intl.formatMessage(
messages['id.verification.camera.help.difficulty.answer'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
</Collapsible>
</div>
);
};
CameraHelp.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool,
isPortrait: PropTypes.bool,
};
@@ -45,4 +48,4 @@ CameraHelp.defaultProps = {
isPortrait: false,
};
export default injectIntl(CameraHelp);
export default CameraHelp;

View File

@@ -1,7 +1,7 @@
import React, { useState, useContext } from 'react';
import { useState, useContext } from 'react';
import PropTypes from 'prop-types';
import { Collapsible } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import messages from './IdVerification.messages';
@@ -11,6 +11,7 @@ import ImagePreview from './ImagePreview';
import SupportedMediaTypes from './SupportedMediaTypes';
const CameraHelpWithUpload = (props) => {
const intl = useIntl();
const { setIdPhotoFile, idPhotoFile, userId } = useContext(IdVerificationContext);
const [hasUploadedImage, setHasUploadedImage] = useState(false);
@@ -27,24 +28,23 @@ const CameraHelpWithUpload = (props) => {
<div>
<Collapsible
styling="card"
title={props.intl.formatMessage(messages['id.verification.id.photo.unclear.question'])}
title={intl.formatMessage(messages['id.verification.id.photo.unclear.question'])}
data-testid="collapsible"
className="mb-4 shadow"
defaultOpen={props.isOpen}
>
{idPhotoFile && hasUploadedImage && <ImagePreview src={idPhotoFile} alt={props.intl.formatMessage(messages['id.verification.id.photo.preview.alt'])} />}
{idPhotoFile && hasUploadedImage && <ImagePreview src={idPhotoFile} alt={intl.formatMessage(messages['id.verification.id.photo.preview.alt'])} />}
<p>
{props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
{intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
<SupportedMediaTypes />
</p>
<ImageFileUpload onFileChange={setAndTrackIdPhotoFile} intl={props.intl} />
<ImageFileUpload onFileChange={setAndTrackIdPhotoFile} />
</Collapsible>
</div>
);
};
CameraHelpWithUpload.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool,
};
@@ -52,4 +52,4 @@ CameraHelpWithUpload.defaultProps = {
isOpen: false,
};
export default injectIntl(CameraHelpWithUpload);
export default CameraHelpWithUpload;

View File

@@ -1,12 +1,13 @@
import React, { useContext } from 'react';
import { useContext } from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Collapsible } from '@openedx/paragon';
import IdVerificationContext from './IdVerificationContext';
import messages from './IdVerification.messages';
const CollapsibleImageHelp = (props) => {
const CollapsibleImageHelp = () => {
const intl = useIntl();
const {
userId, useCameraForId, setUseCameraForId,
} = useContext(IdVerificationContext);
@@ -25,15 +26,15 @@ const CollapsibleImageHelp = (props) => {
<Collapsible
styling="card"
title={useCameraForId
? props.intl.formatMessage(messages['id.verification.photo.upload.help.title'])
: props.intl.formatMessage(messages['id.verification.photo.camera.help.title'])}
? intl.formatMessage(messages['id.verification.photo.upload.help.title'])
: intl.formatMessage(messages['id.verification.photo.camera.help.title'])}
className="mb-4 shadow"
defaultOpen
>
<p data-testid="help-text">
{useCameraForId
? props.intl.formatMessage(messages['id.verification.photo.upload.help.text'])
: props.intl.formatMessage(messages['id.verification.photo.camera.help.text'])}
? intl.formatMessage(messages['id.verification.photo.upload.help.text'])
: intl.formatMessage(messages['id.verification.photo.camera.help.text'])}
</p>
<Button
title={useCameraForId ? 'Upload Photo' : 'Take Photo'} // TO-DO: translation
@@ -42,15 +43,11 @@ const CollapsibleImageHelp = (props) => {
style={{ marginTop: '0.5rem' }}
>
{useCameraForId
? props.intl.formatMessage(messages['id.verification.photo.upload.help.button'])
: props.intl.formatMessage(messages['id.verification.photo.camera.help.button'])}
? intl.formatMessage(messages['id.verification.photo.upload.help.button'])
: intl.formatMessage(messages['id.verification.photo.camera.help.button'])}
</Button>
</Collapsible>
);
};
CollapsibleImageHelp.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CollapsibleImageHelp);
export default CollapsibleImageHelp;

View File

@@ -46,6 +46,11 @@ const messages = defineMessages({
defaultMessage: 'You need a valid identification card that contains your full name and photo, such as a drivers license or passport.',
description: 'Text that explains that the user needs a photo ID.',
},
'id.verification.privacy.modal.close.button': {
id: 'id.verification.privacy.modal.close.button',
defaultMessage: 'Close',
description: 'Label on button to close privacy information dialog.',
},
'id.verification.privacy.title': {
id: 'id.verification.privacy.title',
defaultMessage: 'Privacy Information',
@@ -656,6 +661,11 @@ const messages = defineMessages({
defaultMessage: 'Switch to Camera Mode',
description: 'Button used to switch to camera mode.',
},
'id.verification.context.loading.state': {
id: 'id.verification.context.loading.state',
defaultMessage: 'Loading verification status',
description: 'Message shown for screen readers when a user\'s identification verification is in the loading state',
},
});
export default messages;

View File

@@ -3,6 +3,7 @@ import React, {
} from 'react';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getProfileDataManager } from '../account-settings/data/service';
import PageLoading from '../account-settings/PageLoading';
@@ -15,7 +16,10 @@ import { hasGetUserMediaSupport } from './getUserMediaShim';
import IdVerificationContext, { MEDIA_ACCESS, ERROR_REASONS, VERIFIED_MODES } from './IdVerificationContext';
import { VerifiedNameContext } from './VerifiedNameContext';
import messages from './IdVerification.messages';
const IdVerificationContextProvider = ({ children }) => {
const intl = useIntl();
const { authenticatedUser } = useContext(AppContext);
const { verifiedNameHistoryCallStatus, verifiedName } = useContext(VerifiedNameContext);
@@ -117,7 +121,7 @@ const IdVerificationContextProvider = ({ children }) => {
const loadingStatuses = [IDLE_STATUS, LOADING_STATUS];
// If we are waiting for verification status or verified name history endpoint, show spinner.
if (loadingStatuses.includes(idVerificationData.status) || loadingStatuses.includes(verifiedNameHistoryCallStatus)) {
return <PageLoading srMessage="Loading verification status" />;
return <PageLoading srMessage={intl.formatMessage(messages['id.verification.context.loading.state'])} />;
}
if (!canVerify) {

View File

@@ -1,11 +1,11 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import {
Route, Routes, useLocation, useNavigate,
} from 'react-router-dom';
import camelCase from 'lodash.camelcase';
import qs from 'qs';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, ModalDialog, ActionRow } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { idVerificationSelector } from './data/selectors';
@@ -26,9 +26,10 @@ import SubmittedPanel from './panels/SubmittedPanel';
import messages from './IdVerification.messages';
// eslint-disable-next-line react/prefer-stateless-function
const IdVerificationPage = (props) => {
const IdVerificationPage = () => {
const { search } = useLocation();
const navigate = useNavigate();
const intl = useIntl();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -78,33 +79,33 @@ const IdVerificationPage = (props) => {
</div>
<ModalDialog
isOpen={isModalOpen}
title="Id modal"
title={intl.formatMessage(messages['id.verification.privacy.title'])}
onClose={() => setIsModalOpen(false)}
size="lg"
hasCloseButton={false}
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="Id-modal">
{props.intl.formatMessage(messages['id.verification.privacy.title'])}
{intl.formatMessage(messages['id.verification.privacy.title'])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<div className="p-3">
<h6>
{props.intl.formatMessage(
{intl.formatMessage(
messages['id.verification.privacy.need.photo.question'],
{ siteName: getConfig().SITE_NAME },
)}
</h6>
<p>{props.intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}</p>
<p>{intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}</p>
<h6>
{props.intl.formatMessage(
{intl.formatMessage(
messages['id.verification.privacy.do.with.photo.question'],
{ siteName: getConfig().SITE_NAME },
)}
</h6>
<p>
{props.intl.formatMessage(
{intl.formatMessage(
messages['id.verification.privacy.do.with.photo.answer'],
{ siteName: getConfig().SITE_NAME },
)}
@@ -114,7 +115,7 @@ const IdVerificationPage = (props) => {
<ModalDialog.Footer className="p-2">
<ActionRow>
<ModalDialog.CloseButton variant="link">
Close
{intl.formatMessage(messages['id.verification.privacy.modal.close.button'])}
</ModalDialog.CloseButton>
</ActionRow>
</ModalDialog.Footer>
@@ -124,8 +125,4 @@ const IdVerificationPage = (props) => {
);
};
IdVerificationPage.propTypes = {
intl: intlShape.isRequired,
};
export default connect(idVerificationSelector, {})(injectIntl(IdVerificationPage));
export default connect(idVerificationSelector, {})(IdVerificationPage);

View File

@@ -1,11 +1,12 @@
import React, { useCallback, useState } from 'react';
import { intlShape } from '@edx/frontend-platform/i18n';
import { useCallback, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import messages from './IdVerification.messages';
import SupportedMediaTypes from './SupportedMediaTypes';
const ImageFileUpload = ({ onFileChange, intl }) => {
const ImageFileUpload = ({ onFileChange }) => {
const intl = useIntl();
const [error, setError] = useState(null);
const errorTypes = {
invalidFileType: 'invalidFileType',
@@ -58,7 +59,6 @@ const ImageFileUpload = ({ onFileChange, intl }) => {
ImageFileUpload.propTypes = {
onFileChange: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default ImageFileUpload;

View File

@@ -0,0 +1,147 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import qs from 'qs';
import { getExistingIdVerification, getEnrollments, submitIdVerification } from './service';
jest.mock('@edx/frontend-platform', () => {
const actual = jest.requireActual('@edx/frontend-platform');
return {
...actual,
getConfig: jest.fn(),
};
});
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
jest.mock('qs');
describe('ID Verification Service', () => {
let mockHttpClient;
beforeEach(() => {
jest.resetAllMocks();
getConfig.mockReturnValue({ LMS_BASE_URL: 'http://test.lms' });
mockHttpClient = {
get: jest.fn(),
post: jest.fn(),
};
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
});
describe('getExistingIdVerification', () => {
it('returns transformed data on success', async () => {
const mockResponse = {
data: { status: 'approved', expires: '2025-12-01', can_verify: true },
};
mockHttpClient.get.mockResolvedValue(mockResponse);
const result = await getExistingIdVerification();
expect(mockHttpClient.get).toHaveBeenCalledWith(
'http://test.lms/verify_student/status/',
{ headers: { Accept: 'application/json' } },
);
expect(result).toEqual({
status: 'approved',
expires: '2025-12-01',
canVerify: true,
});
});
it('returns defaults on failure', async () => {
mockHttpClient.get.mockRejectedValue(new Error('Network error'));
const result = await getExistingIdVerification();
expect(result).toEqual({
status: null,
expires: null,
canVerify: false,
});
});
});
describe('getEnrollments', () => {
it('returns data on success', async () => {
const mockResponse = { data: [{ course_id: 'course-v1:test+T101+2025', mode: 'verified' }] };
mockHttpClient.get.mockResolvedValue(mockResponse);
const result = await getEnrollments();
expect(mockHttpClient.get).toHaveBeenCalledWith(
'http://test.lms/api/enrollment/v1/enrollment',
{ headers: { Accept: 'application/json' } },
);
expect(result).toEqual(mockResponse.data);
});
it('returns empty object on failure', async () => {
mockHttpClient.get.mockRejectedValue(new Error('Server error'));
const result = await getEnrollments();
expect(result).toEqual({});
});
});
describe('submitIdVerification', () => {
it('posts transformed data and returns success', async () => {
const verificationData = {
facePhotoFile: 'face-img',
idPhotoFile: 'id-img',
idPhotoName: 'John Doe',
courseRunKey: 'course-v1:test+T101+2025',
};
const expectedPostData = {
face_image: 'face-img',
photo_id_image: 'id-img',
full_name: 'John Doe',
};
qs.stringify.mockReturnValue('encoded-data');
mockHttpClient.post.mockResolvedValue({});
const result = await submitIdVerification(verificationData);
expect(qs.stringify).toHaveBeenCalledWith(expectedPostData);
expect(mockHttpClient.post).toHaveBeenCalledWith(
'http://test.lms/verify_student/submit-photos/',
'encoded-data',
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
expect(result).toEqual({ success: true, message: null });
});
it('omits null/undefined values', async () => {
const verificationData = {
facePhotoFile: 'face-img',
idPhotoFile: null,
idPhotoName: undefined,
};
qs.stringify.mockReturnValue('encoded-data');
mockHttpClient.post.mockResolvedValue({});
await submitIdVerification(verificationData);
expect(qs.stringify).toHaveBeenCalledWith({ face_image: 'face-img' });
});
it('returns failure object on error', async () => {
const error = new Error('Failed');
error.customAttributes = { httpErrorStatus: 400 };
mockHttpClient.post.mockRejectedValue(error);
const result = await submitIdVerification({ facePhotoFile: 'face-img' });
expect(result).toEqual({
success: false,
status: 400,
message: expect.stringContaining('Failed'),
});
});
});
});

View File

@@ -1,17 +1,17 @@
import PropTypes from 'prop-types';
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../IdVerification.messages';
export const EnableCameraDirectionsPanel = (props) => {
const intl = useIntl();
if (props.browserName === 'Internet Explorer') {
return (
<>
<h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11'])}</h6>
<h6>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11'])}</h6>
<ol>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step1'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step2'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step3'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step1'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step2'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.ie11.step3'])}</li>
</ol>
</>
);
@@ -19,17 +19,17 @@ export const EnableCameraDirectionsPanel = (props) => {
if (props.browserName === 'Chrome') {
return (
<>
<h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome'])}</h6>
<h6>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome'])}</h6>
<ol>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step1'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step1'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2'])}</li>
<ul>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.windows'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.mac'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.windows'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step2.mac'])}</li>
</ul>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step3'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step4'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step5'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step3'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step4'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.chrome.step5'])}</li>
</ol>
</>
);
@@ -37,15 +37,15 @@ export const EnableCameraDirectionsPanel = (props) => {
if (props.browserName === 'Firefox') {
return (
<>
<h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox'])}</h6>
<h6>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox'])}</h6>
<ol>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step1'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step2'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step3'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step4'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step5'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step6'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step7'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step1'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step2'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step3'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step4'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step5'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step6'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.firefox.step7'])}</li>
</ol>
</>
);
@@ -53,12 +53,12 @@ export const EnableCameraDirectionsPanel = (props) => {
if (props.browserName === 'Safari') {
return (
<>
<h6>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari'])}</h6>
<h6>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari'])}</h6>
<ol>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step1'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step2'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step3'])}</li>
<li>{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step4'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step1'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step2'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step3'])}</li>
<li>{intl.formatMessage(messages['id.verification.camera.access.failure.temporary.safari.step4'])}</li>
</ol>
</>
);
@@ -68,8 +68,7 @@ export const EnableCameraDirectionsPanel = (props) => {
};
EnableCameraDirectionsPanel.propTypes = {
intl: intlShape.isRequired,
browserName: PropTypes.string.isRequired,
};
export default injectIntl(EnableCameraDirectionsPanel);
export default EnableCameraDirectionsPanel;

View File

@@ -1,9 +1,9 @@
import React, {
import {
useContext, useEffect, useRef,
} from 'react';
import { Form } from '@openedx/paragon';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
@@ -11,12 +11,13 @@ import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages';
const GetNameIdPanel = (props) => {
const GetNameIdPanel = () => {
const location = useLocation();
const navigate = useNavigate();
const nameInputRef = useRef();
const panelSlug = 'get-name-id';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const intl = useIntl();
const { nameOnAccount, idPhotoName, setIdPhotoName } = useContext(IdVerificationContext);
const nameOnAccountValue = nameOnAccount || '';
@@ -41,19 +42,19 @@ const GetNameIdPanel = (props) => {
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.name.check.title'])}
title={intl.formatMessage(messages['id.verification.name.check.title'])}
>
<p>
{props.intl.formatMessage(messages['id.verification.name.check.instructions'])}
{intl.formatMessage(messages['id.verification.name.check.instructions'])}
</p>
<p>
{props.intl.formatMessage(messages['id.verification.name.check.mismatch.information'])}
{intl.formatMessage(messages['id.verification.name.check.mismatch.information'])}
</p>
<Form onSubmit={handleSubmit}>
<Form.Group>
<Form.Label className="font-weight-bold" htmlFor="photo-id-name">
{props.intl.formatMessage(messages['id.verification.name.label'])}
{intl.formatMessage(messages['id.verification.name.label'])}
</Form.Label>
<Form.Control
controlId="photo-id-name"
@@ -72,7 +73,7 @@ const GetNameIdPanel = (props) => {
data-testid="id-name-feedback-message"
type="invalid"
>
{props.intl.formatMessage(messages['id.verification.name.error'])}
{intl.formatMessage(messages['id.verification.name.error'])}
</Form.Control.Feedback>
)}
</Form.Group>
@@ -85,15 +86,11 @@ const GetNameIdPanel = (props) => {
data-testid="next-button"
aria-disabled={!idPhotoName}
>
{props.intl.formatMessage(messages['id.verification.next'])}
{intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
</BasePanel>
);
};
GetNameIdPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GetNameIdPanel);
export default GetNameIdPanel;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
@@ -8,49 +8,46 @@ import CameraHelp from '../CameraHelp';
import messages from '../IdVerification.messages';
import exampleCard from '../assets/example-card.png';
const IdContextPanel = (props) => {
const IdContextPanel = () => {
const panelSlug = 'id-context';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const intl = useIntl();
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.id.tips.title'])}
title={intl.formatMessage(messages['id.verification.id.tips.title'])}
>
<p>{props.intl.formatMessage(messages['id.verification.id.tips.description'])}</p>
<p>{intl.formatMessage(messages['id.verification.id.tips.description'])}</p>
<div className="card mb-4 shadow accent border-warning">
<div className="card-body">
<h6>
{props.intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
{intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
</h6>
<p>
{props.intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
{intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
</p>
<ul>
<li>
{props.intl.formatMessage(messages['id.verification.id.tips.list.well.lit'])}
{intl.formatMessage(messages['id.verification.id.tips.list.well.lit'])}
</li>
<li>
{props.intl.formatMessage(messages['id.verification.id.tips.list.clear'])}
{intl.formatMessage(messages['id.verification.id.tips.list.clear'])}
</li>
</ul>
<img
src={exampleCard}
alt={props.intl.formatMessage(messages['id.verification.example.card.alt'])}
alt={intl.formatMessage(messages['id.verification.example.card.alt'])}
/>
</div>
</div>
<CameraHelp isOpen />
<div className="action-row">
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
{intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
</BasePanel>
);
};
IdContextPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(IdContextPanel);
export default IdContextPanel;

View File

@@ -1,37 +1,37 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
import CameraHelp from '../CameraHelp';
import messages from '../IdVerification.messages';
const PortraitPhotoContextPanel = (props) => {
const PortraitPhotoContextPanel = () => {
const intl = useIntl();
const panelSlug = 'portrait-photo-context';
const nextPanelSlug = useNextPanelSlug(panelSlug);
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.photo.tips.title'])}
title={intl.formatMessage(messages['id.verification.photo.tips.title'])}
>
<p>
{props.intl.formatMessage(messages['id.verification.photo.tips.description'])}
{intl.formatMessage(messages['id.verification.photo.tips.description'])}
</p>
<div className="card mb-4 shadow accent border-warning">
<div className="card-body">
<h6>
{props.intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
{intl.formatMessage(messages['id.verification.photo.tips.list.title'])}
</h6>
<p>
{props.intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
{intl.formatMessage(messages['id.verification.photo.tips.list.description'])}
</p>
<ul className="mb-0">
<li>
{props.intl.formatMessage(messages['id.verification.photo.tips.list.well.lit'])}
{intl.formatMessage(messages['id.verification.photo.tips.list.well.lit'])}
</li>
<li>
{props.intl.formatMessage(messages['id.verification.photo.tips.list.inside.frame'])}
{intl.formatMessage(messages['id.verification.photo.tips.list.inside.frame'])}
</li>
</ul>
</div>
@@ -39,15 +39,11 @@ const PortraitPhotoContextPanel = (props) => {
<CameraHelp isOpen isPortrait />
<div className="action-row">
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
{intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
</BasePanel>
);
};
PortraitPhotoContextPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(PortraitPhotoContextPanel);
export default PortraitPhotoContextPanel;

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useContext } from 'react';
import { useEffect, useContext } from 'react';
import { Link } from 'react-router-dom';
import Bowser from 'bowser';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useRedirect } from '../../hooks';
import { useNextPanelSlug } from '../routing-utilities';
@@ -14,7 +14,8 @@ import { UnsupportedCameraDirectionsPanel } from './UnsupportedCameraDirectionsP
import messages from '../IdVerification.messages';
const RequestCameraAccessPanel = (props) => {
const RequestCameraAccessPanel = () => {
const intl = useIntl();
const { location: returnUrl, text: returnText } = useRedirect();
const panelSlug = 'request-camera-access';
const nextPanelSlug = useNextPanelSlug(panelSlug);
@@ -40,17 +41,17 @@ const RequestCameraAccessPanel = (props) => {
const getTitle = () => {
if (mediaAccess === MEDIA_ACCESS.GRANTED) {
return props.intl.formatMessage(messages['id.verification.camera.access.title.success']);
return intl.formatMessage(messages['id.verification.camera.access.title.success']);
}
if ([MEDIA_ACCESS.UNSUPPORTED, MEDIA_ACCESS.DENIED].includes(mediaAccess)) {
return props.intl.formatMessage(messages['id.verification.camera.access.title.failed']);
return intl.formatMessage(messages['id.verification.camera.access.title.failed']);
}
return props.intl.formatMessage(messages['id.verification.camera.access.title']);
return intl.formatMessage(messages['id.verification.camera.access.title']);
};
const returnLink = (
<a className="btn btn-primary" href={`${getConfig().LMS_BASE_URL}/${returnUrl}`}>
{props.intl.formatMessage(messages[returnText])}
{intl.formatMessage(messages[returnText])}
</a>
);
@@ -67,13 +68,13 @@ const RequestCameraAccessPanel = (props) => {
defaultMessage="In order to take a photo using your webcam, you may receive a browser prompt for access to your camera. {clickAllow}"
description="Instructions to enable camera access."
values={{
clickAllow: <strong>{props.intl.formatMessage(messages['id.verification.camera.access.click.allow'])}</strong>,
clickAllow: <strong>{intl.formatMessage(messages['id.verification.camera.access.click.allow'])}</strong>,
}}
/>
</p>
<div className="action-row">
<button type="button" className="btn btn-primary" onClick={tryGetUserMedia}>
{props.intl.formatMessage(messages['id.verification.camera.access.enable'])}
{intl.formatMessage(messages['id.verification.camera.access.enable'])}
</button>
</div>
</div>
@@ -82,11 +83,11 @@ const RequestCameraAccessPanel = (props) => {
{mediaAccess === MEDIA_ACCESS.GRANTED && (
<div>
<p data-testid="camera-access-success">
{props.intl.formatMessage(messages['id.verification.camera.access.success'])}
{intl.formatMessage(messages['id.verification.camera.access.success'])}
</p>
<div className="action-row">
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
{intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
</div>
@@ -95,9 +96,9 @@ const RequestCameraAccessPanel = (props) => {
{mediaAccess === MEDIA_ACCESS.DENIED && (
<div data-testid="camera-failure-instructions">
<p data-testid="camera-access-failure">
{props.intl.formatMessage(messages['id.verification.camera.access.failure.temporary'])}
{intl.formatMessage(messages['id.verification.camera.access.failure.temporary'])}
</p>
<EnableCameraDirectionsPanel browserName={browserName} intl={props.intl} />
<EnableCameraDirectionsPanel browserName={browserName} />
<div className="action-row">
{returnLink}
</div>
@@ -107,9 +108,9 @@ const RequestCameraAccessPanel = (props) => {
{mediaAccess === MEDIA_ACCESS.UNSUPPORTED && (
<div data-testid="camera-unsupported-instructions">
<p data-testid="camera-unsupported-failure">
{props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported'])}
{intl.formatMessage(messages['id.verification.camera.access.failure.unsupported'])}
</p>
<UnsupportedCameraDirectionsPanel browserName={browserName} intl={props.intl} />
<UnsupportedCameraDirectionsPanel browserName={browserName} />
<div className="action-row">
{returnLink}
</div>
@@ -120,8 +121,4 @@ const RequestCameraAccessPanel = (props) => {
);
};
RequestCameraAccessPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(RequestCameraAccessPanel);
export default RequestCameraAccessPanel;

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useContext } from 'react';
import { useEffect, useContext } from 'react';
import { Link } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@openedx/paragon';
import { useNextPanelSlug } from '../routing-utilities';
@@ -12,7 +12,8 @@ import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages';
import exampleCard from '../assets/example-card.png';
const ReviewRequirementsPanel = (props) => {
const ReviewRequirementsPanel = () => {
const intl = useIntl();
const { userId, profileDataManager } = useContext(IdVerificationContext);
const panelSlug = 'review-requirements';
const nextPanelSlug = useNextPanelSlug(panelSlug);
@@ -41,7 +42,7 @@ const ReviewRequirementsPanel = (props) => {
profileDataManager,
support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
{props.intl.formatMessage(messages['id.verification.support'])}
{intl.formatMessage(messages['id.verification.support'])}
</Hyperlink>
),
}}
@@ -54,17 +55,17 @@ const ReviewRequirementsPanel = (props) => {
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.requirements.title'])}
title={intl.formatMessage(messages['id.verification.requirements.title'])}
focusOnMount={false}
>
{renderManagedProfileMessage()}
<p>
{props.intl.formatMessage(messages['id.verification.requirements.description'])}
{intl.formatMessage(messages['id.verification.requirements.description'])}
</p>
<div className="card mb-4 shadow accent border-warning">
<div className="card-body">
<h6 aria-level="3">
{props.intl.formatMessage(messages['id.verification.requirements.card.device.title'])}
{intl.formatMessage(messages['id.verification.requirements.card.device.title'])}
</h6>
<p className="mb-0">
<FormattedMessage
@@ -72,7 +73,7 @@ const ReviewRequirementsPanel = (props) => {
defaultMessage="You need a device that has a camera. If you receive a browser prompt for access to your camera, please make sure to click {allow}."
description="Text explaining that the user needs access to a camera."
values={{
allow: <strong>{props.intl.formatMessage(messages['id.verification.requirements.card.device.allow'])}</strong>,
allow: <strong>{intl.formatMessage(messages['id.verification.requirements.card.device.allow'])}</strong>,
}}
/>
</p>
@@ -81,37 +82,37 @@ const ReviewRequirementsPanel = (props) => {
<div className="card mb-4 shadow accent border-warning">
<div className="card-body">
<h6 aria-level="3">
{props.intl.formatMessage(messages['id.verification.requirements.card.id.title'])}
{intl.formatMessage(messages['id.verification.requirements.card.id.title'])}
</h6>
<p className="mb-0">
{props.intl.formatMessage(messages['id.verification.requirements.card.id.text'])}
{intl.formatMessage(messages['id.verification.requirements.card.id.text'])}
<img
src={exampleCard}
alt={props.intl.formatMessage(messages['id.verification.example.card.alt'])}
alt={intl.formatMessage(messages['id.verification.example.card.alt'])}
/>
</p>
</div>
</div>
<h4 aria-level="2" className="mb-3">
{props.intl.formatMessage(messages['id.verification.privacy.title'])}
{intl.formatMessage(messages['id.verification.privacy.title'])}
</h4>
<h6 aria-level="3">
{props.intl.formatMessage(
{intl.formatMessage(
messages['id.verification.privacy.need.photo.question'],
{ siteName: getConfig().SITE_NAME },
)}
</h6>
<p>
{props.intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}
{intl.formatMessage(messages['id.verification.privacy.need.photo.answer'])}
</p>
<h6 aria-level="3">
{props.intl.formatMessage(
{intl.formatMessage(
messages['id.verification.privacy.do.with.photo.question'],
{ siteName: getConfig().SITE_NAME },
)}
</h6>
<p>
{props.intl.formatMessage(
{intl.formatMessage(
messages['id.verification.privacy.do.with.photo.answer'],
{ siteName: getConfig().SITE_NAME },
)}
@@ -119,15 +120,11 @@ const ReviewRequirementsPanel = (props) => {
<div className="action-row">
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
{intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
</BasePanel>
);
};
ReviewRequirementsPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ReviewRequirementsPanel);
export default ReviewRequirementsPanel;

View File

@@ -1,7 +1,7 @@
import React, { useContext, useEffect } from 'react';
import { useContext, useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useRedirect } from '../../hooks';
@@ -10,10 +10,11 @@ import messages from '../IdVerification.messages';
import BasePanel from './BasePanel';
const SubmittedPanel = (props) => {
const SubmittedPanel = () => {
const { userId } = useContext(IdVerificationContext);
const { location: returnUrl, text: returnText } = useRedirect();
const panelSlug = 'submitted';
const intl = useIntl();
useEffect(() => {
sendTrackEvent('edx.id_verification.submitted', {
@@ -25,24 +26,20 @@ const SubmittedPanel = (props) => {
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.submitted.title'])}
title={intl.formatMessage(messages['id.verification.submitted.title'])}
>
<p>
{props.intl.formatMessage(messages['id.verification.submitted.text'])}
{intl.formatMessage(messages['id.verification.submitted.text'])}
</p>
<a
className="btn btn-primary"
href={`${getConfig().LMS_BASE_URL}/${returnUrl}`}
data-testid="return-button"
>
{props.intl.formatMessage(messages[returnText])}
{intl.formatMessage(messages[returnText])}
</a>
</BasePanel>
);
};
SubmittedPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(SubmittedPanel);
export default SubmittedPanel;

View File

@@ -1,10 +1,10 @@
import React, { useState, useContext, useEffect } from 'react';
import { useState, useContext, useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
Alert, Hyperlink, Form, Button, Spinner,
} from '@openedx/paragon';
import { Link, useNavigate } from 'react-router-dom';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { submitIdVerification } from '../data/service';
import { useNextPanelSlug } from '../routing-utilities';
@@ -16,7 +16,8 @@ import messages from '../IdVerification.messages';
import CameraHelpWithUpload from '../CameraHelpWithUpload';
import SupportedMediaTypes from '../SupportedMediaTypes';
const SummaryPanel = (props) => {
const SummaryPanel = () => {
const intl = useIntl();
const panelSlug = 'summary';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const {
@@ -51,7 +52,7 @@ const SummaryPanel = (props) => {
profileDataManager,
support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
{props.intl.formatMessage(messages['id.verification.support'])}
{intl.formatMessage(messages['id.verification.support'])}
</Hyperlink>
),
}}
@@ -90,12 +91,11 @@ const SummaryPanel = (props) => {
};
return (
<Button
title="Confirmation"
disabled={isSubmitting}
onClick={handleClick}
data-testid="submit-button"
>
{props.intl.formatMessage(messages['id.verification.review.confirm'])}
{intl.formatMessage(messages['id.verification.review.confirm'])}
</Button>
);
};
@@ -103,18 +103,18 @@ const SummaryPanel = (props) => {
function getError() {
if (submissionError.status === 400) {
if (submissionError.message.includes('face_image')) {
return props.intl.formatMessage(messages['id.verification.submission.alert.error.face']);
return intl.formatMessage(messages['id.verification.submission.alert.error.face']);
}
if (submissionError.message.includes('Photo ID image')) {
return props.intl.formatMessage(messages['id.verification.submission.alert.error.id']);
return intl.formatMessage(messages['id.verification.submission.alert.error.id']);
}
if (submissionError.message.includes('Name')) {
return props.intl.formatMessage(messages['id.verification.submission.alert.error.name']);
return intl.formatMessage(messages['id.verification.submission.alert.error.name']);
}
if (submissionError.message.includes('unsupported format')) {
return (
<>
{props.intl.formatMessage(messages['id.verification.submission.alert.error.unsupported'])}
{intl.formatMessage(messages['id.verification.submission.alert.error.unsupported'])}
<SupportedMediaTypes />
</>
);
@@ -131,7 +131,7 @@ const SummaryPanel = (props) => {
values={{
support_link: (
<Alert.Link href="https://support.edx.org/hc/en-us">
{props.intl.formatMessage(
{intl.formatMessage(
messages['id.verification.review.error'],
{ siteName: getConfig().SITE_NAME },
)}
@@ -145,7 +145,7 @@ const SummaryPanel = (props) => {
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.review.title'])}
title={intl.formatMessage(messages['id.verification.review.title'])}
>
{submissionError && (
<Alert
@@ -158,17 +158,17 @@ const SummaryPanel = (props) => {
</Alert>
)}
<p>
{props.intl.formatMessage(messages['id.verification.review.description'])}
{intl.formatMessage(messages['id.verification.review.description'])}
</p>
<div className="row mb-4">
<div className="col-6">
<label htmlFor="photo-of-face" className="font-weight-bold">
{props.intl.formatMessage(messages['id.verification.review.portrait.label'])}
{intl.formatMessage(messages['id.verification.review.portrait.label'])}
</label>
<ImagePreview
id="photo-of-face"
src={facePhotoFile}
alt={props.intl.formatMessage(messages['id.verification.review.portrait.alt'])}
alt={intl.formatMessage(messages['id.verification.review.portrait.alt'])}
/>
<Link
className="btn btn-outline-primary"
@@ -176,17 +176,17 @@ const SummaryPanel = (props) => {
state={{ fromSummary: true }}
data-testid="portrait-retake"
>
{props.intl.formatMessage(messages['id.verification.review.portrait.retake'])}
{intl.formatMessage(messages['id.verification.review.portrait.retake'])}
</Link>
</div>
<div className="col-6">
<label htmlFor="photo-of-id/edit" className="font-weight-bold">
{props.intl.formatMessage(messages['id.verification.review.id.label'])}
{intl.formatMessage(messages['id.verification.review.id.label'])}
</label>
<ImagePreview
id="photo-of-id"
src={idPhotoFile}
alt={props.intl.formatMessage(messages['id.verification.review.id.alt'])}
alt={intl.formatMessage(messages['id.verification.review.id.alt'])}
/>
<Link
className="btn btn-outline-primary"
@@ -194,14 +194,14 @@ const SummaryPanel = (props) => {
state={{ fromSummary: true }}
data-testid="id-retake"
>
{props.intl.formatMessage(messages['id.verification.review.id.retake'])}
{intl.formatMessage(messages['id.verification.review.id.retake'])}
</Link>
</div>
</div>
<CameraHelpWithUpload />
<div className="form-group">
<label htmlFor="name-to-be-used" className="font-weight-bold">
{props.intl.formatMessage(messages['id.verification.name.label'])}
{intl.formatMessage(messages['id.verification.name.label'])}
</label>
{renderManagedProfileMessage()}
<div className="d-flex">
@@ -237,8 +237,4 @@ const SummaryPanel = (props) => {
);
};
SummaryPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(SummaryPanel);
export default SummaryPanel;

View File

@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
@@ -14,11 +14,12 @@ import ImageFileUpload from '../ImageFileUpload';
import CollapsibleImageHelp from '../CollapsibleImageHelp';
import SupportedMediaTypes from '../SupportedMediaTypes';
const TakeIdPhotoPanel = (props) => {
const TakeIdPhotoPanel = () => {
const panelSlug = 'take-id-photo';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const { setIdPhotoFile, idPhotoFile, useCameraForId } = useContext(IdVerificationContext);
const [mounted, setMounted] = useState(false);
const intl = useIntl();
useEffect(() => {
// This prevents focus switching to the heading when taking a photo
@@ -30,31 +31,31 @@ const TakeIdPhotoPanel = (props) => {
name={panelSlug}
focusOnMount={!mounted}
title={useCameraForId
? props.intl.formatMessage(messages['id.verification.id.photo.title.camera'])
: props.intl.formatMessage(messages['id.verification.id.photo.title.upload'])}
? intl.formatMessage(messages['id.verification.id.photo.title.camera'])
: intl.formatMessage(messages['id.verification.id.photo.title.upload'])}
>
<div>
{idPhotoFile && !useCameraForId && (
<ImagePreview
src={idPhotoFile}
alt={props.intl.formatMessage(messages['id.verification.id.photo.preview.alt'])}
alt={intl.formatMessage(messages['id.verification.id.photo.preview.alt'])}
/>
)}
{useCameraForId ? (
<div>
<p>
{props.intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])}
{intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])}
</p>
<Camera onImageCapture={setIdPhotoFile} isPortrait={false} />
</div>
) : (
<div style={{ marginBottom: '1.25rem' }}>
<p data-testid="upload-text">
{props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
{intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])}
<SupportedMediaTypes />
</p>
<ImageFileUpload onFileChange={setIdPhotoFile} intl={props.intl} />
<ImageFileUpload onFileChange={setIdPhotoFile} />
</div>
)}
</div>
@@ -62,15 +63,11 @@ const TakeIdPhotoPanel = (props) => {
<CollapsibleImageHelp />
<div className="action-row" style={{ visibility: idPhotoFile ? 'unset' : 'hidden' }}>
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
{intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
</BasePanel>
);
};
TakeIdPhotoPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TakeIdPhotoPanel);
export default TakeIdPhotoPanel;

View File

@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
@@ -10,11 +10,12 @@ import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages';
const TakePortraitPhotoPanel = (props) => {
const TakePortraitPhotoPanel = () => {
const panelSlug = 'take-portrait-photo';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const { setFacePhotoFile, facePhotoFile } = useContext(IdVerificationContext);
const [mounted, setMounted] = useState(false);
const intl = useIntl();
useEffect(() => {
// This prevents focus switching to the heading when taking a photo
@@ -25,26 +26,22 @@ const TakePortraitPhotoPanel = (props) => {
<BasePanel
name={panelSlug}
focusOnMount={!mounted}
title={props.intl.formatMessage(messages['id.verification.portrait.photo.title.camera'])}
title={intl.formatMessage(messages['id.verification.portrait.photo.title.camera'])}
>
<div>
<p>
{props.intl.formatMessage(messages['id.verification.portrait.photo.instructions.camera'])}
{intl.formatMessage(messages['id.verification.portrait.photo.instructions.camera'])}
</p>
<Camera onImageCapture={setFacePhotoFile} isPortrait />
</div>
<CameraHelp isPortrait />
<div className="action-row" style={{ visibility: facePhotoFile ? 'unset' : 'hidden' }}>
<Link to={`/id-verification/${nextPanelSlug}`} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
{intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
</BasePanel>
);
};
TakePortraitPhotoPanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TakePortraitPhotoPanel);
export default TakePortraitPhotoPanel;

View File

@@ -1,19 +1,21 @@
import PropTypes from 'prop-types';
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../IdVerification.messages';
export const UnsupportedCameraDirectionsPanel = (props) => (
<>
{props.browserName === 'Chrome' && <span>{props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.chrome.explanation'])}</span>}
<span> </span>
<span>{props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.instructions'])}</span>
</>
);
export const UnsupportedCameraDirectionsPanel = (props) => {
const intl = useIntl();
return (
<>
{props.browserName === 'Chrome' && <span>{intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.chrome.explanation'])}</span>}
<span> </span>
<span>{intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.instructions'])}</span>
</>
);
};
UnsupportedCameraDirectionsPanel.propTypes = {
intl: intlShape.isRequired,
browserName: PropTypes.string.isRequired,
};
export default injectIntl(UnsupportedCameraDirectionsPanel);
export default UnsupportedCameraDirectionsPanel;

View File

@@ -4,16 +4,13 @@ import {
render, cleanup, act, screen,
} from '@testing-library/react';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { ERROR_REASONS } from '../IdVerificationContext';
import AccessBlocked from '../AccessBlocked';
const IntlAccessBlocked = injectIntl(AccessBlocked);
describe('AccessBlocked', () => {
const defaultProps = {
intl: {},
error: '',
};
@@ -27,7 +24,7 @@ describe('AccessBlocked', () => {
await act(async () => render((
<Router>
<IntlProvider locale="en">
<IntlAccessBlocked {...defaultProps} />
<AccessBlocked {...defaultProps} />
</IntlProvider>
</Router>
)));
@@ -43,7 +40,7 @@ describe('AccessBlocked', () => {
await act(async () => render((
<Router>
<IntlProvider locale="en">
<IntlAccessBlocked {...defaultProps} />
<AccessBlocked {...defaultProps} />
</IntlProvider>
</Router>
)));
@@ -59,7 +56,7 @@ describe('AccessBlocked', () => {
await act(async () => render((
<Router>
<IntlProvider locale="en">
<IntlAccessBlocked {...defaultProps} />
<AccessBlocked {...defaultProps} />
</IntlProvider>
</Router>
)));

View File

@@ -5,6 +5,7 @@ import {
render, cleanup, screen, act, fireEvent,
} from '@testing-library/react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import CameraPhoto from 'jslib-html5-camera-photo';
// eslint-disable-next-line import/no-unresolved
import * as blazeface from '@tensorflow-models/blazeface';
import * as analytics from '@edx/frontend-platform/analytics';
@@ -181,4 +182,99 @@ describe('SubmittedPanel', () => {
await fireEvent.click(checkbox);
expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.id_photo.face_detection_disabled');
});
describe('Camera getSizeFactor method', () => {
let mockGetDataUri;
beforeEach(() => {
jest.clearAllMocks();
mockGetDataUri = jest.fn().mockReturnValue('data:image/jpeg;base64,test');
});
it('scales down large resolutions to stay under 10MB limit', async () => {
const currentSettings = { width: 4000, height: 3000 };
CameraPhoto.mockImplementation(() => ({
startCamera: jest.fn(),
stopCamera: jest.fn(),
getDataUri: mockGetDataUri,
getCameraSettings: jest.fn().mockReturnValue(currentSettings),
}));
await act(async () => render((
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByRole('button', { name: /take photo/i });
fireEvent.click(button);
// For large resolution: size = 4000 * 3000 * 3 = 36,000,000 bytes
// Ratio = 9,999,999 / 36,000,000 ≈ 0.278
expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({
sizeFactor: expect.closeTo(0.278, 2),
}));
});
it('scales up 640x480 resolution to improve quality', async () => {
const currentSettings = { width: 640, height: 480 };
CameraPhoto.mockImplementation(() => ({
startCamera: jest.fn(),
stopCamera: jest.fn(),
getDataUri: mockGetDataUri,
getCameraSettings: jest.fn().mockReturnValue(currentSettings),
}));
await act(async () => render((
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByRole('button', { name: /take photo/i });
fireEvent.click(button);
expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({
sizeFactor: 2,
}));
});
it('maintains original size for medium resolutions', async () => {
const currentSettings = { width: 1280, height: 720 };
CameraPhoto.mockImplementation(() => ({
startCamera: jest.fn(),
stopCamera: jest.fn(),
getDataUri: mockGetDataUri,
getCameraSettings: jest.fn().mockReturnValue(currentSettings),
}));
await act(async () => render((
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByRole('button', { name: /take photo/i });
fireEvent.click(button);
expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({
sizeFactor: 1,
}));
});
});
});

View File

@@ -1,9 +1,8 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
render, cleanup, screen, act,
} from '@testing-library/react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import * as analytics from '@edx/frontend-platform/analytics';
import IdVerificationContext from '../IdVerificationContext';
import CollapsibleImageHelp from '../CollapsibleImageHelp';
@@ -17,11 +16,7 @@ analytics.sendTrackEvent = jest.fn();
window.HTMLMediaElement.prototype.play = () => {};
const IntlCollapsible = injectIntl(CollapsibleImageHelp);
describe('CollapsibleImageHelpPanel', () => {
const defaultProps = { intl: {} };
const contextValue = {
useCameraForId: true,
setUseCameraForId: jest.fn(),
@@ -36,7 +31,7 @@ describe('CollapsibleImageHelpPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCollapsible {...defaultProps} />
<CollapsibleImageHelp />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -56,7 +51,7 @@ describe('CollapsibleImageHelpPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCollapsible {...defaultProps} />
<CollapsibleImageHelp />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>

View File

@@ -1,13 +1,12 @@
/* eslint-disable react/jsx-no-useless-fragment */
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import {
render, act, screen, fireEvent,
} from '@testing-library/react';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import IdVerificationPage from '../IdVerificationPage';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationPageSlot from '../../plugin-slots/IdVerificationPageSlot';
import * as selectors from '../data/selectors';
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ idVerificationSelector: () => ({}) })));
@@ -47,22 +46,18 @@ jest.mock('../panels/SubmittedPanel', () => function SubmittedPanelMock() {
return <></>;
});
const IntlIdVerificationPage = injectIntl(IdVerificationPage);
const mockStore = configureStore();
describe('IdVerificationPage', () => {
selectors.mockClear();
jest.spyOn(Storage.prototype, 'setItem');
const store = mockStore();
const props = {
intl: {},
};
it('decodes and stores course_id', async () => {
await act(async () => render((
<Router initialEntries={[`/?course_id=${encodeURIComponent('course-v1:edX+DemoX+Demo_Course')}`]}>
<IntlProvider locale="en">
<Provider store={store}>
<IntlIdVerificationPage {...props} />
<IdVerificationPageSlot />
</Provider>
</IntlProvider>
</Router>
@@ -78,7 +73,7 @@ describe('IdVerificationPage', () => {
<Router initialEntries={['/?next=dashboard']}>
<IntlProvider locale="en">
<Provider store={store}>
<IntlIdVerificationPage {...props} />
<IdVerificationPageSlot />
</Provider>
</IntlProvider>
</Router>
@@ -93,7 +88,7 @@ describe('IdVerificationPage', () => {
<Router initialEntries={['/?next=dashboard']}>
<IntlProvider locale="en">
<Provider store={store}>
<IntlIdVerificationPage {...props} />
<IdVerificationPageSlot />
</Provider>
</IntlProvider>
</Router>
@@ -107,7 +102,7 @@ describe('IdVerificationPage', () => {
<Router initialEntries={['/?next=dashboard']}>
<IntlProvider locale="en">
<Provider store={store}>
<IntlIdVerificationPage {...props} />
<IdVerificationPageSlot />
</Provider>
</IntlProvider>
</Router>

View File

@@ -1,10 +1,9 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import { VerifiedNameContext } from '../../VerifiedNameContext';
import GetNameIdPanel from '../../panels/GetNameIdPanel';
@@ -13,13 +12,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
const IntlGetNameIdPanel = injectIntl(GetNameIdPanel);
describe('GetNameIdPanel', () => {
const defaultProps = {
intl: {},
};
const IDVerificationContextValue = {
nameOnAccount: 'test',
userId: 3,
@@ -37,7 +30,7 @@ describe('GetNameIdPanel', () => {
<IntlProvider locale="en">
<VerifiedNameContext.Provider value={verifiedNameContextValue}>
<IdVerificationContext.Provider value={idVerificationContextValue}>
<IntlGetNameIdPanel {...defaultProps} />
<GetNameIdPanel />
</IdVerificationContext.Provider>
</VerifiedNameContext.Provider>
</IntlProvider>

View File

@@ -4,7 +4,7 @@ import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import IdContextPanel from '../../panels/IdContextPanel';
@@ -12,13 +12,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
const IntlIdContextPanel = injectIntl(IdContextPanel);
describe('IdContextPanel', () => {
const defaultProps = {
intl: {},
};
const contextValue = {
facePhotoFile: 'test.jpg',
reachedSummary: false,
@@ -33,7 +27,7 @@ describe('IdContextPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlIdContextPanel {...defaultProps} />
<IdContextPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -49,7 +43,7 @@ describe('IdContextPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlIdContextPanel {...defaultProps} />
<IdContextPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>

View File

@@ -1,10 +1,9 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import PortraitPhotoContextPanel from '../../panels/PortraitPhotoContextPanel';
import IdVerificationContext from '../../IdVerificationContext';
@@ -12,13 +11,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
const IntlPortraitPhotoContextPanel = injectIntl(PortraitPhotoContextPanel);
describe('PortraitPhotoContextPanel', () => {
const defaultProps = {
intl: {},
};
const contextValue = { reachedSummary: false };
afterEach(() => {
@@ -30,7 +23,7 @@ describe('PortraitPhotoContextPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlPortraitPhotoContextPanel {...defaultProps} />
<PortraitPhotoContextPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -46,7 +39,7 @@ describe('PortraitPhotoContextPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlPortraitPhotoContextPanel {...defaultProps} />
<PortraitPhotoContextPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>

View File

@@ -5,7 +5,7 @@ import {
render, screen, cleanup, act, fireEvent,
} from '@testing-library/react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import RequestCameraAccessPanel from '../../panels/RequestCameraAccessPanel';
@@ -15,13 +15,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
jest.mock('bowser');
const IntlRequestCameraAccessPanel = injectIntl(RequestCameraAccessPanel);
describe('RequestCameraAccessPanel', () => {
const defaultProps = {
intl: {},
};
const contextValue = {
reachedSummary: false,
tryGetUserMedia: jest.fn(),
@@ -38,7 +32,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -54,7 +48,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -73,7 +67,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -89,7 +83,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -106,7 +100,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -123,7 +117,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -139,7 +133,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -155,7 +149,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -171,7 +165,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -188,7 +182,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -205,7 +199,7 @@ describe('RequestCameraAccessPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
<RequestCameraAccessPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>

View File

@@ -4,7 +4,7 @@ import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import ReviewRequirementsPanel from '../../panels/ReviewRequirementsPanel';
@@ -12,13 +12,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
const IntlReviewRequirementsPanel = injectIntl(ReviewRequirementsPanel);
describe('ReviewRequirementsPanel', () => {
const defaultProps = {
intl: {},
};
const context = {};
const getPanel = async () => {
@@ -26,7 +20,7 @@ describe('ReviewRequirementsPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={context}>
<IntlReviewRequirementsPanel {...defaultProps} />
<ReviewRequirementsPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>

View File

@@ -1,10 +1,9 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
render, cleanup, act, screen,
} from '@testing-library/react';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import SubmittedPanel from '../../panels/SubmittedPanel';
@@ -12,13 +11,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
const IntlSubmittedPanel = injectIntl(SubmittedPanel);
describe('SubmittedPanel', () => {
const defaultProps = {
intl: {},
};
const contextValue = {
facePhotoFile: 'test.jpg',
idPhotoFile: 'test.jpg',
@@ -43,7 +36,7 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlSubmittedPanel {...defaultProps} />
<SubmittedPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -59,7 +52,7 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlSubmittedPanel {...defaultProps} />
<SubmittedPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -75,7 +68,7 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlSubmittedPanel {...defaultProps} />
<SubmittedPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>

View File

@@ -1,11 +1,10 @@
/* eslint-disable no-import-assign */
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
render, cleanup, act, screen, fireEvent, waitFor,
} from '@testing-library/react';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import * as dataService from '../../data/service';
import IdVerificationContext from '../../IdVerificationContext';
import SummaryPanel from '../../panels/SummaryPanel';
@@ -18,13 +17,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
jest.mock('../../data/service');
dataService.submitIdVerification = jest.fn().mockReturnValue({ success: true });
const IntlSummaryPanel = injectIntl(SummaryPanel);
describe('SummaryPanel', () => {
const defaultProps = {
intl: {},
};
const appContextValue = {
facePhotoFile: 'test.jpg',
idPhotoFile: 'test.jpg',
@@ -42,7 +35,7 @@ describe('SummaryPanel', () => {
<IntlProvider locale="en">
<VerifiedNameContext.Provider value={verifiedNameContextValue}>
<IdVerificationContext.Provider value={appContextValue}>
<IntlSummaryPanel {...defaultProps} />
<SummaryPanel />
</IdVerificationContext.Provider>
</VerifiedNameContext.Provider>
</IntlProvider>

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import TakeIdPhotoPanel from '../../panels/TakeIdPhotoPanel';
import messages from '../../IdVerification.messages';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
@@ -13,13 +13,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({
jest.mock('../../Camera');
const IntlTakeIdPhotoPanel = injectIntl(TakeIdPhotoPanel);
describe('TakeIdPhotoPanel', () => {
const defaultProps = {
intl: {},
};
const contextValue = {
facePhotoFile: 'test.jpg',
idPhotoFile: null,
@@ -37,7 +31,7 @@ describe('TakeIdPhotoPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlTakeIdPhotoPanel {...defaultProps} />
<TakeIdPhotoPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -52,7 +46,7 @@ describe('TakeIdPhotoPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlTakeIdPhotoPanel {...defaultProps} />
<TakeIdPhotoPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -70,7 +64,7 @@ describe('TakeIdPhotoPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlTakeIdPhotoPanel {...defaultProps} />
<TakeIdPhotoPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -85,7 +79,7 @@ describe('TakeIdPhotoPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlTakeIdPhotoPanel {...defaultProps} />
<TakeIdPhotoPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -98,4 +92,24 @@ describe('TakeIdPhotoPanel', () => {
const text = await screen.findByTestId('upload-text');
expect(text.textContent).toContain('Please upload a photo of your identification card');
});
it('shows correct text if useCameraForId', async () => {
contextValue.useCameraForId = true;
await act(async () => render((
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<TakeIdPhotoPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
// check that upload title and text are correct
const title = await screen.findByText(messages['id.verification.id.photo.title.camera'].defaultMessage);
expect(title).toBeVisible();
const text = await screen.findByText(messages['id.verification.id.photo.instructions.camera'].defaultMessage);
expect(text).toBeVisible();
});
});

View File

@@ -1,10 +1,9 @@
/* eslint-disable react/jsx-no-useless-fragment */
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import TakePortraitPhotoPanel from '../../panels/TakePortraitPhotoPanel';
@@ -16,13 +15,7 @@ jest.mock('../../Camera', () => function CameraMock() {
return <></>;
});
const IntlTakePortraitPhotoPanel = injectIntl(TakePortraitPhotoPanel);
describe('TakePortraitPhotoPanel', () => {
const defaultProps = {
intl: {},
};
const contextValue = {
facePhotoFile: null,
idPhotoFile: null,
@@ -39,7 +32,7 @@ describe('TakePortraitPhotoPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlTakePortraitPhotoPanel {...defaultProps} />
<TakePortraitPhotoPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -54,7 +47,7 @@ describe('TakePortraitPhotoPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlTakePortraitPhotoPanel {...defaultProps} />
<TakePortraitPhotoPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -73,7 +66,7 @@ describe('TakePortraitPhotoPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlTakePortraitPhotoPanel {...defaultProps} />
<TakePortraitPhotoPanel />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>

View File

@@ -6,12 +6,13 @@ import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import {
subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig,
} from '@edx/frontend-platform';
import React from 'react';
import ReactDOM from 'react-dom';
import React, { StrictMode } from 'react';
// eslint-disable-next-line import/no-unresolved
import { createRoot } from 'react-dom/client';
import { Route, Routes, Outlet } from 'react-router-dom';
import Header from '@edx/frontend-component-header';
import FooterSlot from '@openedx/frontend-slot-footer';
import { FooterSlot } from '@edx/frontend-component-footer';
import configureStore from './data/configureStore';
import AccountSettingsPage, { NotFoundPage } from './account-settings';
@@ -20,42 +21,40 @@ import messages from './i18n';
import './index.scss';
import Head from './head/Head';
import NotificationCourses from './notification-preferences/NotificationCourses';
import NotificationPreferences from './notification-preferences/NotificationPreferences';
const rootNode = createRoot(document.getElementById('root'));
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={configureStore()}>
<Head />
<Routes>
<Route element={(
<div className="d-flex flex-column" style={{ minHeight: '100vh' }}>
<Header />
<main className="flex-grow-1" id="main">
<Outlet />
</main>
<FooterSlot />
</div>
rootNode.render(
<StrictMode>
<AppProvider store={configureStore()}>
<Head />
<Routes>
<Route element={(
<div className="d-flex flex-column" style={{ minHeight: '100vh' }}>
<Header />
<main className="flex-grow-1" id="main">
<Outlet />
</main>
<FooterSlot />
</div>
)}
>
<Route path="/notifications/:courseId" element={<NotificationPreferences />} />
<Route path="/notifications" element={<NotificationCourses />} />
<Route
path="/id-verification/*"
element={<IdVerificationPageSlot />}
/>
<Route path="/" element={<AccountSettingsPage />} />
<Route path="/notfound" element={<NotFoundPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</AppProvider>,
document.getElementById('root'),
>
<Route
path="/id-verification/*"
element={<IdVerificationPageSlot />}
/>
<Route path="/" element={<AccountSettingsPage />} />
<Route path="/notfound" element={<NotFoundPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</AppProvider>
</StrictMode>,
);
});
subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
rootNode.render(<ErrorPage message={error.message} />);
});
initialize({
@@ -66,9 +65,11 @@ initialize({
config: () => {
mergeConfig({
SUPPORT_URL: process.env.SUPPORT_URL,
SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_CHANNEL || 'false',
SHOW_PUSH_CHANNEL: process.env.SHOW_PUSH_CHANNEL || false,
SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_CHANNEL || false,
ENABLE_COPPA_COMPLIANCE: (process.env.ENABLE_COPPA_COMPLIANCE || false),
ENABLE_ACCOUNT_DELETION: (process.env.ENABLE_ACCOUNT_DELETION !== 'false'),
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: JSON.parse(process.env.COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED || '[]'),
ENABLE_DOB_UPDATE: (process.env.ENABLE_DOB_UPDATE || false),
MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false),
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK,

View File

@@ -1,10 +1,8 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";
@@ -118,9 +116,9 @@ $fa-font-path: "~font-awesome/fonts";
}
.dropdown-item:active,
.dropdown-item:focus,
.dropdown-item:focus,
.btn-tertiary:not(:disabled):not(.disabled).active {
background-color: $light-300 !important;
background-color: var(--pgn-color-light-300) !important;
}
@@ -131,6 +129,20 @@ $fa-font-path: "~font-awesome/fonts";
.h-4\.5 {
height: 36px;
}
.course-dropdown{
#course-dropdown-btn {
width: 100%;
font-size: 14px !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
border: 1px solid var(--pgn-color-light-500) !important;
}
.dropdown-item {
font-size: 14px !important;
}
}
}
.usabilla_live_button_container {

Some files were not shown because too many files have changed in this diff Show More